Repository navigation
feat(spec,core,rest): import column applyOn create-only, and matchFields ordered key sets - #25664
Conversation
`matchFields` takes one key set (`string[]`, unchanged) or ordered key sets (`string[][]`). Each row is matched by the first set whose fields are all non-blank on it, and only by that set; a row blank on every set fails with BLANK_MATCH_KEY instead of being created or skipped. `skipBlankMatchKey` with key sets, and malformed key sets, are refused with 400 INVALID_REQUEST. AMBIGUOUS_MATCH per set is unchanged. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Zj6EHJ5eped9wNcSM4jvh
…ly when its row is created `FieldMappingEntry.applyOn: 'create' | 'always'` (default `always`). A create-only column is written when the import creates the row and left out of the patch when an upsert updates an existing one (an update import never writes it), so one upsert re-run refreshes master data without reverting state written since. The key set a row was matched by stays in its update. An applyOn value outside create / always is refused with 400 INVALID_REQUEST. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Zj6EHJ5eped9wNcSM4jvh
…port-applyon-matchkeys
📓 Docs Drift CheckThis PR changes 3 package(s): 12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 152 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 b9e0cd3e73f72d2a13020ef3c4ed0b3d712afbdc && git checkout b9e0cd3e73f72d2a13020ef3c4ed0b3d712afbdc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin baae6ed14e90e357b6e437e62aed01f0f1be9649 b89b3b242adb7d8a71dc7a0bc3aa6a8f75f8c283 && git checkout -B drift-repro baae6ed14e90e357b6e437e62aed01f0f1be9649 && git merge --no-ff b89b3b242adb7d8a71dc7a0bc3aa6a8f75f8c283
node scripts/docs-audit/affected-docs.mjs --json baae6ed14e90e357b6e437e62aed01f0f1be9649
|
席位核对ACCEPT · #25577 + #25578 (folded) ·
Generated by Claude Code |
Contract reviewServed-tier: Face: published schema ( ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS
Generated by Claude Code |
Fixes #25577
Fixes #25578
Clause-②: yes (widening)
Metadata-protocol: added
Ruling-ref: 6377846440
Ruling-ref: 6377845536
One import-contract PR for two ruled cards, contract and runner together (spec first in each commit), so no release carries either key without the runner that honours it. One commit per card:
f21019ef(key sets, the second card) lands first becauseapplyOn's update patch keeps the key set the row was matched by;fb327b11(applyOn, the first card) builds on it;b89b3b24mergesorigin/main(no regeneration was owed after it:check:generated14/14 up to date).Rulings implemented
FieldMappingEntry.applyOn: 'create' | 'always'(默认always):标create的列只在新建行时写,已有行不动;一次upsert即幂等(主数据列刷新,状态列不冲回)。」 No newwriteModevalue.matchFields接受string[][],单组写法照旧有效),每行用第一组非空的键;哪组都空就报行错误,不再静默跳过;AMBIGUOUS_MATCH规则不变。」What changed
packages/spec(src/api/export.zod.ts)FieldMappingEntry.applyOn: z.enum(['create', 'always']).default('always').ImportRequest.matchFieldsisstring[](unchanged) orstring[][](non-empty list of non-empty sets).skipBlankMatchKey: truetogether with key sets (pathskipBlankMatchKey), mirroring the door.isMatchKeySets(matchFields), the one predicate the spec refinement, the REST door and the runner share.api-surface/,export-origins/,authorable-surface/,authorable-defaults/,content/docs/references/api/export.mdx.packages/core(src/utils/import-runner.ts, the runner both import doors call)RunImportOptions.matchFields: string[] | string[][], pluscreateOnlyFields?: string[].none(upsert creates), never a fall-through to the next set.{ ok: false, action: 'failed', code: 'BLANK_MATCH_KEY' }, on the dry run and the commit alike. One key set keeps its own blank handling exactly (upsert creates, update andskipBlankMatchKeyskip withBLANK_MATCH_KEY).packages/rest(src/import-prepare.ts)matchFieldsin both spellings. One key set is read exactly as before (non-string names dropped). Key sets are read strictly: an empty set or name, a non-string name, or names mixed with sets refuse with400 INVALID_REQUEST.skipBlankMatchKey: truewith key sets refuses with400 INVALID_REQUEST.applyOn: 'create'intocreateOnlyFields; anapplyOnoutsidecreate/alwaysrefuses with400 INVALID_REQUEST.Docs:
content/docs/data-modeling/import-mappings.mdxgains the two sections, the new refusals and the failedBLANK_MATCH_KEYrow.Decisions taken within the rulings
skipBlankMatchKeywith key sets is refused (400), not honoured. Honouring it would skip a row blank on every set, which is the 「静默跳过」 the ruling retires; ignoring it would accept a flag the runtime does not keep. One key set keepsskipBlankMatchKeyas today.[["credit_code"]](key-set form, one set) reports a blank row as a failedBLANK_MATCH_KEY;["credit_code"]keeps today's create/skip. Applying the row error to the single-set spelling would change its meaning, which 「单组写法照旧有效」 rules out.BLANK_MATCH_KEYwithok: false/action: 'failed', so no new error code enters the ledger; the skipped and the failed rows are told apart byok/action.applyOn: 'create'on a match-key column: the column is read for matching on every row; on update, the fields of the key set the row was matched by stay in the patch (the value equals the one the row was found by), so the update patch is never empty because ofapplyOn. With key sets, a create-only column that is not in the row's matched set is left out of that row's update.Tests
All on
b89b3b24(the final head) unless noted.packages/rest/src/import-apply-on-match-key-sets.test.ts(new): 16/16, through the realPOST /data/:object/importroute on ObjectQL + sqlite. It covers: an upsert run twice keeps a create-only column and refreshes analwayscolumn (and creates a new row with it); update mode never writes it, and the dry run agrees; a create-only match-key column still matches; thealways/ absent control;applyOn: 'insert'gives 400INVALID_REQUEST. For key sets: a mixed sheet matches set 1 and set 2 in one import and is idempotent on re-run; no fall-through from a non-blank first set; a row blank on every set is a failedBLANK_MATCH_KEYon the dry run and the commit;AMBIGUOUS_MATCHper set;skipBlankMatchKeywith key sets gives 400INVALID_REQUEST; four malformed key-set shapes give 400INVALID_REQUEST; and a control showing one key set's blank handling unchanged. A last test exercises both keys in one sheet.packages/spec/src/api/export.test.ts: 50/50 (applyOn default, accept and refuse; key sets accept and refuse;skipBlankMatchKeyrefinement; typed SDK request).@objectstack/spec715 files / 21721 tests passed;@objectstack/core118 / 2757 passed;@objectstack/rest321 files / 6416 passed, 326 skipped (taken onfb327b11; the merge brought no change to these three packages' sources except the spec approval schema, rebuilt and regenerated).@objectstack/spec,@objectstack/core,@objectstack/rest, and the othermatchFields/runImportconsumers@objectstack/client,@objectstack/service-automation,@objectstack/plugin-auth(downstream of the changed contract): all exit 0.import-reference-exposure,import-user-field-target): 2 files / 16 tests passed.packages/lint: has no import-mapping rule (no file judgesmatchFieldsor mapping entries), so it is not touched.Ablation (the doors read the runner from
@objectstack/core'sdist/; each leg:scripts/ablation-replace.mjsmutation, core rebuild,ablation-dist-preflight.mjsmarker present in 2 built files, run, restore with blob equal to HEAD, rebuild, marker absent):applyOn:updatePatchkeeps every column. 3 tests red (upsert run twice; update mode; both keys in one sheet), 13 green. The first attempt at this leg did not run: the mutation leftkeySetunused, so the core build failed (exit 90) before any test; it was rerun with a mutation that still readskeySet.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackonb89b3b24derived 116 families, all run locally, each exit captured before any pipe. Reconciliation:--ranreports 116 derived, 116 run, 0 NOT-MEASURED, 0 UNRUN. Three families first refused a prerequisite (exit 3) and were rerun green once it was met:check-plugin-teardown-shape --self-test(shallow clone; the pinned fixture commit was fetched),spec check:skill-examples(builtclient-react),check:dual-build-cjs-loads(built the dogfood closure).check:dev-prereqs(local-only) exit 0.test:repowas not run locally; it belongs to CI.Acceptance notes
ExportImportTemplateSchema.mappingsalso usesFieldMappingEntry, so a template entry can now carryapplyOn. No runtime code reads export/import templates (git grep ExportImportTemplateoutsidepackages/specfinds nothing), so this is the same as its existingtransform/requiredkeys. Noted, not filed (no consumer).mappingartifact (mappingName) has its ownfieldMappingpipeline and anupsertKey: string[]. Neither ruling names it, so it gets neither key:applyOnis an inline-entry key, andupsertKeystays one key set. A request'smatchFieldskey sets still override an artifact'supsertKeyas before.Generated by Claude Code