Skip to content

Commit 9d29223

Browse files
fix(spec): the ImportRequest.matchFields describe names the multi-value or structured match-key refusal (#27658)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSi8fwG9uuvQnfYYbTRahdmZw)_ Fixes #27583 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:`ImportRequestSchema.matchFields` 的 describe 补了一句:update / upsert 的匹配键若指向多值或结构化字段(`multiple: true` 的 select/radio/lookup/user/file/image、multiselect/checkboxes/tags、json/composite/repeater/record/location/address/vector),导入入口在读任何一行之前以 `400 INVALID_REQUEST` 拒绝并点名字段;绕过入口直接进 import runner 的调用方(mapping 的 connector pull)每行以 `VALIDATION_ERROR` 失败。同句写明命名 mapping 的 `upsertKey` 作键时同样判定,补救是用单值字段做键。API 参考页随之重生成,附 patch changeset。 - **为什么改**:#27562 落地后服务端已经这样拒绝,但 JSON schema 与 API 参考里的 describe 只字未提,集成方或 AI 作者按 describe 选 matchFields 只能在被拒时才知道。 - **风险与回滚**:纯文本,不改 schema、不改接受集、不改服务端回答;回滚即 revert 本 PR。 - **先看**:`packages/spec/src/api/export.zod.ts`(describe 新增的那一句);`.changeset/27583-import-match-fields-describe-multi-value-key.md`;`content/docs/references/api/export.mdx`(生成件,两行)。 - **两分钟验证**:`pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec check:docs`,预期 `232 generated files in sync`;或打开 `content/docs/references/api/export.mdx` 第 56 行,应看到 `400 INVALID_REQUEST` 与 `VALIDATION_ERROR` 两处。 - **需要你做的**:无,席位落地。 ## What changes One sentence appended to the `matchFields` describe of `ImportRequestSchema` (`packages/spec/src/api/export.zod.ts`), written to the code the base already runs: - In `update` / `upsert`, a key naming a field that holds a list or a structured value is refused with `400 INVALID_REQUEST` before any row is matched or written, naming each such field. The request's `matchFields` and the named mapping's `upsertKey` it falls back to are judged the same way (`packages/rest/src/import-prepare.ts`, `prepareImportRequest`, via `multiValueMatchKeyRefusal`). - Which fields: the population the spec's own arbiters define — `isMultiValueField` (a `multiple: true` select, radio, lookup, user, file or image field; a `multiselect`, `checkboxes` or `tags` field) and `STRUCTURED_JSON_TYPES` (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`), `packages/spec/src/data/field-value.zod.ts`. - The remedy: match on single-value fields. - A caller that hands rows to the import runner without the door (a mapping's connector pull) gets every row failed with `VALIDATION_ERROR` and the same message (`packages/core/src/utils/import-runner.ts`, `runImport`). `content/docs/references/api/export.mdx` is regenerated by `pnpm --filter @objectstack/spec gen:docs` (two `matchFields` rows: the sync import request and `CreateImportJobRequestSchema`, which is the same object). Changeset: patch on `@objectstack/spec`, text only. ## Why Measured on `178b5238`: the describe (L295–L311) said nothing about the refusal; the server path reads the request's `matchFields` by hand in `prepareImportRequest` (no Zod parse of `ImportRequestSchema` on that path, so the describe documents the published contract and enforces nothing), and the refusal landed in PR #27562 (commit `f557d90f`, an ancestor of the base). The published JSON schema and the generated API reference carry the describe, so a caller reading them was not told about a 400 the server answers. `content/docs/data-modeling/import-mappings.mdx` already states both halves (L263, L282); the describe now agrees with it. ## Tests No behaviour changes, so no new test. Run on the affected package: On the final head `e40c42dd`, each under `scripts/pm/os-verify-lock.sh` (verdict = the lock's `VERDICT command-exit` line): - `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/api/export.test.ts`: Test Files 1 passed (1), Tests 57 passed (57); command-exit 0. - `pnpm --filter @objectstack/spec typecheck`: command-exit 0 (1m24s). - `pnpm --filter @objectstack/spec test`: Test Files 752 passed (752), Tests 22343 passed | 1 todo (22344); command-exit 0 (4m24s). No ablation: the diff changes no tested behaviour (describe text, a changeset, a regenerated reference). ## Acceptance notes - `ImportValidationConfigSchema.deduplication.matchFields` (same file, L115) is left as it is: `git grep deduplication` over `packages/rest/src`, `packages/core/src`, `packages/plugins`, `packages/services` finds no reader, so it is not the key the door judges. A dormant describe, not a finding (noted, not filed; `carrier: none`). - `ui/packages/types/src/data.ts` L1129 carries a one-line TS docblock mirror of the describe's first sentence (`matchFields?: string[]`, already behind the key-set union). A TypeScript type comment on the UI side, not a metadata surface and not this card's file; noted, not filed (`carrier: none`). - An `insert` import matches nothing and is not judged; the sentence scopes itself to `update` / `upsert` as the card asked. ## Verification Local gates on `e40c42dd`, exit codes captured to a file before any pipe. `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 97 families from the three changed files; every one was run. - 94 exit 0, among them spec `check:docs` (`✅ 232 generated files in sync with packages/spec`), `check:api-surface`, `check:authorable-surface`, `check:export-origins`, `check:llms-txt`, `check:variant-docs`, `check:yaml-examples`, `check:spec-parsed-alias`, `check:spec-docblock-symbol-anchors`, `check:doc-authoring`, `check:nul-bytes`, `check:issue-citations`, `check:closed-ceremony-text`, `check:published-files`, `check:merge-driver`, `check:pm-prior-rulings`, `check-adr-0087-registration --base origin/main`, `check-empty-changeset --base origin/main`, `check-changeset-no-major --base origin/main`, `check:changeset-gate-self-tests`, `check-adr-links`, `check-doc-route-spelling --advisory`, the docs-audit scripts and the `--self-test` batteries. - 3 NOT MEASURED, prerequisite not met (exit 3: they import every published package's built `dist/`): spec `check:skill-examples`, `pnpm check:dual-build-cjs-loads`, `pnpm check:lean-entry-closure`. CI's `Lint & Repo Gates` runs them. - `dispatch-gates --ran`: "97 derived, 94 run, 3 NOT-MEASURED, 0 UNRUN". - `pnpm --filter @objectstack/spec check:generated` after the build on the merged head: 1 of 14 stale (`check:docs`), regenerated with `gen:docs` (two rows), then all 14 up to date (exit 0). - The full battery (`pnpm lint`, Test Core shards, the UI jobs) is CI's and was not re-run locally. - `origin/main` (`efa2915e`) merged once before opening, as `4be9eb37` via `scripts/pm/os-regen-merge.sh`; `packages/spec` and `content/docs/references` had not moved on main since the base, so no generated-file conflict. - Independent full review at `CONTRACT_REVIEW_TIER` (an isolated subagent, not the drafter): PASS on `29c63f99` with no must-fix and no should-fix; two of its optional notes applied in `d19be4c9`; delta review PASS on `e40c42dd`. Both records are posted as a comment on this PR. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. (No name added.) - [x] 3. No formatter ran; `pnpm lint` is CI's. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the repo's own Chinese reader section). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_017ihL2cAD3GsST8mQKFsbJZ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 524befb commit 9d29223

3 files changed

Lines changed: 22 additions & 3 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): the `ImportRequest.matchFields` describe says a multi-value or structured field is refused as a match key
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The `matchFields` describe of `ImportRequestSchema` (the body of `POST /data/:object/import` and `POST /data/:object/import/jobs`, which ships in the published JSON schema and the generated API reference) did not say that an `update` / `upsert` whose match key names a field that holds a list or a structured value is refused at the import door with `400 INVALID_REQUEST` before any row. A caller picking `matchFields` from the describe learnt it only from the refusal. The describe now names the refusal; the fields it covers (a `multiple: true` select, radio, lookup, user, file or image field, a `multiselect`, `checkboxes` or `tags` field, or a `json`, `composite`, `repeater`, `record`, `location`, `address` or `vector` field, the population the spec's `isMultiValueField` and `STRUCTURED_JSON_TYPES` define); that a named mapping's `upsertKey` used as the key is judged the same way; the remedy (match on single-value fields); and the runner's answer for a caller that reaches it without the door (a mapping's connector pull): every row failed with `VALIDATION_ERROR`. Text only: no schema, accepted value or server answer changes.

