Skip to content

fix(core)!: the import dry run previews create rows in the commit's batches (#26684) - #26778

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-swz0n7
Oct 27, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-swz0n7

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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-run flag and os migrate plan)

维护者速读

  • 改了什么:导入的试运行(dryRun)不再逐行单独问引擎,而是把新建行按正式导入写入时的同一批次(每批 200 行)一次性交给引擎预览;引擎的批量预览也在规则之后跑一遍写入时的引用检查,通过才计入后面行看到的父汇总。
  • 为什么改:Declarative "at most one row per parent where X" (conditional unique) — summary-based guards miss rows in the same bulk insert #25579 之后正式导入会把同批前面已通过的行计入父汇总("一个供应商只能有一个主联系人"),试运行却仍逐行判断,两行主联系人都说"有效",正式导入却拒掉第二行,操作员被告知文件没问题。
  • 风险与回滚:试运行现在会多报出正式导入本来就会拒的行(收窄,BREAKING 标注);单行的插入预览会报出悬空引用。回滚即 revert 本 PR。已知未覆盖:试运行不写库,跨 200 行批次边界的两行冲突仍都显示有效(已写进文档,待你决定是否要补)。
  • 先看: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, beside pendingCreates) and flushed at the commit's checkpoints (every progressEvery rows, and the end) by flushPendingPreviews, which splits them into the commit's runs (Math.min(progressEvery, MAX_CREATE_BATCH_SIZE), the bulkWrite batch size) and asks the engine about each run in ONE validateData call with data as an array, in file order (previewCreateRun). The engine's multi-row validate() (#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): same row, 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 insertManyData culls 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 matches insert()'s: after the rules, the write's reference check (assertReferencesResolve, the same method, with the caller's pre-default row rawRows[i] as supplied, the write's suppliedPerRow). A row it refuses is answered valid: false with the write's own reference_not_found finding, 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 a lookup target 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 of rows.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 real POST /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: true and committed, and pins the per-row verdicts equal (row, ok, action, error) plus created / updated / errors:
    • two primary contacts for a new supplier: the dry run refuses row 2 with the commit's message, A supplier has at most one primary contact.;
    • the dry run writes nothing (no contact, summary still 0);
    • two non-primary rows: both valid in the dry run, as both land;
    • a row the rules refuse is not counted (placeholder name, then a primary): both halves admit the second;
    • a file larger than one batch (203 rows): two primary rows inside the second batch, the dry run refuses row 202 as the commit does;
    • an update-mode dry run keeps its per-row preview (an update against a stored primary is refused, a rename is not) and agrees with its commit;
    • the seat's addendum: a multi-row insert preview refuses the dangling-reference row with region / reference_not_found and admits the next primary row, as insertMany does; a single-row insert preview refuses a dangling reference as insert() does; a caller the write-gate probe refuses gets no reference_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 caller progressEvery of 3 sets the checkpoints and run length ([r1..r3] [r4..r6] [r7], progress 3, 6, 7); an upsert's matched row is asked alone in update mode 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; no validateData falls back to the coercion verdict.
  • Updated, stale doubles — four core tests modelled validateData as answering one verdict for any call, so a batch of rows read as one row: import-runner-dropped-fields.test.ts (two cases) and import-runner-kept-options.test.ts now answer one verdict per submitted row (the ValidateDataResponse contract, "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 reads data as a one-row array.
  • Updated, 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 whether h_sent exists), and the objectql: a validate() preview binds no master-detail parent header in either mode, so an import dry run refuses a detail row whose rule reads parent while 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-object equal to the base blobs (import-runner.ts 4983d0e39a, engine.ts 1f7cd15714), then turbo run build --filter=@objectstack/objectql --filter=@objectstack/core exit 0.

  • rest 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 expected action: "failed", error: "A supplier has at most one primary contact.", received ok: true, action: "created".
  • core import-runner-dry-run-batch.test.ts: Tests 5 failed | 1 passed (6) (only the no-validateData fallback is green on main).
  • Restore leg: git checkout HEAD --, git diff --quiet HEAD clean, both packages rebuilt; the same files then Tests 9 passed (9) and Tests 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)
  1. Send the create rows one by one again — import-runner.ts const 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: rest Tests 2 failed | 7 passed (9) (exactly those two), core Tests 5 failed | 1 passed (6). A first run of this ablation read a stale objectql dist/ 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.
  2. Drop the preview's reference check — engine.ts the await this.assertReferencesResolve(schemaForValidation, row, rawRows[i], options?.context, messages); line deleted. Predicted: the two dangling-reference cases red. Observed: rest Tests 2 failed | 7 passed (9), exactly those two.

