Skip to content

Commit d7b9817

Browse files
fix(rest): an import row for a NOT NULL refusal or a unique conflict answers what the create door answers (#20956)
Part of #20701 Clause-②: no REST item 5 of #20701, as triage's answer `5920419144` scopes it and the claim `5920542737` holds it: the NOT NULL row and the unique-conflict row's sentence. Item 1 (the drop signal) is not in this PR and remains open on the card; it waits on #20922. ## What changed - **`packages/rest/src/import-runner.ts`.** `toFailedResult` widens item 4's adoption gate (PR #20941, `f80e2a6dad`) on `mapDataError`'s verdict, the mapper `POST /api/v1/data/:object` uses, from `INVALID_FIELD` to two more verdicts. The gate lives in `adoptDoorVerdict`, with the verdict set `ADOPTED_DOOR_VERDICTS`. - **A driver's NOT NULL refusal.** The door answers `400 VALIDATION_FAILED` with `fields: [{ field, code: 'required' }]`. The row now reads `code: 'required'`, `field` and the door's sentence. It used to relay the dialect code (`SQLITE_CONSTRAINT_NOTNULL`) with no `field`. No driver dialect's code reaches the wire `code` (ADR-0112). - **A unique conflict.** `code: 'UNIQUE_VIOLATION'` and `field` are the same as before. The sentence is now the door's ("A record with this code already exists"), where it was the engine's (which the door ships as `developerMessage`). - The gate is on the door's VERDICT, not on the error. Which arm fired, the `field` and the sentence are all the mapper's, and the import derives none of them. A `VALIDATION_FAILED` that carries no finding is not taken, and a finding on the thrown error still wins over the door, as before. - No key is added. The door's `hint`, `object` and `developerMessage` are not keys of `ImportRowResultSchema`, and they stay off the row. - **Pins, `packages/rest/src/import-row-schema-drift-20701.test.ts`.** PR #20941's two "unchanged by this card" control pins flip on purpose. The pins cover the commit (insert and upsert) and the async job's results route. Each asserts the row equals the door's answer, the row's key set is exactly `row, ok, action, code, field, error`, and the `code` is no dialect code. The item-4 drift cases and a writable row beside each failed row stay as controls. - **`packages/rest/src/error-response.ts`** is not edited. The mapper could be adopted as it stands. - **Changesets.** The new `.changeset/20701-import-row-not-null-and-unique-door-answer.md` is `@objectstack/rest` `patch`. See the next section for the second changeset file. ## A pending release note corrected: please confirm This PR also edits `.changeset/20701-import-row-schema-drift-door-answer.md`, item 4's changeset from PR #20941. That changeset is still pending (it has not been released). It said: "Rows for a unique conflict or a NOT NULL failure are unchanged." This PR changes exactly those rows, and both notes ship in the same release, so the sentence would be false in the CHANGELOG. This PR removes that one sentence and changes nothing else in that file. This is the DELIBERATE CORRECTION class that `scripts/check-empty-changeset.mjs` names. Its scan exits 1 here on purpose, and `Check Changeset` stays red until a person confirms the correction on this PR. `Check Changeset` is not a required context. ⛔ `skip-changeset` does not apply. ## Measured: before and after Readings are from real ObjectQL, `ObjectStackProtocolImplementation` and `better-sqlite3` `:memory:`. The object has `code` (`unique: true`) and `must` (`storage: { notNull: true }`, not `required`). The probe was throwaway and was never committed. "Before" is `75519e1c0a` and "after" is `1eada1f134`. | door or row | before | after | |:--|:--|:--| | `POST /data/:object` without `must` | `400 VALIDATION_FAILED`, `fields [{ must, required }]`, "must is required", `hint` | unchanged | | `PATCH /data/:object/:id` with `must: null` | the same | unchanged | | import commit row, insert, without `must` | `code SQLITE_CONSTRAINT_NOTNULL`, "must is required.", no `field` | `code required`, `field must`, "must is required" | | import commit row, upsert with no match, without `must` | the same as insert | the same as insert | | async job results row, without `must` | the same as insert | the same as insert | | `POST /data/:object`, repeated `code` | `409 UNIQUE_VIOLATION`, `field code`, "A record with this code already exists", `developerMessage` = the engine's sentence | unchanged | | `PATCH /data/:object/:id`, repeated `code` | the same 409 | unchanged | | import commit row, insert or upsert-update, repeated `code` | `UNIQUE_VIOLATION`, `field code`, the engine's sentence "Duplicate record refused on 'proj_probe': …" | `UNIQUE_VIOLATION`, `field code`, "A record with this code already exists" | | async job results row, repeated `code` | the same as the commit | the same as the commit | | dry run, both rows | `ok`, `created` | unchanged (not pinned; see the notes) | **Why the row's `code` is `required`, not `VALIDATION_FAILED`.** The row applies the rule that a finding wins over the top-level code (`#4633`), and it applies it to the door's finding as it does to the engine's. A metadata-`required` field the engine refuses already reads `{ field, code: 'required' }` on the row (`import-integration.test.ts`, "required-field dry-run fidelity"), while the door answers `VALIDATION_FAILED` with that same finding. So this is the row's existing rendering of the door's verdict, not a new one. ## Verification (at `1eada1f134`) - **Pins and the neighbouring import suites** (drift pins, `import-row-report-field-20701`, `import-runner-unique-violation-row`, `import-runner-error-sanitize`, `import-job-integration`, `import-integration`, `import-dryrun-parity`, `import-runner-bulk`, and the probe): `Test Files 9 passed (9)`, `Tests 163 passed (163)`. - **`pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2`:** `Test Files 244 passed (244)`, `Tests 4873 passed | 114 skipped (4987)`, VERDICT command-exit 0. - **`pnpm --filter @objectstack/rest typecheck`:** exit 0, "check:test-typecheck: OK". `tsc -p tsconfig.test.json --listFilesOnly` lists the pin file (count 1). - **Ablation.** The fix was committed first. Each mutation went through `scripts/ablation-replace.mjs` in wrap mode, with the anchor at x1 → x0 and the blob changed. Each was restored to blob == HEAD with an empty `git diff HEAD`. - A. `UNIQUE_VIOLATION` removed from the verdict set: `Tests 3 failed | 20 passed (23)`. The three failures are the unique insert, the unique upsert and the async job case. - B. `VALIDATION_FAILED` removed: 3 failed. They are the NOT NULL insert, the NOT NULL upsert and the async job case. - C. The door's finding no longer wins over its top-level code: 3 failed, on the same NOT NULL cases. - All three moved in the predicted direction. The drift pins and `import-runner-unique-violation-row` stayed green in each run. `import-runner.ts` is imported relatively from `packages/rest/src`, so no build or dist preflight applies. - **Lint, narrowed with proof:** - (1) Population from eslint's own config: `calculateConfigForFile` lints both `.ts` files, and `isPathIgnored` is true for both changesets. - (2) `eslint --no-inline-config --format json`: files=2, errors=0, warnings=0. - (3) Invariance: `parserOptions.project` and `projectService` are null on both files, and the active rules are single-file syntactic rules. So type-aware linting is off, and this diff cannot move eslint's verdict on any untouched file. - **Control-byte self-scan** over the 4 changed files: clean. - **`dispatch-gates --commands`** at `1eada1f134` (merge base `75519e1c0`) derived 61 commands, and each was run with its exit code recorded before any pipe. `--ran`: "61 derived famil(ies) accounted for — 59 run, 2 NOT-MEASURED". - 58 exited 0. - `check-empty-changeset.mjs --base origin/main` exited 1, by design (see the section above). - NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`. Reason: PREREQUISITE NOT MET (exit 3). Both need every package's `dist/`. The one full-build attempt was cut off by a container restart, and it was not repeated on this shared box. `Build Core` and `Lint & Repo Gates` cover them on this PR. - `origin/main` moved to `013f97df93` after the base. None of its three commits touches `packages/rest`, `objectql`, `types`, `metadata-protocol`, `driver-sql` or the `20701` changesets. ## Acceptance notes - **The dry run is unchanged.** Both rows preview as `ok` / `created`. `engine.validate` reads metadata and does not judge `storage.notNull` or uniqueness. Triage settled the drift case (`5920419144`, Ask 2), and this PR adds no preview-side check. - **The other dialects are read from source only, NOT MEASURED.** The mapper's not-null branch matches SQLite `NOT NULL constraint failed: t.c`, Postgres `null value in column "c"` (SQLSTATE `23502`) and MySQL `Column 'c' cannot be null` (`ER_BAD_NULL_ERROR`). None of the three is caught earlier by the missing-column limbs. The mapper is not widened. - **A driver's unique refusal that reaches the row without the engine's envelope** now reads `UNIQUE_VIOLATION` with the door's `field` and sentence, where it relayed the dialect code. On the real engine this path is unreachable: insert, update and the bulk path all envelope the refusal (measured on the bulk path). - **A sandboxed producer that declares `UNIQUE_VIOLATION` with no finding** now carries its business sentence (`innerMessage`) instead of the QuickJS debug wrapper. That is the door's answer, and it is the same residue class item 4's record named for `INVALID_FIELD`. - **A producer that throws `code: 'UNIQUE_VIOLATION'` with a declared status and its own `field`** leaves through the door's passthrough, which carries no `field`. The row now matches the door and carries no `field` either. No producer of that shape reaches the import through the engine: driver-memory's refusal is enveloped. Noted. - **`isEngineDuplicateRecordEnvelope` in `toFailedResult`** is now reached only when the thrown error carries a finding without a code. The engine's envelope carries none, so for that envelope the door's arm answers first. It is left in place and is unreachable in practice. - **The door's NOT NULL `hint` names drift** ("the physical schema has drifted from metadata. Run 'os migrate'"). For a field that declares `storage.notNull` on purpose, that advice is wrong. The hint is the door's text in `error-response.ts` and does not reach the row. Noted, not changed. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent de8cd58 commit d7b9817

4 files changed

Lines changed: 208 additions & 57 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest): an import row for a NOT NULL refusal or a unique conflict answers what the create door answers (#20701)
6+
7+
**`POST /api/v1/data/:object/import` and the async import job — a NOT NULL
8+
refusal.** When the database refuses a row because a NOT NULL column has no
9+
value (for example a field declared `storage: { notNull: true }` and not
10+
`required`, which the engine's own check lets through), the committed row
11+
now fails with `code: 'required'`, `field` set to the field, and the sentence
12+
`POST /api/v1/data/:object` gives for it ("f is required"). It used to fail with
13+
the database's own code (`SQLITE_CONSTRAINT_NOTNULL` on SQLite) and no `field`.
14+
The create door answers `400 VALIDATION_FAILED` with a `required` finding for
15+
the field; the row reports that finding the way it reports a `required` field
16+
the engine refuses itself, so no database dialect's code reaches the row.
17+
18+
**A unique conflict.** A committed row that repeats a unique value keeps
19+
`code: 'UNIQUE_VIOLATION'` and its `field`, and now carries the create door's
20+
sentence, "A record with this f already exists", in place of the engine's longer
21+
sentence (which the create door returns as `developerMessage`).
22+
23+
The async job's rows, read from `GET /api/v1/data/import/jobs/:jobId/results`,
24+
change the same way. The row takes these answers from the same mapper as the
25+
create door, as it already does for a missing database column. No key is added
26+
to the row. The dry run still previews such rows as `ok`, because it checks the
27+
metadata and does not judge `storage.notNull` or uniqueness.

‎.changeset/20701-import-row-schema-drift-door-answer.md‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ fail with the database's own code and text (for example `SQLITE_ERROR` and
1616
`GET /api/v1/data/import/jobs/:jobId/results`, change the same way.
1717

1818
The row now classifies a write error through the same mapper as the create door,
19-
and takes that answer when it is `INVALID_FIELD`. Rows for a unique conflict or a
20-
NOT NULL failure are unchanged. The dry run still cannot see a missing column,
19+
and takes that answer when it is `INVALID_FIELD`. The dry run still cannot see a missing column,
2120
because it checks the metadata and not the table, so it previews such a row as
2221
`ok`.

‎packages/rest/src/import-row-schema-drift-20701.test.ts‎

Lines changed: 117 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,43 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* [#20701] A declared field whose column is missing (schema drift): the import
5-
* row answers what `POST /api/v1/data/:object` answers, on the commit and on
6-
* the async job's rows. Read through the public doors over a REAL
7-
* {@link ObjectQL} + {@link ObjectStackProtocolImplementation} + SQLite
8-
* `:memory:`, with the platform's own `sys_import_job`.
4+
* [#20701] A write the driver refuses on a column: the import row answers what
5+
* `POST /api/v1/data/:object` answers, on the commit and on the async job's
6+
* rows. Read through the public doors over a REAL {@link ObjectQL} +
7+
* {@link ObjectStackProtocolImplementation} + SQLite `:memory:`, with the
8+
* platform's own `sys_import_job`. Three refusals, each classified by
9+
* `mapDataError`, the mapper the door uses (`toFailedResult` adopts its
10+
* verdict):
911
*
10-
* Drift is made the way it happens: the object declares `late` after the DDL
11-
* ran, with no re-sync. The engine's declared-field door passes `late`, since it
12-
* is declared, and the driver refuses the write.
12+
* 1. **Schema drift.** The object declares `late` after the DDL ran, with no
13+
* re-sync. The engine's declared-field door passes `late`, since it is
14+
* declared, and the driver refuses the write.
15+
* 2. **NOT NULL.** `must` is `storage: { notNull: true }` and not `required`,
16+
* so the record validator lets a missing value through (ADR-0113) and the
17+
* driver refuses it.
18+
* 3. **Unique conflict.** `code` is `unique: true` and the row repeats `X`.
1319
*
14-
* Measured on the base (`67c1b11a20`):
20+
* Measured on the bases (`67c1b11a20` for 1, `75519e1c0a` for 2 and 3):
1521
*
16-
* | door | answer for `late` |
17-
* |:--|:--|
18-
* | `POST /data/:object` | `400 INVALID_FIELD`, `field: 'late'`, "The database table of object … has no column for field 'late'. …" |
19-
* | import commit, insert | failed, `code: 'SQLITE_ERROR'`, `table proj_… has no column named late`, no `field` |
20-
* | import commit, upsert onto an existing row | failed, `code: 'SQLITE_ERROR'`, `no such column: late`, no `field` |
21-
* | async import job, results route | failed, `code: 'SQLITE_ERROR'`, the driver's text, no `field` |
22+
* | door | drift (`late`) | NOT NULL (`must`) | unique (`code`) |
23+
* |:--|:--|:--|:--|
24+
* | `POST /data/:object` | `400 INVALID_FIELD`, `field: 'late'`, "The database table of object … has no column for field 'late'. …" | `400 VALIDATION_FAILED`, `fields: [{ field: 'must', code: 'required' }]`, "must is required", a `hint` | `409 UNIQUE_VIOLATION`, `field: 'code'`, "A record with this code already exists", the engine's sentence as `developerMessage` |
25+
* | import commit row, before | `SQLITE_ERROR`, the driver's text, no `field` | `SQLITE_CONSTRAINT_NOTNULL`, "must is required.", no `field` | `UNIQUE_VIOLATION`, `field: 'code'`, the engine's sentence |
26+
* | async import job row, before | the same as the commit | the same as the commit | the same as the commit |
2227
*
23-
* The row now takes the door's verdict when the door's verdict is
24-
* `INVALID_FIELD` (`toFailedResult` asks `mapDataError`, the mapper the door
25-
* uses). The unique-conflict and NOT NULL rows are controls: the door answers
26-
* them in other words (see `toFailedResult`'s docblock), and this change leaves
27-
* both rows as they were. Their pins say "unchanged by this card", not "right".
28-
* A card that converges them updates these pins on purpose.
28+
* Each row now carries the door's `code`, `field` and sentence. For NOT NULL
29+
* the door's `required` finding wins over its top-level `VALIDATION_FAILED`,
30+
* the rule the row applies to the engine's own findings, so the row reads
31+
* `code: 'required'`, `field: 'must'` — the row a metadata-`required` field
32+
* already gets — and no driver dialect's code reaches the wire (ADR-0112). No
33+
* key is added: the door's `hint`, `object` and `developerMessage` stay off the
34+
* row, whose keys are `ImportRowResultSchema`'s.
2935
*
3036
* ⚠️ Not pinned: the dry run. `engine.validate` reads metadata, never the
31-
* table, so a drifted column previews as `ok` / `created` (measured on the
32-
* base, and unchanged here). That is the gap between the preview and the
33-
* commit that the fix report names, not a behaviour this file vouches for.
37+
* table, and does not judge `storage.notNull` or uniqueness, so all three rows
38+
* preview as `ok` / `created` (measured on the bases, and unchanged here). That
39+
* gap between the preview and the commit is not a behaviour this file vouches
40+
* for; triage settled the drift case as the migration door's.
3441
*/
3542

3643
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
@@ -175,26 +182,97 @@ describe('[#20701] the async import job\'s rows carry the same answer', () => {
175182
});
176183
});
177184

178-
describe('[#20701] controls: a unique conflict and a NOT NULL row keep the answers they had', () => {
185+
/** The row's whole key set: the door's extra keys (`hint`, `object`, `developerMessage`) are not on it. */
186+
const ROW_KEYS = ['action', 'code', 'error', 'field', 'ok', 'row'];
187+
188+
/** Dialect codes for a NOT NULL or unique refusal (SQLite, Postgres SQLSTATE, MySQL): none reaches a row. */
189+
const DIALECT_CODE = /^(SQLITE_|ER_)|^\d{5}$/;
190+
191+
function expectRowIsDoor(row: any, door: { code: unknown; field: unknown; error: unknown }, rowNo: number) {
192+
expect(row, JSON.stringify(row)).toMatchObject({ row: rowNo, ok: false, action: 'failed', ...door });
193+
expect(Object.keys(row).sort()).toEqual(ROW_KEYS);
194+
expect(String(row.code)).not.toMatch(DIALECT_CODE);
195+
}
196+
197+
describe('[#20701] a NOT NULL refusal fails the row with the create door\'s `required` finding', () => {
198+
let b: Boot;
199+
beforeEach(async () => { b = await boot(); });
200+
201+
/** What the door answers for a record missing `must`, read as the row renders a finding. */
202+
const notNullDoor = async () => {
203+
const door = await b.call('POST', '/api/v1/data/:object', { title: 't' });
204+
expect(door.status, JSON.stringify(door.body)).toBe(400);
205+
expect(door.body).toMatchObject({ code: 'VALIDATION_FAILED', fields: [{ field: 'must', code: 'required' }] });
206+
return { code: door.body.fields[0].code, field: door.body.fields[0].field, error: door.body.error };
207+
};
208+
209+
it('insert: the bulk create path', async () => {
210+
const door = await notNullDoor();
211+
const r = await b.importRows([{ title: 't' }, { id: 'w1', title: 'control', must: 'm' }]);
212+
expect(r.status, JSON.stringify(r.body)).toBe(200);
213+
expectRowIsDoor(r.body.results[0], door, 1);
214+
expect(r.body.results[0]).toMatchObject({ code: 'required', field: 'must' });
215+
expect(r.body.results[1]).toMatchObject({ row: 2, ok: true, action: 'created', id: 'w1' });
216+
expect(r.body).toMatchObject({ ok: 1, errors: 1, created: 1 });
217+
});
218+
219+
it('upsert with no match: the create half of an upsert', async () => {
220+
const door = await notNullDoor();
221+
const r = await b.importRows([{ id: 'n1', title: 't' }], { writeMode: 'upsert' });
222+
expect(r.status, JSON.stringify(r.body)).toBe(200);
223+
expectRowIsDoor(r.body.results[0], door, 1);
224+
expect(await b.engine.findOne(OBJECT, { where: { id: 'n1' } })).toBeNull();
225+
});
226+
});
227+
228+
describe('[#20701] a unique conflict fails the row with the create door\'s sentence', () => {
179229
let b: Boot;
180230
beforeEach(async () => { b = await boot(); });
181231

182-
it('unique conflict: UNIQUE_VIOLATION naming the column, with the engine\'s sentence', async () => {
232+
it('insert: UNIQUE_VIOLATION naming the column, in the words of `POST /data/:object`', async () => {
183233
const door = await b.call('POST', '/api/v1/data/:object', { title: 't', must: 'm', code: 'X' });
184-
expect(door.status).toBe(409);
185-
const r = await b.importRows([{ title: 't', must: 'm', code: 'X' }]);
186-
expect(r.body.results[0]).toMatchObject({
187-
row: 1, ok: false, action: 'failed', code: 'UNIQUE_VIOLATION', field: 'code',
188-
// The row's sentence is the engine's, which the door ships as `developerMessage`.
189-
error: door.body.developerMessage,
190-
});
234+
expect(door.status, JSON.stringify(door.body)).toBe(409);
235+
expect(door.body).toMatchObject({ code: 'UNIQUE_VIOLATION', field: 'code' });
236+
const r = await b.importRows([{ title: 't', must: 'm', code: 'X' }, { id: 'w1', title: 'control', must: 'm', code: 'Z' }]);
237+
expectRowIsDoor(r.body.results[0], { code: door.body.code, field: door.body.field, error: door.body.error }, 1);
238+
// The engine's sentence is the door's `developerMessage`, and it is no longer the row's.
239+
expect(r.body.results[0].error).not.toBe(door.body.developerMessage);
240+
expect(r.body.results[1]).toMatchObject({ row: 2, ok: true, action: 'created', id: 'w1' });
191241
});
192242

193-
it('NOT NULL: the driver\'s code, with no field (the door says VALIDATION_FAILED with a `required` finding)', async () => {
194-
const door = await b.call('POST', '/api/v1/data/:object', { title: 't' });
195-
expect(door.body).toMatchObject({ code: 'VALIDATION_FAILED' });
196-
const r = await b.importRows([{ title: 't' }]);
197-
expect(r.body.results[0]).toMatchObject({ row: 1, ok: false, action: 'failed', code: 'SQLITE_CONSTRAINT_NOTNULL' });
198-
expect(r.body.results[0]).not.toHaveProperty('field');
243+
it('upsert onto an existing row: the update path, in the words of `PATCH /data/:object/:id`', async () => {
244+
await b.engine.insert(OBJECT, { id: 'e2', title: 'other', code: 'Y', must: 'm' } as any);
245+
const door = await b.call('PATCH', '/api/v1/data/:object/:id', { code: 'X' }, { object: OBJECT, id: 'e2' });
246+
expect(door.status, JSON.stringify(door.body)).toBe(409);
247+
const r = await b.importRows([{ id: 'e2', code: 'X' }], { writeMode: 'upsert' });
248+
expectRowIsDoor(r.body.results[0], { code: door.body.code, field: door.body.field, error: door.body.error }, 1);
249+
expect(await b.engine.findOne(OBJECT, { where: { id: 'e2' } })).toMatchObject({ code: 'Y' });
250+
});
251+
});
252+
253+
describe('[#20701] the async import job\'s rows carry the door\'s NOT NULL and unique answers', () => {
254+
it('the results route reports both, and the writable row is created', async () => {
255+
const b = await boot();
256+
const notNull = await b.call('POST', '/api/v1/data/:object', { title: 't' });
257+
const unique = await b.call('POST', '/api/v1/data/:object', { title: 't', must: 'm', code: 'X' });
258+
const created = await b.call('POST', '/api/v1/data/:object/import/jobs', {
259+
format: 'json', dryRun: false,
260+
rows: [{ title: 't' }, { title: 't', must: 'm', code: 'X' }, { id: 'w3', title: 'control', must: 'm' }],
261+
});
262+
expect(created.status, JSON.stringify(created.body)).toBe(201);
263+
const jobId = String(created.body.jobId);
264+
let progress: { status: number; body: any } | undefined;
265+
for (let i = 0; i < 200; i++) {
266+
progress = await b.call('GET', '/api/v1/data/import/jobs/:jobId', undefined, { jobId });
267+
if (['succeeded', 'failed', 'cancelled'].includes(progress.body?.status)) break;
268+
await new Promise((r) => setTimeout(r, 5));
269+
}
270+
expect(progress?.body).toMatchObject({ status: 'succeeded', created: 1, errors: 2 });
271+
const rows: any[] = (await b.call('GET', '/api/v1/data/import/jobs/:jobId/results', undefined, { jobId })).body.results;
272+
expectRowIsDoor(rows.find((r) => r.row === 1), {
273+
code: notNull.body.fields[0].code, field: notNull.body.fields[0].field, error: notNull.body.error,
274+
}, 1);
275+
expectRowIsDoor(rows.find((r) => r.row === 2), { code: unique.body.code, field: unique.body.field, error: unique.body.error }, 2);
276+
expect(rows.find((r) => r.row === 3)).toMatchObject({ row: 3, ok: true, action: 'created', id: 'w3' });
199277
});
200278
});

‎packages/rest/src/import-runner.ts‎

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -371,26 +371,39 @@ export function sanitizeRowError(raw: unknown): string {
371371
* undeclared key reads as before: the door relays the engine's own envelope,
372372
* in the engine's own words.
373373
*
374-
* Only that verdict is taken. The door also classifies a unique conflict and a
375-
* NOT NULL failure, and it says different words about them than the row does:
376-
* its conflict sentence has no trailing period and carries the engine's
377-
* sentence as `developerMessage`, and its NOT NULL answer is
378-
* `VALIDATION_FAILED` with a `required` finding and a `hint`, where the row
379-
* relays the driver's code. Those rows keep their own answers here. A finding
380-
* still wins over the door, as it does for `code` above.
374+
* ## A NOT NULL refusal and a unique conflict get the create door's answer too (#20701)
375+
*
376+
* The same mapper classifies two more write failures, and the row takes those
377+
* verdicts as well ({@link adoptDoorVerdict}):
378+
*
379+
* - **A driver's NOT NULL refusal.** A field declared `storage: { notNull:
380+
* true }` and not `required` passes the record validator (ADR-0113), and
381+
* the driver refuses the missing value in its own dialect (SQLite
382+
* `SQLITE_CONSTRAINT_NOTNULL`, Postgres `23502`, MySQL
383+
* `ER_BAD_NULL_ERROR`). The row used to relay that dialect code on the wire
384+
* `code`, with no `field`; ADR-0112's vocabulary has no room for it. The
385+
* door answers `VALIDATION_FAILED` with a `required` finding for the
386+
* field, and the row renders the door's finding the way it renders the
387+
* engine's own: `code: 'required'` and `field`, with the door's sentence.
388+
* That is the row a metadata-`required` field already gets.
389+
* - **A unique conflict.** For the engine's envelope the row already said
390+
* `UNIQUE_VIOLATION` and named the column; it now carries the door's
391+
* sentence as well, where it carried the engine's (which the door ships as
392+
* `developerMessage`, a key the row does not have). A driver's unique
393+
* refusal that reaches this function without the envelope gets the same
394+
* answer, where the row used to relay its dialect code.
395+
*
396+
* Nothing from the door's body beyond `code`, `field` and the sentence reaches
397+
* the row: the door's `hint`, `object` and `developerMessage` are not keys of
398+
* `ImportRowResultSchema`. A finding on the thrown error still wins over the
399+
* door, as it does for `code` above.
381400
*/
382401
function toFailedResult(rowNo: number, err: unknown, objectName: string): ImportRowResult {
383402
const e = err as { code?: unknown; message?: unknown; fields?: unknown; field?: unknown } | null | undefined;
384-
const head: unknown = Array.isArray(e?.fields) ? e.fields[0] : undefined;
385-
const first = head !== null && typeof head === 'object' ? (head as { field?: unknown; code?: unknown }) : undefined;
403+
const first = firstFinding(e?.fields);
386404
if (first === undefined) {
387-
const door = mapDataError(err, objectName).body;
388-
if (door.code === 'INVALID_FIELD') {
389-
return {
390-
row: rowNo, ok: false, action: 'failed', error: String(door.error), code: 'INVALID_FIELD',
391-
...(typeof door.field === 'string' && door.field !== '' ? { field: door.field } : {}),
392-
};
393-
}
405+
const adopted = adoptDoorVerdict(rowNo, mapDataError(err, objectName).body);
406+
if (adopted !== undefined) return adopted;
394407
}
395408
const thrownCode = isEngineDuplicateRecordEnvelope(e) ? 'UNIQUE_VIOLATION' : e?.code;
396409
const code = first?.code ?? thrownCode ?? 'IMPORT_ROW_FAILED';
@@ -402,6 +415,40 @@ function toFailedResult(rowNo: number, err: unknown, objectName: string): Import
402415
};
403416
}
404417

418+
/** The head of a `fields` list when it is a finding (an object); a bare name or an empty list is none. */
419+
function firstFinding(fields: unknown): { field?: unknown; code?: unknown } | undefined {
420+
const head: unknown = Array.isArray(fields) ? fields[0] : undefined;
421+
return head !== null && typeof head === 'object' ? (head as { field?: unknown; code?: unknown }) : undefined;
422+
}
423+
424+
/**
425+
* The create door's verdicts an import row takes whole (#20701). The gate is
426+
* on the door's VERDICT, never on the error: which arm fired, which `field` it
427+
* named and the sentence are all `mapDataError`'s, and the import derives none
428+
* of them. See {@link toFailedResult} for each verdict.
429+
*/
430+
const ADOPTED_DOOR_VERDICTS: ReadonlySet<string> = new Set(['INVALID_FIELD', 'UNIQUE_VIOLATION', 'VALIDATION_FAILED']);
431+
432+
/**
433+
* The row for a door verdict in {@link ADOPTED_DOOR_VERDICTS}, or `undefined`
434+
* when the row keeps its own answer. The door's finding wins over its
435+
* top-level `code`, the rule the row applies to the engine's findings. A
436+
* `VALIDATION_FAILED` that carries no finding is not taken: it names no field,
437+
* and the door's only source for one is the thrown error, which the row reads
438+
* itself.
439+
*/
440+
function adoptDoorVerdict(rowNo: number, door: Record<string, unknown>): ImportRowResult | undefined {
441+
if (typeof door.code !== 'string' || !ADOPTED_DOOR_VERDICTS.has(door.code)) return undefined;
442+
const finding = firstFinding(door.fields);
443+
if (door.code === 'VALIDATION_FAILED' && finding === undefined) return undefined;
444+
const code = typeof finding?.code === 'string' && finding.code !== '' ? finding.code : door.code;
445+
const field = typeof finding?.field === 'string' && finding.field !== '' ? finding.field : door.field;
446+
return {
447+
row: rowNo, ok: false, action: 'failed', error: String(door.error), code,
448+
...(typeof field === 'string' && field !== '' ? { field } : {}),
449+
};
450+
}
451+
405452
/** Upper bound on rows in one createManyData batch (framework#2678 suggests 100-500). */
406453
const MAX_CREATE_BATCH_SIZE = 200;
407454

0 commit comments

Comments
 (0)