‎content/docs/references/api/export.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ const result = CreateImportJobRequestSchema.parse(data);
5353
| **mappingName** | `string` | optional | Name of a registered `mapping` metadata artifact to apply; the server resolves it (org-scoped rows first, then env-wide) and projects columns through it. Mutually exclusive with an inline `mapping` — supplying both is refused (400 CONFLICTING_MAPPING). |
5454
| **dryRun** | `boolean` | optional (default: `false`) | Validate + coerce every row without persisting. The verdict is the engine's own write-path validation, with one boundary an author should know: a preview runs NO automations. Hooks never fire in a dry run — a preview that executed user-authored side effects (mail, outbound calls, writes to other objects) would be the retired `validateOnly` defect in a new spelling. So a dry run with `runAutomations: true` can report `required` for a field a `beforeInsert` hook would populate during the real import; for hook-derived fields the real write is authoritative. |
5555
| **writeMode** | `Enum<'insert' \| 'update' \| 'upsert'>` | optional (default: `"insert"`) | insert / update / upsert semantics |
56-
| **matchFields** | `string[] \| string[][]` | optional | Fields that identify an existing record (required for update/upsert). Either one key set (`['email']`), or an ordered list of key sets (`[['credit_code'], ['country', 'registration_no']]`) for a sheet whose rows are identified in different ways: each row is matched by the first set whose fields are all non-blank on that row, and only by that set. A row blank on every set fails with `BLANK_MATCH_KEY` instead of being created or skipped, so `skipBlankMatchKey` cannot be combined with key sets. A set that matches more than one record fails the row with `AMBIGUOUS_MATCH`, as one key set does. Rows of one import that carry the same key (the key set each row is matched by, with its cell values as coerced, so an option label and its value are one key) all fail with `DUPLICATE_MATCH_KEY`, naming every row that carries it, and none of them is written, on a dry run as on the import: keep one row per key. |
56+
| **matchFields** | `string[] \| string[][]` | optional | Fields that identify an existing record (required for update/upsert). Either one key set (`['email']`), or an ordered list of key sets (`[['credit_code'], ['country', 'registration_no']]`) for a sheet whose rows are identified in different ways: each row is matched by the first set whose fields are all non-blank on that row, and only by that set. A row blank on every set fails with `BLANK_MATCH_KEY` instead of being created or skipped, so `skipBlankMatchKey` cannot be combined with key sets. A set that matches more than one record fails the row with `AMBIGUOUS_MATCH`, as one key set does. Rows of one import that carry the same key (the key set each row is matched by, with its cell values as coerced, so an option label and its value are one key) all fail with `DUPLICATE_MATCH_KEY`, naming every row that carries it, and none of them is written, on a dry run as on the import: keep one row per key. In `update` / `upsert`, a key (this field, or the named mapping's `upsertKey` it falls back to) naming a field that holds a list or a structured value — a `multiple: true` select, radio, lookup, user, file or image field, a `multiselect`, `checkboxes` or `tags` field, or a `json`, `composite`, `repeater`, `record`, `location`, `address` or `vector` field, the population `isMultiValueField` and `STRUCTURED_JSON_TYPES` define — is refused with `400 INVALID_REQUEST` before any row is matched or written, on a dry run as on the import, naming each such field (match on single-value fields: a code, a name, an email); a caller that hands rows to the import runner without this door (a mapping's connector pull) gets every row failed with `VALIDATION_ERROR` and the same refusal instead. |
5757
| **runAutomations** | `boolean` | optional (default: `true`) | Fire triggers/hooks for each imported row. ON by default, and opting out must be explicit: automations always ran on import historically (the engine ignored this flag until this flag was honoured), so omitting the key runs them. This matches platform convention (Salesforce fires triggers on import by default). Only the system principal (an in-process caller such as a seed or migration job) may send `runAutomations: false`, because it takes the object's triggers and metadata-bound hooks, its approvals and record freezes among them, off every row: the import doors refuse it from every other caller, every signed-in user and administrator included, with `403 PERMISSION_DENIED` before any row is read. One boundary: a `dryRun` preview runs NO automations whatever this flag says. |
5858
| **treatAsHistorical** | `boolean` | optional (default: `false`) | Import as established historical facts. Two effects, both off by default so a normal import is unchanged: (1) skip the state_machine rule so mid-lifecycle rows (e.g. already-closed tickets, closed_won deals) are not rejected by initialStates; and (2) preserve the original audit timeline — keep the supplied created_at / updated_at / updated_by and author-declared business readonly fields (e.g. closed_at, resolved_by) instead of stamping-now / stripping them. Undoing a historical import mirrors (2): the captured pre-import values are restored verbatim rather than re-stamped. Only the system principal (an in-process caller such as a seed or migration job) may send `treatAsHistorical: true`: the import doors refuse it from every other caller, every signed-in user and administrator included, with `403 PERMISSION_DENIED` before any row is read. |
5959
| **trimWhitespace** | `boolean` | optional (default: `true`) | Trim leading/trailing whitespace from string cells |
@@ -311,7 +311,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo
311311
| **mappingName** | `string` | optional | Name of a registered `mapping` metadata artifact to apply; the server resolves it (org-scoped rows first, then env-wide) and projects columns through it. Mutually exclusive with an inline `mapping` — supplying both is refused (400 CONFLICTING_MAPPING). |
312312
| **dryRun** | `boolean` | optional (default: `false`) | Validate + coerce every row without persisting. The verdict is the engine's own write-path validation, with one boundary an author should know: a preview runs NO automations. Hooks never fire in a dry run — a preview that executed user-authored side effects (mail, outbound calls, writes to other objects) would be the retired `validateOnly` defect in a new spelling. So a dry run with `runAutomations: true` can report `required` for a field a `beforeInsert` hook would populate during the real import; for hook-derived fields the real write is authoritative. |
313313
| **writeMode** | `Enum<'insert' \| 'update' \| 'upsert'>` | optional (default: `"insert"`) | insert / update / upsert semantics |
314-
| **matchFields** | `string[] \| string[][]` | optional | Fields that identify an existing record (required for update/upsert). Either one key set (`['email']`), or an ordered list of key sets (`[['credit_code'], ['country', 'registration_no']]`) for a sheet whose rows are identified in different ways: each row is matched by the first set whose fields are all non-blank on that row, and only by that set. A row blank on every set fails with `BLANK_MATCH_KEY` instead of being created or skipped, so `skipBlankMatchKey` cannot be combined with key sets. A set that matches more than one record fails the row with `AMBIGUOUS_MATCH`, as one key set does. Rows of one import that carry the same key (the key set each row is matched by, with its cell values as coerced, so an option label and its value are one key) all fail with `DUPLICATE_MATCH_KEY`, naming every row that carries it, and none of them is written, on a dry run as on the import: keep one row per key. |
314+
| **matchFields** | `string[] \| string[][]` | optional | Fields that identify an existing record (required for update/upsert). Either one key set (`['email']`), or an ordered list of key sets (`[['credit_code'], ['country', 'registration_no']]`) for a sheet whose rows are identified in different ways: each row is matched by the first set whose fields are all non-blank on that row, and only by that set. A row blank on every set fails with `BLANK_MATCH_KEY` instead of being created or skipped, so `skipBlankMatchKey` cannot be combined with key sets. A set that matches more than one record fails the row with `AMBIGUOUS_MATCH`, as one key set does. Rows of one import that carry the same key (the key set each row is matched by, with its cell values as coerced, so an option label and its value are one key) all fail with `DUPLICATE_MATCH_KEY`, naming every row that carries it, and none of them is written, on a dry run as on the import: keep one row per key. In `update` / `upsert`, a key (this field, or the named mapping's `upsertKey` it falls back to) naming a field that holds a list or a structured value — a `multiple: true` select, radio, lookup, user, file or image field, a `multiselect`, `checkboxes` or `tags` field, or a `json`, `composite`, `repeater`, `record`, `location`, `address` or `vector` field, the population `isMultiValueField` and `STRUCTURED_JSON_TYPES` define — is refused with `400 INVALID_REQUEST` before any row is matched or written, on a dry run as on the import, naming each such field (match on single-value fields: a code, a name, an email); a caller that hands rows to the import runner without this door (a mapping's connector pull) gets every row failed with `VALIDATION_ERROR` and the same refusal instead. |
315315
| **runAutomations** | `boolean` | optional (default: `true`) | Fire triggers/hooks for each imported row. ON by default, and opting out must be explicit: automations always ran on import historically (the engine ignored this flag until this flag was honoured), so omitting the key runs them. This matches platform convention (Salesforce fires triggers on import by default). Only the system principal (an in-process caller such as a seed or migration job) may send `runAutomations: false`, because it takes the object's triggers and metadata-bound hooks, its approvals and record freezes among them, off every row: the import doors refuse it from every other caller, every signed-in user and administrator included, with `403 PERMISSION_DENIED` before any row is read. One boundary: a `dryRun` preview runs NO automations whatever this flag says. |
316316
| **treatAsHistorical** | `boolean` | optional (default: `false`) | Import as established historical facts. Two effects, both off by default so a normal import is unchanged: (1) skip the state_machine rule so mid-lifecycle rows (e.g. already-closed tickets, closed_won deals) are not rejected by initialStates; and (2) preserve the original audit timeline — keep the supplied created_at / updated_at / updated_by and author-declared business readonly fields (e.g. closed_at, resolved_by) instead of stamping-now / stripping them. Undoing a historical import mirrors (2): the captured pre-import values are restored verbatim rather than re-stamped. Only the system principal (an in-process caller such as a seed or migration job) may send `treatAsHistorical: true`: the import doors refuse it from every other caller, every signed-in user and administrator included, with `403 PERMISSION_DENIED` before any row is read. |
317317
| **trimWhitespace** | `boolean` | optional (default: `true`) | Trim leading/trailing whitespace from string cells |