After both: tree == HEAD, core and objectql rebuilt, preflight --absent / present confirms the HEAD build, rest 9 passed (9), core 6 passed (6).

Acceptance notes

  • Not covered: two rows that conflict across a batch boundary. Measured on this head with a throwaway probe (not committed): 199 non-primary rows, then primary rows at 200 and 201. The commit refuses row 201 (batch 1's recompute writes the stored count before batch 2 reads it); the dry run answers both valid, because a dry run writes nothing between batches and batch 2's preview reads the stored 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: one validate() 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 new validateData request key in packages/spec, outside this lane). Left to the seat as an open question in the os-dev-report.
  • Update rows keep their per-row preview (the ruling). Two update rows that each move a parent summary still preview against the stored value; their commit writes them one by one, so the second can be refused. Unchanged by this PR.
  • The reference check in the preview is insert-only. 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).
  • The insert preview now reads one more thing: the reference check's probe of each id the caller supplied, behind the write-gate probe. A caller the probe refuses gets no such read and no reference_not_found.
  • Degraded run: when a run's call throws, each row is asked alone and the survivors together again. This mirrors the commit's insertManyData, which culls a refused row and accumulates the rest; it costs extra calls only on a failing run.
  • packages/spec untouched (lane red line). The import door's dryRun description in export.zod.ts ("The verdict is the engine's own write-path validation") stays true and is not edited.
  • Open PRs touching these files: feat!: ownership queues and assignment rules (ADR-0142 phase 2) #26337 and feat(formula): label(record.<field>) renders a select value in the reader's locale #26755 (engine.ts), chore(ui-merge): clear the objectui leftovers after the ui/ merge (#25753) #26598 (fields.mdx); git merge-tree of each against this head is clean.

Verification

All on head 80b0a99e, in this card thread's container.

  • Package suites (pnpm --filter PKG test, the local project): @objectstack/core 2803 passed (2803); @objectstack/objectql 8807 passed (8807); @objectstack/rest exit 0. repo projects (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.
  • Downstream readers of the dry run and of validate(): plugin-auth admin-import-users*.test.ts 62 passed; runtime external-validation-*.test.ts 25 passed; @objectstack/service-datasource test 975 passed; service-automation connector-pull 22 passed.
  • Derived gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on this diff: 96 families, every one run with its exit code captured before any pipe, all exit 0; --ran reconciliation: 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 full dist/ for check:skill-examples and check:dual-build-cjs-loads) and were re-run green after git fetch origin 621a4876 and the full build check:type-check-debt makes.
  • Scoped lint: pnpm exec eslint --no-inline-config on the 8 touched source and test files: exit 0.
Dogfood battery (narrowing arm)

