Repository navigation
fix(core)!: the import dry run previews create rows in the commit's batches (#26684) - #26778
Conversation
…tches The dry run asked the engine about each create row alone, so a child rule reading a parent roll-up (#25579) answered valid for a row the commit refuses. Create rows are now buffered like the commit's and previewed through one multi-row validate() per commit batch, in file order; update rows keep their per-row preview. The engine's multi-row preview now admits an insert row after the write's reference check, as insert() does. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5krDpBpMjEHKqjJ6u8
…mport's batches Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5krDpBpMjEHKqjJ6u8
…it's batches Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5krDpBpMjEHKqjJ6u8
…he header-read control counts the write's reference check Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5krDpBpMjEHKqjJ6u8
📓 Docs Drift CheckThis PR changes 2 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 38 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin fd8fc59514bcfaca6d079a3fbaf9c1185e3a22d3 && git checkout fd8fc59514bcfaca6d079a3fbaf9c1185e3a22d3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 785a7c3b3e31a669ef52e0d83bc18916d170ae9c 80b0a99e12f72ea46d99072cfd3820ee2d04b03b && git checkout -B drift-repro 785a7c3b3e31a669ef52e0d83bc18916d170ae9c && git merge --no-ff 80b0a99e12f72ea46d99072cfd3820ee2d04b03b
node scripts/docs-audit/affected-docs.mjs --json 785a7c3b3e31a669ef52e0d83bc18916d170ae9c
|
Contract reviewServed-tier: Face: narrowing. Card #26684, PR #26778, net diff ① Derived judgmentsD1 — the narrowing at the import dry run ( D2 — update-mode previews unchanged: right. The D3 — the commit path unchanged: right. The diff touches no line of D4 — triage D5 — the D6 — the per-row verdict shape: unchanged. D7 — the public surface of D8 — a boundary I read that the PR does not name, judged non-blocking (two observations, both erring toward refusing, neither reachable through the import door): (i) ② Semver levelThe changeset ③ Boundary flags
Check-runs on Implemented-by: VERDICT: PASS |
席位核对 · ACCEPT ·
|
| item | reading |
|---|---|
| Shape | Draft, base main, Fixes #26684 under triage 6442590531. 10 files: import-runner.ts and four of its tests, engine.ts (the multi-row validate() admit step) and its test, a REST dry-run test, fields.mdx, and a patch changeset for core and objectql with a BREAKING block and ADR-0087 not-required (no-migration-prescription). Clause-②: no (narrowing) and Metadata-protocol: none appear in both the body and the changeset. |
| Fix | The dry run previews create rows in the commit's own runs (Math.min(progressEvery, MAX_CREATE_BATCH_SIZE)), one validateData call per run, so the engine's batch-aware accumulation from #25579 applies; no rule or accumulation is re-implemented. The engine's validate() admit step now runs the write's assertReferencesResolve before counting a row, in the same order as insert(). The per-row verdict shape is unchanged. Update-mode previews and the commit path are unchanged. |
| Narrowing | A create row that a batch-aware parent-summary rule refuses inside its batch, and a create row with a dangling supplied reference, answered valid in the dry run on main and are refused at head, matching the commit. A single-row insert preview now also reports a dangling reference (declared in the changeset). |
| Limit (seat ruling A) | Two rows that conflict only across a run boundary still both preview valid; this is stated in fields.mdx and the changeset's "Not covered" line, as ruling 6446027005 requires. |
| Dogfood battery | Pinned to 80b0a99e12 (this head), full, no shard: 302 files and 2505 tests passed, declared skips only. review-checklist.md :50 is met. |
| Evidence | CI green on head 80b0a99e12 (40 success, 7 skipped, 0 failed). Governed check: NOT governed, 693 changed lines. Red first on main; two ablations turn the agreement tests red. 96 of 96 derived gates. |
| Non-blocking | The review notes the create arm now fails a row with ERR_BULK_RESULT_MISMATCH when a responder short-answers results (a contract-violating responder; the update arm keeps the old tolerance), the preview never runs normalizeMultiValueFields (pre-existing, not reachable through the import door), and a stale results[0] docblock at import-runner.ts:60. None blocks. |
| Landing | 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay, once every check on this head is green or an expected skip. |
Generated by Claude Code
Requested by Leehom · project thread
Fixes #26684
Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — no
skills/**text describes the import dry run or the insert preview (git grep -i -e 'dry run' -e dryRun skills/hits only the i18n CLI--dry-runflag andos migrate plan)维护者速读
packages/core/src/utils/import-runner.ts(previewCreateRun/flushPendingPreviews);packages/objectql/src/engine.ts的validate()新增引用检查那一段;packages/rest/src/import-dry-run-batch-parent-summary.test.ts。pnpm --filter @objectstack/rest exec vitest run src/import-dry-run-batch-parent-summary.test.ts,预期 9 个用例全绿,其中"两行主联系人"一例里试运行第二行失败、信息与正式导入相同。What changes
@objectstack/core—packages/core/src/utils/import-runner.ts. A dry run's CREATE rows are no longer asked about one at a time. They are buffered as the commit buffers its creates (pendingPreviews, besidependingCreates) and flushed at the commit's checkpoints (everyprogressEveryrows, and the end) byflushPendingPreviews, which splits them into the commit's runs (Math.min(progressEvery, MAX_CREATE_BATCH_SIZE), thebulkWritebatch size) and asks the engine about each run in ONEvalidateDatacall withdataas an array, in file order (previewCreateRun). The engine's multi-rowvalidate()(#26680) does the accumulation: ⛔ the runner neither reads nor judges a rule. Each row still gets its own report, built by the same code as before (reportPreview, lifted out of the row loop unchanged): samerow,ok,action,field,code,error,warnings,droppedFields, at the row's own position. The run's call carries the union of its rows' kept option values, as the commit's batch call does (writeCtxFor). UPDATE rows keep their per-row preview (previewVerdict, one row object,mode: 'update'), unchanged.A run whose call throws, or answers other than one verdict per row, is degraded as the commit degrades a failed batch: each row is asked alone (so a row the engine refuses outright, such as an undeclared column, carries its own refusal, as
insertManyDataculls it per row), then the rows that survived alone are asked together again so they still see each other.@objectstack/objectql—ObjectQL.validate()(the seat's addendum 6442401477). The insert preview's admit step now matchesinsert()'s: after the rules, the write's reference check (assertReferencesResolve, the same method, with the caller's pre-default rowrawRows[i]assupplied, the write'ssuppliedPerRow). A row it refuses is answeredvalid: falsewith the write's ownreference_not_foundfinding, and is not counted toward a later row's parent summary. ⛔ It runs only behind the same write-gate probe (mayWrite) as the preview's related read: the probe on alookuptarget is elevated, so a caller the probe refuses learns nothing about which ids exist. The rows are now judged in a sequential loop (judgeRow) instead ofrows.map, because the admit step is awaited before the next row reads what the earlier rows added. Update mode is untouched.Docs.
content/docs/data-modeling/fields.mdx(the 批量等于逐条 paragraph #26680 added): the dry run previews create rows in the import's batches, and the one case it cannot see (rows in different batches, below).Why
#25579 made a bulk create count the earlier admitted rows of the same batch when a child rule reads a parent
summary. The import commit does; the dry run asked the engine about each row alone, so on #25579's reproduction (two primary contacts for one new supplier) it answered both rows valid while the commit created one and refused the other. The engine's multi-row preview already accumulates (#26680); the dry run just never handed it more than one row.Tests
packages/rest/src/import-dry-run-batch-parent-summary.test.ts(new, 9 cases) — through the realPOST /api/v1/data/:object/import, a real ObjectQL engine and a sqlite:memory:driver, Declarative "at most one row per parent where X" (conditional unique) — summary-based guards miss rows in the same bulk insert #25579's schema. Each agreement case runs the same file twice on two fresh stacks,dryRun: trueand committed, and pins the per-row verdicts equal (row,ok,action,error) pluscreated/updated/errors:A supplier has at most one primary contact.;0);region/reference_not_foundand admits the next primary row, asinsertManydoes; a single-row insert preview refuses a dangling reference asinsert()does; a caller the write-gate probe refuses gets noreference_not_found(no existence oracle).packages/core/src/utils/import-runner-dry-run-batch.test.ts(new, 6 cases) — against a protocol double that answers one verdict per submitted row: the create rows of a small file are one insert-mode call in file order with each row's own report; 450 rows are previewed as 200 / 200 / 50; a callerprogressEveryof 3 sets the checkpoints and run length ([r1..r3] [r4..r6] [r7], progress3, 6, 7); an upsert's matched row is asked alone inupdatemode with its id while the creates around it keep one batch; a batch the engine refuses outright is asked row by row, then the survivors together again; novalidateDatafalls back to the coercion verdict.validateDataas answering one verdict for any call, so a batch of rows read as one row:import-runner-dropped-fields.test.ts(two cases) andimport-runner-kept-options.test.tsnow answer one verdict per submitted row (theValidateDataResponsecontract, "one entry per submitted row"); the kept-options dry-run case now pins the ONE batch call and its union context, the context the commit's batch write carries (plus a case: a batch that kept nothing carries the import's context unchanged);import-runner-update-preview-address.test.ts's create control now readsdataas a one-row array.packages/objectql/src/validate-preview-parent.test.ts(c) — "insert and update previews of a plain line issue no read of the header object" split in two: the update preview still issues none; the insert preview now issues exactly the reads the insert itself makes (its reference check asks the header's read door whetherh_sentexists), and the objectql: avalidate()preview binds no master-detailparentheader in either mode, so an import dry run refuses a detail row whose rule readsparentwhile the write admits it #22474 header binding adds none.Red first on main (production sources restored to 1685a06, core and objectql rebuilt)
Restore proved by
git hash-objectequal to the base blobs (import-runner.ts4983d0e39a,engine.ts1f7cd15714), thenturbo run build --filter=@objectstack/objectql --filter=@objectstack/coreexit 0.import-dry-run-batch-parent-summary.test.ts:Tests 4 failed | 5 passed (9)— red: the two-primary case, the second-batch case, both dangling-reference cases. The two-primary case's diff: row 2 expectedaction: "failed",error: "A supplier has at most one primary contact.", receivedok: true,action: "created".import-runner-dry-run-batch.test.ts:Tests 5 failed | 1 passed (6)(only the no-validateDatafallback is green onmain).git checkout HEAD --,git diff --quiet HEADclean, both packages rebuilt; the same files thenTests 9 passed (9)andTests 6 passed (6).Ablations (both through scripts/ablation-replace.mjs, package rebuilt, ablation-dist-preflight proving the marker in dist/, restore proved by blob == HEAD)
import-runner.tsconst runLength = Math.min(progressEvery, MAX_CREATE_BATCH_SIZE);→const runLength = 1;. Predicted: the two-primary and second-batch agreement cases red, everything else in the rest file green; the core batching cases red. Observed: restTests 2 failed | 7 passed (9)(exactly those two), coreTests 5 failed | 1 passed (6). A first run of this ablation read a stale objectqldist/left by the red-first leg (the two reference cases were also red); that reading is void and is not counted: objectql was rebuilt at HEAD and the ablation re-run.engine.tstheawait this.assertReferencesResolve(schemaForValidation, row, rawRows[i], options?.context, messages);line deleted. Predicted: the two dangling-reference cases red. Observed: restTests 2 failed | 7 passed (9), exactly those two.After both: tree == HEAD, core and objectql rebuilt, preflight
--absent/ present confirms the HEAD build, rest9 passed (9), core6 passed (6).Acceptance notes
0. Inside one batch (the card's reproduction, and every file of up to 200 create rows) the two agree. The docs paragraph and the changeset's "Not covered" bullet say so. Closing it needs one of: onevalidate()call over all of a file's create rows (up to 5,000 sync / 50,000 job rows in one engine call), or an accumulation carried across preview calls (a newvalidateDatarequest key inpackages/spec, outside this lane). Left to the seat as an open question in the os-dev-report.update()also runs it on the write; the update-mode preview still does not. Out of the addendum's scope (the admit step exists only on insert).reference_not_found.insertManyData, which culls a refused row and accumulates the rest; it costs extra calls only on a failing run.packages/specuntouched (lane red line). The import door'sdryRundescription inexport.zod.ts("The verdict is the engine's own write-path validation") stays true and is not edited.engine.ts), chore(ui-merge): clear the objectui leftovers after the ui/ merge (#25753) #26598 (fields.mdx);git merge-treeof each against this head is clean.Verification
All on head
80b0a99e, in this card thread's container.pnpm --filter PKG test, thelocalproject):@objectstack/core2803 passed (2803);@objectstack/objectql8807 passed (8807);@objectstack/restexit 0.repoprojects (vitest run --project repo): core 83 passed, objectql 5 passed, rest 280 passed / 18 skipped. Type check (pnpm --filter PKG typecheck): core, objectql, rest exit 0.validate(): plugin-authadmin-import-users*.test.ts62 passed; runtimeexternal-validation-*.test.ts25 passed;@objectstack/service-datasourcetest 975 passed; service-automationconnector-pull22 passed.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon this diff: 96 families, every one run with its exit code captured before any pipe, all exit 0;--ranreconciliation:96 derived famil(ies) accounted for — 96 run, 0 NOT-MEASURED. Three first answered exit 3 (prerequisite not met: the teardown self-test's positive control commit not fetched, no fulldist/forcheck:skill-examplesandcheck:dual-build-cjs-loads) and were re-run green aftergit fetch origin 621a4876and the full buildcheck:type-check-debtmakes.pnpm exec eslint --no-inline-configon the 8 touched source and test files: exit 0.Dogfood battery (narrowing arm)
On
80b0a99e,pnpm --filter @objectstack/dogfood testunder the verify lock, no shard, afterturbo run build --filter='@objectstack/dogfood^...':Test Files 302 passed | 3 skipped (305),Tests 2505 passed | 20 skipped (2525),os-verify-lock: VERDICT command-exit 0, held 799s. No failures; the skips are declared skips.Human Minimum
packages/specholds no runtime logic. (Nopackages/specchange.)pnpm lintpasses (scoped to the touched files locally; the repo-wide run is CI's).main, one pull request.packages/spectouched: not touched.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code