Commit 657b6b7
feat(objectql,metadata-protocol): validate and insertMany answer which row lost which field (#21041)
Fixes #20922
Clause-②: yes (widening)
## What changes
The dry run and the partial-success batch insert now say which row lost
which field.
- **Dry run.** `ObjectQL.validate` answers `droppedFields` on each
accepted row of `results`: the fields the write would strip from that
row, one `DroppedFieldsEvent` per reason. `validateData` relays the
verdict, so its answer now fills
`ValidateDataResponseSchema.results[].droppedFields`.
- **Commit.** `ObjectQL.insertMany` answers `droppedFields` on each `ok`
outcome: the fields stripped from that row. `insertManyData` passes the
outcomes through, and its declared return type now names the key.
- The key is absent when nothing was taken from the row. A preview row
the verdict refuses, and an `ok: false` outcome, carry none: a drop
means the write completed without the field (the
`DroppedFieldsEventSchema` contract), and the write does not complete
that row.
## How
- **Row attribution is recorded at the strips.**
`stripComputedWriteFields` now also returns `droppedPerRow` (the keys
taken from each row, index-aligned). The caller-write strips
(`stripRuntimeOwnedFields`, the static `readonly` strip, and
`stripReadonlyFields` on an `update`-mode preview) record each taken key
into the existing union and into the taking row's own list, in the same
step. Nothing is reconstructed from the union afterwards. That matters
because a `beforeInsert` hook can exempt a key on one row and not
another (`hookWrittenKeys`), so "which rows supplied N" is not "which
rows dropped N".
- **One builder for both channels.** `droppedFieldEvents(object,
computed, readonly)` turns a strip result into events: one per reason,
`computed` before `readonly`, the order the strips run. It builds the
batch-level union events (`validate`'s listener, `insert`'s
`insertDrops`) and the per-row lists, so the two cannot disagree on a
reason or its order. There is no second strip and no second reason
vocabulary.
- **The batch-level union is unchanged.** The `onFieldsDropped` events
of `insert`, `insertMany` and `validate` are still one per reason,
naming no row, built from the same union arrays in the same order.
`insertManyData`'s top-level `droppedFields` and both union docblocks
(`engine.ts` `insertMany`, `protocol.ts` `insertManyData`) are
byte-identical. The per-row channel is documented on
`InsertManyRowOutcome` and at the code points instead.
- **Untouched:** `insert(object, rows[])` still returns the records with
no per-row slot. `strictReadonlyWrites` still refuses the whole batch
before any outcome is built. The engine's aggregate door and the
packaged-base door in `protocol.ts` are not touched.
## Spec docblocks (two TSDoc sentences, no schema change)
- `packages/spec/src/api/protocol.zod.ts`, the
`ValidateDataResponseSchema` docblock: "the engine's `validate` already
runs the write's strips ... no server sets the key until it does" now
says what the producer does. `validate` runs the computed-field strip
and the static `readonly` / runtime-owned strips, records what each
takes per row, and reports it on an accepted row. `validateData` relays
it. An `update`-mode preview does not run the `readonlyWhen` or
primary-key strips, so it can report fewer drops than the update it
predicts.
- `packages/spec/src/api/export.zod.ts`, the `ImportRowResultSchema`
docblock: "pinned at the wire" now names what is pinned. The synchronous
route is pinned by `import-dryrun-parity.test.ts`, and no test reads
`warnings` off the async job's results.
- Both are TSDoc comments, not `.describe()`. `check:generated` reports
all 15 artifacts up to date after a spec build. The diff touches
`packages/spec/src/**`, so the landing owes an at-tier contract review.
## Premise checks (measured at `9b0de7de7` before the first edit)
- **H1 holds.** `engine.validate` built `computedStrip.dropped` and
`readonlyDropped` across all rows and emitted one event per reason. It
already built per-row `results`, and the per-row channel now rides them.
- **H2 holds.** `insertManyData` calls `engine.insertMany`, which is
`insert(…, { __partialRowErrors: true })`. The outcomes are assembled in
`insert`'s partial-mode branch, and that is where the per-outcome report
is attached, from the strips' own record. `insertManyData` adds nothing.
- **H3, insert mode: no fork.** An `insert`-mode preview runs the same
three strips the write runs (computed, runtime-owned, static `readonly`
with its re-default), in the same order and under the same `isSystem`
gate. A row's preview drops therefore equal its outcome drops (pinned
row for row). The one known difference is the documented "no hooks run"
gap: a key a `beforeInsert` hook assigns is reported by the preview and
kept by the write.
- **H3, update mode: a fork, not settled here.** An `update`-mode
preview runs no `readonlyWhen` or primary-key strip, while the update it
predicts does. The import route previews an upsert's matched rows in
`update` mode and commits them through `updateData`. On a row whose
`readonlyWhen` is true (for example the showcase invoice's `tax_rate`
once `status == 'paid'`), the dry run names nothing for that field and
the update drops it under `readonly_when`. This PR keeps the existing
named limit, and the spec docblock now states it. The options and their
costs are in the report on the card.
- **H4.** Both sentences are TSDoc, so no generated artifact moved.
## Tests
The tests ran at `24ca898f8` / `99a7547af`. HEAD `886ad2c43` adds only a
merge of `origin/main`, whose three commits touch none of `objectql`,
`metadata-protocol`, `spec` or `rest`. The gate union ran at
`886ad2c43`.
- New `packages/objectql/src/engine-per-row-dropped-fields.test.ts` (14
cases, recording driver). It pins a formula column (`computed`) and a
`readonly` column (`readonly`) through the dry run, the commit, and both
relayed through the real protocol (`validateData`, `insertManyData`). A
clean row carries none (the control), and the batch-level union is
unchanged. A refused row and a failed outcome carry none. A
hook-exempted row is not named. Under `isSystem`, only the computed
strip reports. `update`-mode previews report per row too.
- `engine-autonumber-runtime-owned.test.ts`: the case that pinned "names
no row" against the real engine now pins the row that lost
`account_number` on its own outcome, beside the unchanged union.
- `protocol.dropped-fields.bulk.test.ts`: a new case checks that an
engine-attributed outcome passes through as answered. The existing cases
still pin that the seam derives nothing from the union.
- `pnpm --filter @objectstack/objectql exec vitest run --project local
--maxWorkers=2`: 349 files, 6835 tests passed.
- `pnpm --filter @objectstack/metadata-protocol exec vitest run
--maxWorkers=2`: 195 passed / 3 skipped files, 2897 passed / 19 skipped
tests.
- `pnpm --filter @objectstack/objectql --filter
@objectstack/metadata-protocol run typecheck`: exit 0
(`check:test-typecheck` OK, ledger unchanged).
- Downstream consumers, against rebuilt `dist/`: all 25
`packages/rest/src/import-*.test.ts` files (622 passed, 21 skipped), and
four `plugin-security` preview/write tests (132 passed).
- **Ablation A1** (via `scripts/ablation-replace.mjs`, which landed and
restored the mutation on disk; the test reads `engine.ts` from `src`):
the `insertMany` outcome was given the batch union instead of its row's
list. Result: 4 failed / 10 passed. The tree was restored to the HEAD
blob, and `git diff HEAD` was empty.
- **Ablation A2**: the same on `validate`'s rows. Result: 5 failed / 9
passed, then restored.
- **Reverse check of the cross-package type read**: a typo key on
`insertManyData`'s outcome (read through
`@objectstack/metadata-protocol`'s rebuilt `.d.ts`) turned
`check:test-typecheck` red (1 type error in the new file). It was then
restored.
## Gates
- `node scripts/pm/dispatch-gates.mjs --commands` was derived at
`886ad2c43` and named 89 families. All were run: 87 exited 0, and 2
exited 3. Reconciled with `--ran`: "89 derived famil(ies) accounted for,
87 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)".
- NOT MEASURED: `check:dual-build-cjs-loads`, `check:type-check-debt`.
Reason: PREREQUISITE NOT MET, because both read a full workspace build
this worktree does not have. CI builds it.
- NOT MEASURED locally: `packages/runtime` integration tier
(`undeclared-field-write-driver-split.integration.test.ts` calls
`validate`). Reason: 16 packages in runtime's closure are unbuilt here.
It makes no deep-equality assertion on `results`. Declared to CI.
- eslint, narrowed to the 7 touched `.ts` files with `--no-inline-config
--format json`: 7 files, 0 errors, 0 warnings. Each file is inside the
config's population (`--print-config` resolves it, and no "file ignored"
warning). The narrowing excludes nothing: `eslint.config.mjs` enables no
type-aware linting for any file (its own note near line 327), so this
diff cannot move a verdict on an untouched file.
## Changeset
`.changeset/20922-per-row-dropped-fields.md` grades
`@objectstack/objectql: minor` and `@objectstack/metadata-protocol:
minor` by the WHICH LEVEL rule. Each widens a published method's
declared answer with a new optional key: `InsertManyRowOutcome` gains
`droppedFields`, and so does each outcome of `insertManyData`'s return
type. That is an additive widening of the public surface. Nothing is
removed, renamed or refused. The spec edits are comments, so the spec
carries no entry.
## Acceptance notes
- The `ImportRowResultSchema` docblock's `droppedFields` bullet still
reads "The engine has to report drops per row out of `validateData` and
`insertMany`, and the REST import route has to copy them onto the row;
until both do, no server sets the key". This stays true until the REST
half copies the report, and the engine half is now done. The PR that
lands #20701's REST item rewrites it. It is out of this card's
two-sentence spec scope.
- `packages/rest/src/import-runner.ts`'s
`ImportProtocolLike.insertManyData` types its outcomes without
`droppedFields`. Widening it is the REST half's first step, and it is
not touched here.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent d1633f3 commit 657b6b7
8 files changed
Lines changed: 455 additions & 45 deletions
File tree
- .changeset
- packages
- metadata-protocol/src
- objectql/src
- spec/src/api
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
Lines changed: 28 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
141 | 141 | | |
142 | 142 | | |
143 | 143 | | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
144 | 149 | | |
145 | 150 | | |
146 | 151 | | |
| |||
250 | 255 | | |
251 | 256 | | |
252 | 257 | | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
253 | 281 | | |
254 | 282 | | |
255 | 283 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
11836 | 11836 | | |
11837 | 11837 | | |
11838 | 11838 | | |
| 11839 | + | |
| 11840 | + | |
| 11841 | + | |
| 11842 | + | |
| 11843 | + | |
| 11844 | + | |
11839 | 11845 | | |
11840 | 11846 | | |
11841 | 11847 | | |
| |||
13609 | 13615 | | |
13610 | 13616 | | |
13611 | 13617 | | |
13612 | | - | |
| 13618 | + | |
13613 | 13619 | | |
13614 | 13620 | | |
13615 | 13621 | | |
| |||
13623 | 13629 | | |
13624 | 13630 | | |
13625 | 13631 | | |
13626 | | - | |
| 13632 | + | |
| 13633 | + | |
| 13634 | + | |
| 13635 | + | |
| 13636 | + | |
| 13637 | + | |
13627 | 13638 | | |
13628 | 13639 | | |
13629 | 13640 | | |
| |||
Lines changed: 12 additions & 9 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
305 | 305 | | |
306 | 306 | | |
307 | 307 | | |
308 | | - | |
| 308 | + | |
309 | 309 | | |
310 | | - | |
311 | | - | |
312 | | - | |
313 | | - | |
314 | | - | |
315 | | - | |
316 | | - | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
317 | 317 | | |
318 | 318 | | |
319 | 319 | | |
| |||
323 | 323 | | |
324 | 324 | | |
325 | 325 | | |
326 | | - | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
327 | 330 | | |
328 | 331 | | |
329 | 332 | | |
| |||
0 commit comments