‎packages/spec/src/api/export.zod.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,16 @@ export const ImportRequestSchema = lazySchema(() => z.object({
307307
'import that carry the same key (the key set each row is matched by, with its cell values ' +
308308
'as coerced, so an option label and its value are one key) all fail with ' +
309309
'`DUPLICATE_MATCH_KEY`, naming every row that carries it, and none of them is written, on a ' +
310-
'dry run as on the import: keep one row per key.',
310+
'dry run as on the import: keep one row per key. In `update` / `upsert`, a key (this field, or the ' +
311+
'named mapping\'s `upsertKey` it falls back to) naming a field that holds a list or a structured ' +
312+
'value — a `multiple: true` select, radio, lookup, user, file or image field, a `multiselect`, ' +
313+
'`checkboxes` or `tags` field, or a `json`, `composite`, `repeater`, `record`, `location`, ' +
314+
'`address` or `vector` field, the population `isMultiValueField` and `STRUCTURED_JSON_TYPES` ' +
315+
'define — is refused with `400 INVALID_REQUEST` before any row is matched or written, on a dry run ' +
316+
'as on the import, naming each such field (match on single-value fields: a code, a name, an email); ' +
317+
'a caller that hands rows to ' +
318+
'the import runner without this door (a mapping\'s connector pull) gets every row failed with ' +
319+
'`VALIDATION_ERROR` and the same refusal instead.',
311320
),
312321
runAutomations: z.boolean().default(true)
313322
.describe(

0 commit comments

Comments
 (0)