On 80b0a99e, pnpm --filter @objectstack/dogfood test under the verify lock, no shard, after turbo 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

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (No packages/spec change.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes (scoped to the touched files locally; the repo-wide run is CI's).
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: not touched.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English (the 维护者速读 block is the lane's maintainer summary).
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

…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
…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/core, @objectstack/objectql, touching 7 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via /:object/import (route, bridged from symbol runImport — its route source's handler names it))
  • content/docs/api/wire-format.mdx (via /:object/import (route, bridged from symbol runImport — its route source's handler names it))
  • content/docs/data-modeling/fields.mdx (via /:object/import (route, bridged from symbol runImport — its route source's handler names it))
  • content/docs/data-modeling/import-mappings.mdx (via /:object/import (route, bridged from symbol runImport — its route source's handler names it), /:object/import/jobs (route, bridged from symbol runImport — its route source's handler names it))
  • content/docs/permissions/system-context.mdx (via /:object/import (route, bridged from symbol runImport — its route source's handler names it), /:object/import/jobs (route, bridged from symbol runImport — its route source's handler names it))
  • content/docs/protocol/objectql/state-machine.mdx (via /:object/import (route, bridged from symbol runImport — its route source's handler names it), /:object/import/jobs (route, bridged from symbol runImport — its route source's handler names it))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx (via /:object/import (route, bridged from symbol runImport — its route source's handler names it))
  • content/docs/releases/v17/17-6.mdx (via runImport (symbol, a top-level function), /:object/import (route, bridged from symbol runImport — its route source's handler names it))
  • content/docs/releases/v18/18-1.mdx (via /:object/import (route, bridged from symbol runImport — its route source's handler names it))
  • content/docs/releases/v18/18-2.mdx (via /:object/import (route, bridged from symbol runImport — its route source's handler names it))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 72 pages), VALIDATION_FAILED (literal, 35 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 55 of 207 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 38 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 785a7c3b3e31a669ef52e0d83bc18916d170ae9c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from fd8fc59514bcfaca6d079a3fbaf9c1185e3a22d3 — the merge of head 80b0a99e12f72ea46d99072cfd3820ee2d04b03b into base 785a7c3b3e31a669ef52e0d83bc18916d170ae9c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 785a7c3b3e31a669ef52e0d83bc18916d170ae9c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 80b0a99e12f72ea46d99072cfd3820ee2d04b03b
Local-runs: none
Scope: packages/core/src/utils/import-runner.ts, packages/objectql/src/engine.ts, packages/core/src/utils/import-runner-dry-run-batch.test.ts, packages/core/src/utils/import-runner-dropped-fields.test.ts, packages/core/src/utils/import-runner-kept-options.test.ts, packages/core/src/utils/import-runner-update-preview-address.test.ts, packages/objectql/src/validate-preview-parent.test.ts, packages/rest/src/import-dry-run-batch-parent-summary.test.ts + packages/objectql/src/engine-referential-integrity.ts (assertReferencesResolve body), packages/core/src/utils/bulk-write.ts (the commit's chunking), packages/rest/src/data-action-routes.ts (the sync import door and the import job, both callers of runImport), packages/metadata-protocol/src/protocol.ts (validateData forwarding to engine.validate), packages/spec/src/api/protocol.zod.ts (ValidateDataResponseSchema, read only), packages/objectql/src/validation/record-validator.ts (normalizeMultiValueFields), packages/core/src/utils/import-coerce.ts (multi-value cell split), content/docs/data-modeling/fields.mdx, content/docs/data-modeling/import-mappings.mdx :176, .changeset/26684-import-dry-run-batch-preview.md, .claude/skills/pm-dispatch/references/review-checklist.md :50

Face: narrowing. Card #26684, PR #26778, net diff origin/main (785a7c3b3e, merge base 1685a06dd9)...80b0a99e12: 10 files, +647/−46. Read-only: the diff, the card body and all six comments (seat addendum 6442401477, triage 6442590531, claim 6444444867, os-dev-report 6445989077, seat ruling 6446027005), the PR body and file list, and the head's check-runs, read at 2026-10-27T15:29Z. Rendered by the seat's subagent.

① Derived judgments

D1 — the narrowing at the import dry run (POST /api/v1/data/:object/import with dryRun: true, and POST .../import/jobs with dryRun: true): right. Both doors reach one runner (data-action-routes.ts :392 sync, :567 job; the job passes progressEvery: 200, the sync door takes the runner's default Math.max(1, opts.progressEvery ?? 200) at import-runner.ts :718), so both narrow the same way. On main a create-resolved row was asked alone (previewVerdict(data, 'insert', rowCtx), one row object, results[0]). At head a create-resolved row is pushed to pendingPreviews (:1424) and flushed by flushPendingPreviews (:1322-1337) in runs of Math.min(progressEvery, MAX_CREATE_BATCH_SIZE) through previewCreateRun, which hands the run to p.validateData as ONE call with data an array in file order. Two requests that answered valid on main and are refused at head, each pinned through the real door over a real engine and sqlite (rest test, 9 cases): (a) a create row a batch-aware parent-summary rule refuses once the earlier admitted rows of its batch are counted (two primary contacts for one new supplier; row 2 ok: false, action: 'failed', the rule's own message, equal to the commit's verdict row for row); (b) a create row whose caller-supplied reference names no row (region: 'nowhere'), refused with the write's reference_not_found and not counted toward a later row's summary (the multi-row preview answers [false, true] as insertMany answers [false, true]). Nothing widened: a row the per-row preview refused on main is refused at head too (the batch overlay only adds counted rows; a row the rules refuse is not accepted into the accumulator, batchSummaries?.accept(row) runs only after the admit step).

D2 — update-mode previews unchanged: right. The willUpdate arm still calls previewVerdict({ ...updatePatch(data, matchedKeySet), id: target.id }, 'update', rowCtx) with one row object (:1419-1421), previewVerdict is previewVerdicts(...)[0] ?? null (the old results[0] ?? null), and reportPreview(i, rowNo, verdict, target) is the old inline report lifted verbatim. In the engine the new admit step is gated mode === 'insert' && mayWrite (engine.ts :14600), so an update-mode validate() runs nothing new; the per-row accumulator was already gated on insert mode and more than one row. Pinned: the rest update-mode case (refused against a stored primary, a rename admitted, verdicts equal to the commit) and the core "an update row is asked alone, in update mode" case.

D3 — the commit path unchanged: right. The diff touches no line of flushPendingCreates, bulkWrite's options, the willUpdate write arm, the canBulkCreate buffer or the inline createData arm; the only new statements on the commit's path are the two await flushPendingPreviews() calls (:1478, :1490), which return at once on an empty pendingPreviews (a commit never pushes to it: the push sits inside the else if (dryRun) arm). In the engine, insert() (:14701-15990), insertMany, update() are untouched; the diff's engine hunks are inside validate() only.

D4 — triage 6442590531, no second rule copy, same chunking: right. The runner neither reads nor evaluates a rule: the only engine contact is p.validateData (the protocol forwards request.data whole to engine.validate, protocol.ts :13717-13722), and the accumulation is the engine's createBatchSummaryAccumulator already pinned by #26680. Chunking: the preview's run length Math.min(progressEvery, MAX_CREATE_BATCH_SIZE) (:1326) is the commit's batchSize: Math.min(progressEvery, MAX_CREATE_BATCH_SIZE) (:1187), and bulkWrite slices rows.slice(start, start + batchSize) in order (bulk-write.ts :217-218) as flushPendingPreviews slices batch.slice(start, start + runLength); the checkpoints are the same line (processed % progressEvery === 0 || processed === rows.length, :1475) plus the post-loop flush; the buffered population is the same (only create-resolved, coercion-clean, unmatched rows reach either buffer; update rows and refused rows reach neither). The run's context is writeCtxFor over the run's rows' kept values, the union the commit's ctxForRows(toCreate) carries. Pinned by the core test: 450 rows previewed as 200/200/50; progressEvery: 3 gives [r1..r3] [r4..r6] [r7] with progress 3, 6, 7. The degraded path (a run whose call throws, or answers a verdict count other than one per row) re-asks each row alone and then the survivors together in ONE call again, so even there no accumulation is re-implemented in the runner.

D5 — the validate() admit step vs insert()'s: the same method, the same order. insert() (engine.ts :15626-15633): normalizeMultiValueFields → validateRecordInScope(..., 'insert', 'include', ...) → evaluateValidationRules(..., related: batchSummaries.overlay(...)) → await this.assertReferencesResolve(schema, rows[i], suppliedPerRow[i], opCtx.context, msgCtx) → batchSummaries?.accept(rows[i]). validate() (:14564-14604): validateRecordInScope(..., mode, 'include', ...) → evaluateValidationRules(..., related: batchSummaries.overlay(...)) → await this.assertReferencesResolve(schemaForValidation, row, rawRows[i], options?.context, messages) → batchSummaries?.accept(row). Same private method (one wrapper over engine-referential-integrity.ts assertReferencesResolve), same position (after the rules, before the accept), same supplied meaning (rawRows[i] is the caller's payload after the blank/numeric normalizations and the computed strip, before the defaults; suppliedPerRow[i] is callerSuppliedRow on the caller's keys; the check only uses supplied to decide WHETHER a field is checked, by its raw value being non-empty, and reads the value from data). The rows are now judged sequentially (judgeRow in a for loop) so row i's awaited admission precedes row i+1's overlay read; on main the rows.map callback was synchronous, so the sequential loop preserves order and changes no verdict by itself. One deliberate difference, declared in the PR body and the engine docblock: the preview runs the check only when mayWrite (the registered write-gate probe admits the caller); insert() has no such gate in the executor because its middleware has already refused the whole write for such a caller. The preview cannot answer the write decision, so withholding the elevated existence probe (and therefore reference_not_found) from a refused caller is the honest, fail-closed-on-information direction; pinned ("a caller the write-gate probe refuses is told nothing about which ids exist"). Can it change a single-row preview or a non-batch caller: yes, and it is declared. A single-row validate() / validateData in insert mode now refuses a dangling caller-supplied reference (pinned: "a single-row preview refuses a dangling reference, as the single insert does") and issues one elevated findOne per supplied reference id, the read the insert itself makes (pinned in validate-preview-parent.test.ts: the insert preview's header reads equal the insert's). The changeset names exactly this: "the insert preview (validate / validateData, mode insert) refuses a row whose caller-supplied reference names no row, which it answered valid before". No REST data route exposes validateData directly (rest-route-ledger.ts lists only the datasource external/validate), so the public reach is the import dry run plus in-process protocol callers; the dev ran the downstream readers it found (plugin-auth admin-import, runtime external-validation, service-datasource, service-automation connector-pull).

D6 — the per-row verdict shape: unchanged. reportPreview (:1046-1068) is the old inline block moved out of the row loop with the same keys in the same order: row, ok, action ('failed' / 'created' / 'updated'), field when the first error names one, code (first?.code ?? 'VALIDATION_FAILED'), error via the same composeValidationMessage(verdict.errors), warnings when present, droppedFields via the same rowDrops, id on an update. rowNo (i + 1) and index travel in the PendingCreate, results[b.index] is written at the row's own position, and the post-loop compacted filter is the old one, so row numbers and order are the file's. The counters (okCount, errCount, created, updated) are incremented by the same lines, and the flush runs before onProgress, so a checkpoint snapshot carries the same counts it carried when the preview was inline. A row whose preview call throws is reported by the same toFailedResult(rowNo, err, objectName) the row loop's catch used on main. Pinned with toEqual on whole result objects in the core test (row 1 { row: 1, ok: true, action: 'created' }, row 2 { row: 2, ok: false, action: 'failed', field: 'name', code: 'rule_violation', error: 'refused bad-b' }).

D7 — the public surface of @objectstack/core and @objectstack/objectql: no export, type or signature change. runImport's options and ImportRunSummary are untouched; ImportProtocolLike.validateData already took ValidateDataRequest, whose data admits an array; ObjectQL.validate's signature is unchanged. packages/spec untouched (the file list has no spec path; the lane red line holds). import-mappings.mdx :176 ("the verdict comes from the engine's own write-path validation") and export.zod.ts's dryRun description stay true at head and need no edit.

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) previewCreateRun throws ERR_BULK_RESULT_MISMATCH when a responder answers a verdict count other than one per row, and that failure lands on the row (toFailedResult) after the per-row degradation; on main a responder answering no results fell to the coercion verdict (results?.[0] ?? null). ValidateDataResponseSchema.results is a required array ("one entry per submitted row, in submission order", protocol.zod.ts :2579, :2605), so such a responder violates the contract; the update arm keeps the old tolerance (verdicts[0] ?? null). Within contract; noted as an asymmetry, not a defect. (ii) insert() runs normalizeMultiValueFields before its admit step and the preview never has (pre-existing, outside this diff's hunks), so a multiple: true lookup handed to validateData as one joined string is probed by the preview as one id where the write splits it first. The import door splits multi-value reference cells in coerceRow (import-coerce.ts :789) before the dry run, so dry run and commit agree there; the corner is a direct protocol caller passing a joined string. Named here for the seat; a follow-up card if a caller is found, not a condition on this PR.

② Semver level

The changeset .changeset/26684-import-dry-run-batch-preview.md bumps @objectstack/core: patch and @objectstack/objectql: patch, the two packages the diff moves (packages/rest carries only a test). Clause-②: no (narrowing) on the PR body and in the changeset, line-initial, matching the measured diff: the import dry run and the insert preview refuse requests they admitted (D1, D5), nothing is admitted that was refused, no protocol key moves. Metadata-protocol: none — no key, accepted value, metadata type, meaning or default an author can write changes; packages/spec is untouched. Per AGENTS.md ("a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition"), patch is the level; the changeset carries the fix(core)!: title, a BREAKING block with Before / Now / Unchanged / Not covered / Who sees it, a remedy, and exactly one ADR-0087 marker, not-required (no-migration-prescription), whose only mechanical condition (no migration prescription in the body) the body meets: nothing is renamed or removed, the refusals are the rule author's own message and the write's existing reference_not_found. Check Changeset on the head: success. Clause-②: no (narrowing) — right.

③ Boundary flags

  • os-dev-report open_questions[0] (the cross-batch straddle): answered by seat ruling 6446027005, letter A, with a condition: the limit must stay stated in content/docs/data-modeling/fields.mdx and in the changeset's "Not covered" line. Both are present at head: fields.mdx :325-329 ("It writes nothing, so a row in a later batch reads the summary as stored before the import: two such rows that fall in different batches of a dry run both preview valid, while the import refuses the second"), inside the 批量等于逐条 paragraph where an author reads the rule; the changeset's Not covered bullet says the same. The ruling withdrew the order's "agrees across the chunk boundary" pin; the rest test's 203-row case pins agreement for two primaries INSIDE the second batch (rows 201-202), which is what head delivers, and does not claim the straddle. A pin that the limit exists is "welcome, if the PR has one": the PR has none; the ruling makes it optional. Closed.
  • Dev flag — "the update-mode preview does not run the write's reference check (update() does)": out of the addendum's scope (the admit step the addendum names is insert-only), stated in the PR's Acceptance notes and in the engine docblock ("on an insert"); update-mode previews are unchanged (D2). Not a condition; a follow-up card at the seat's discretion. Closed.
  • Dev flag — two update rows that each move a parent summary preview against the stored value: the ruling keeps update rows per-row, stated in the changeset's "Unchanged" line. Closed.
  • Dev flag — the insert preview now reads one more thing (the reference probe, behind the write-gate probe): stated in the engine docblock and the PR body; pinned as equal to the insert's own reads. Closed.
  • Dev flag — degraded run costs extra calls only on a failing run: read in the code (D4); the commit degrades the same way. Closed.
  • Dev flag — open PRs on the touched files (feat!: ownership queues and assignment rules (ADR-0142 phase 2) #26337, feat(formula): label(record.<field>) renders a select value in the reader's locale #26755 on engine.ts, chore(ui-merge): clear the objectui leftovers after the ui/ merge (#25753) #26598 on fields.mdx): feat!: ownership queues and assignment rules (ADR-0142 phase 2) #26337 has since merged into main (a9f654ebfb, in the base range); the PR's merge-tree claim is the dev's, and the head's Governed Surface Queue Guard and Lint & Repo Gates are success; the Type Check · workspace job is still running at read time, so a conflict with the merged feat!: ownership queues and assignment rules (ADR-0142 phase 2) #26337 would surface there. Noted, not a verdict input beyond the check-runs.
  • Seat question — the dogfood battery (review-checklist.md :50: ! or (narrowing) ⇒ the battery pinned to head, full, red fixed or reported): the report's dogfood_battery reads "80b0a99e · os-verify-lock: VERDICT command-exit 0 (held 799s) · Test Files 302 passed | 3 skipped (305) · Tests 2505 passed | 20 skipped (2525) · no failures", and the PR body says no shard, after a dogfood build. 80b0a99e is this head's short sha; full (no shard); no failures; the skips are declared skips. Met, as the dev's own reading; the head's Dogfood Regression Gate check-run is success (its shard matrix skipped on this diff).
  • Seat question — the declarations: Clause-②: no (narrowing) right; Metadata-protocol: none right; patch with the BREAKING block and the ADR-0087 marker right (section ②). The Skills-docs line names a measured grep; no skills/** path is in the diff.
  • D8 (i) and (ii) are this review's own observations, recorded there; neither conditions the verdict.
  • Stale comment, non-blocking: import-runner.ts :60 still reads "the dry run: validateData's verdict for the row (results[0])"; at head a create row's verdict is entry k of its run's call. Cosmetic; a one-line follow-up if the seat wants the docblock exact.

Check-runs on 80b0a99e12, read at 2026-10-27T15:29Z (45 runs, all head_sha = this head). Completed success (30): Lint & Repo Gates, Lint · ESLint, Lint · CI release & engine gates, Lint · docs & code guards, Lint · repo & tooling gates, Lint · scoped · query-options erasure, Lint · scoped · slot lookup & corpus, Lint · scoped · tooling self-tests, Lint · PM dispatch-gates, Type Check · source gates, Check Changeset, Check PR Size, Check Documentation Links, Governed Surface Queue Guard, PR-body guards, Spec property liveness, Flag docs affected by code changes, Dogfood Regression Gate, Plan Test Core shards, Build UI Deps, Test UI, Auto Label, filter. Completed skipped (7, opt-in or matrix placeholders): 18.x upgrade harness, Packed-tarball smoke, Temporal Conformance, Dogfood Verify CLI, Dogfood Regression Gate (matrix), Test UI (matrix), Lint UI. Still in_progress (15): Build Core, Build Docs, Build & E2E UI, Test Core (1/8)…(8/8), Type Check UI, Type Check · workspace, Type Check · consumer gates, Type Check · debt ledger. No run on this head has concluded failure. The two required jobs the checklist names: Lint & Repo Gates is success; TypeScript Type Check (Type Check · workspace) is still running and is the seat's to re-read before the queue; this record's verdict rests on the contract reading above and on the runs that have concluded, and names the running ones so the seat re-reads them.

Implemented-by: claude/project-thread-swz0n7
Reviewed-by: session_01R1q2xdtYgU6FKrZ7LgjPCC

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-10-27T15:51Z

Head 80b0a99e12, report 6445989077 (on #26684), claim 6444444867, seat ruling 6446027005 (letter A), contract review 6446259933 (CONTRACT_REVIEW_TIER, narrowing face, PASS on this head).

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 27, 2026 15:52
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 27, 2026
Merged via the queue into main with commit ef7067f Oct 27, 2026
48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-swz0n7 branch October 27, 2026 16:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

import: the dry run previews each row alone, so after #25579 it answers valid for a row the commit refuses by a batch-aware parent-summary rule

2 participants