Repository navigation
Commit b77bb37
feat(spec,core,rest): import column applyOn create-only, and matchFields ordered key sets (#25664)
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 because `applyOn`'s update patch keeps the
key set the row was matched by; `fb327b11` (`applyOn`, the first card)
builds on it; `b89b3b24` merges `origin/main` (no regeneration was owed
after it: `check:generated` 14/14 up to date).
## Rulings implemented
- First card, ruling 6377846440, letter B, verbatim: 「**B** =
`FieldMappingEntry.applyOn: 'create' | 'always'`(默认 `always`):标 `create`
的列只在新建行时写,已有行不动;一次 `upsert` 即幂等(主数据列刷新,状态列不冲回)。」 No new `writeMode`
value.
- Second card, ruling 6377845536, letter A, verbatim: 「**A** =
匹配键接受有序多组(`matchFields` 接受
`string[][]`,单组写法照旧有效),每行用第一组非空的键;哪组都空就报行错误,不再静默跳过;`AMBIGUOUS_MATCH`
规则不变。」
## What changed
**`packages/spec` (`src/api/export.zod.ts`)**
- `FieldMappingEntry.applyOn: z.enum(['create',
'always']).default('always')`.
- `ImportRequest.matchFields` is `string[]` (unchanged) or `string[][]`
(non-empty list of non-empty sets).
- A refinement refuses `skipBlankMatchKey: true` together with key sets
(path `skipBlankMatchKey`), mirroring the door.
- New export `isMatchKeySets(matchFields)`, the one predicate the spec
refinement, the REST door and the runner share.
- Regenerated: `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[][]`, plus
`createOnlyFields?: string[]`.
- Each row uses the first key set whose fields are all non-blank on it,
and only that set: a miss on it is `none` (upsert creates), never a
fall-through to the next set.
- Key sets: a row blank on every set is `{ 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 and
`skipBlankMatchKey` skip with `BLANK_MATCH_KEY`).
- An update (and its dry-run preview, and its undo snapshot) writes the
row minus its create-only columns, keeping the fields of the key set the
row was matched by.
**`packages/rest` (`src/import-prepare.ts`)**
- Reads `matchFields` in 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 with
`400 INVALID_REQUEST`.
- `skipBlankMatchKey: true` with key sets refuses with `400
INVALID_REQUEST`.
- Collects the targets of inline entries with `applyOn: 'create'` into
`createOnlyFields`; an `applyOn` outside `create` / `always` refuses
with `400 INVALID_REQUEST`.
**Docs:** `content/docs/data-modeling/import-mappings.mdx` gains the two
sections, the new refusals and the failed `BLANK_MATCH_KEY` row.
## Decisions taken within the rulings
1. **`skipBlankMatchKey` with 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 keeps `skipBlankMatchKey` as today.
2. **The blank-row rule follows the spelling.** `[["credit_code"]]`
(key-set form, one set) reports a blank row as a failed
`BLANK_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.
3. **The row error reuses `BLANK_MATCH_KEY`** with `ok: false` /
`action: 'failed'`, so no new error code enters the ledger; the skipped
and the failed rows are told apart by `ok` / `action`.
4. **`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 of `applyOn`. 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 real `POST /data/:object/import` route on ObjectQL +
sqlite. It covers: an upsert run twice keeps a create-only column and
refreshes an `always` column (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; the `always` / absent control; `applyOn: 'insert'`
gives 400 `INVALID_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 failed
`BLANK_MATCH_KEY` on the dry run and the commit; `AMBIGUOUS_MATCH` per
set; `skipBlankMatchKey` with key sets gives 400 `INVALID_REQUEST`; four
malformed key-set shapes give 400 `INVALID_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; `skipBlankMatchKey` refinement;
typed SDK request).
- Package suites: `@objectstack/spec` 715 files / 21721 tests passed;
`@objectstack/core` 118 / 2757 passed; `@objectstack/rest` 321 files /
6416 passed, 326 skipped (taken on `fb327b11`; the merge brought no
change to these three packages' sources except the spec approval schema,
rebuilt and regenerated).
- Typecheck: `@objectstack/spec`, `@objectstack/core`,
`@objectstack/rest`, and the other `matchFields` / `runImport` consumers
`@objectstack/client`, `@objectstack/service-automation`,
`@objectstack/plugin-auth` (downstream of the changed contract): all
exit 0.
- Dogfood import tests (`import-reference-exposure`,
`import-user-field-target`): 2 files / 16 tests passed.
- `packages/lint`: has no import-mapping rule (no file judges
`matchFields` or mapping entries), so it is not touched.
**Ablation** (the doors read the runner from `@objectstack/core`'s
`dist/`; each leg: `scripts/ablation-replace.mjs` mutation, core
rebuild, `ablation-dist-preflight.mjs` marker present in 2 built files,
run, restore with blob equal to HEAD, rebuild, marker absent):
- `applyOn`: `updatePatch` keeps 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 left `keySet` unused, so the core
build failed (exit 90) before any test; it was rerun with a mutation
that still reads `keySet`.
- Key sets: the blank-on-every-set branch disabled. 1 test red (row
error on dry run and commit), 15 green.
- After restore: 16/16 green.
## Gates
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` on `b89b3b24` derived 116 families, all run
locally, each exit captured before any pipe. Reconciliation: `--ran`
reports 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` (built
`client-react`), `check:dual-build-cjs-loads` (built the dogfood
closure). `check:dev-prereqs` (local-only) exit 0. `test:repo` was not
run locally; it belongs to CI.
## Acceptance notes
- `ExportImportTemplateSchema.mappings` also uses `FieldMappingEntry`,
so a template entry can now carry `applyOn`. No runtime code reads
export/import templates (`git grep ExportImportTemplate` outside
`packages/spec` finds nothing), so this is the same as its existing
`transform` / `required` keys. Noted, not filed (no consumer).
- The named `mapping` artifact (`mappingName`) has its own
`fieldMapping` pipeline and an `upsertKey: string[]`. Neither ruling
names it, so it gets neither key: `applyOn` is an inline-entry key, and
`upsertKey` stays one key set. A request's `matchFields` key sets still
override an artifact's `upsertKey` as before.
- 「hotsrm 发版后拆掉三处字段声明绕行」 / 「拆掉两遍导入的绕行」 is hotsrm-side work, not part of
this PR.
---
_Generated by [Claude
Code](https://claude.ai/code/session_013Zj6EHJ5eped9wNcSM4jvh)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 1fbd9de commit b77bb37
12 files changed
Lines changed: 627 additions & 33 deletions
File tree
- .changeset
- content/docs
- data-modeling
- references/api
- packages
- core/src/utils
- rest/src
- spec
- api-surface
- authorable-defaults
- authorable-surface
- export-origins
- 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 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
198 | 198 | | |
199 | 199 | | |
200 | 200 | | |
201 | | - | |
| 201 | + | |
202 | 202 | | |
203 | 203 | | |
204 | 204 | | |
205 | 205 | | |
206 | 206 | | |
207 | 207 | | |
208 | 208 | | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
209 | 248 | | |
210 | 249 | | |
211 | 250 | | |
| |||
221 | 260 | | |
222 | 261 | | |
223 | 262 | | |
224 | | - | |
| 263 | + | |
225 | 264 | | |
226 | 265 | | |
227 | 266 | | |
| |||
236 | 275 | | |
237 | 276 | | |
238 | 277 | | |
239 | | - | |
| 278 | + | |
| 279 | + | |
240 | 280 | | |
241 | 281 | | |
242 | 282 | | |
| |||
0 commit comments