Skip to content

Commit 165c1d4

Browse files
fix(rest): an import row names its column from the error envelope, and the driver-string branch stops calling a declared field unknown (#20905)
Part of #20701 Clause-②: no The REST half of the #20701 split, delivered in part. Two of its three items land here. The third, the drop signal on the import row report, cannot be read from the seam this package reads, so it goes back to the seat with the extraction it needs (see "Not in this PR"). #20701 remains open. ## What changed - **An import row names its column from the error envelope.** `toFailedResult` (`packages/rest/src/import-runner.ts`) read `field` only off a `ValidationError` finding. The engine's declared-field door throws `INVALID_FIELD` / 400 with the column in the envelope's `field` member and the bare names in `fields` (strings, not findings). So a row that door refused named no column, on the dry run and on the commit alike, while `POST /api/v1/data/:object` named it. The row now takes the envelope's `field` when no finding names one. A finding still wins, as it does for `code`. `DuplicateRecordError` carries `field` too, so a unique-conflict row now names the column the `409` names. - **The driver-string branch stops calling a declared field unknown.** `mapDataError`'s missing-column branch (`packages/rest/src/error-response.ts`) sees only the driver's text, not the object's field map. The engine refuses an undeclared write key itself before any driver runs, in its own words. So what reaches this branch is usually a field the object DECLARES whose column is missing (schema drift). The sentence now reads "The database table of object 'X' has no column for field 'f'. If the object declares 'f', its database schema has drifted from the metadata: run 'os migrate' to reconcile." `code`, `status`, `field` and `object` are unchanged. The engine's own "Unknown field" sentence for an undeclared key is untouched, and a pin holds that control. - **Pins:** `packages/rest/src/import-row-report-field-20701.test.ts`, 9 cases on real ObjectQL + `ObjectStackProtocolImplementation` + SQLite `:memory:`, through the public doors. - **Changeset:** `@objectstack/rest` `patch`. `Clause-②: no`, re-read against this diff. The claim's `yes (widening)` was written for the drop-signal key, which is not in this diff. `field` is an existing optional key of `ImportRowResultSchema`, now filled in more cases. The sentence change moves no key and no accept set. No error code is minted. ## Readings Base `ca5408c629` (before) and head `901010d4d8` (after). Real ObjectQL + protocol + SQLite `:memory:`, non-system caller. The "before" column is from a throwaway probe (never committed); the unique-conflict "before" is ablation leg A below, which restores the old read. | column | import dry run | import commit | `POST /api/v1/data/:object` | |:--|:--|:--|:--| | undeclared `nope` | failed `INVALID_FIELD`, no `field` → `field: 'nope'` | the same | `400 INVALID_FIELD`, `field: 'nope'` (unchanged) | | `unique` value already held | not measured (`validate` does not touch the driver) | failed `UNIQUE_VIOLATION`, no `field` → `field: 'code'` | `409 UNIQUE_VIOLATION`, `field: 'code'` (unchanged) | | declared `late`, table has no column | not measured (`validate` does not touch the driver) | failed `SQLITE_ERROR` with the driver's text; not re-measured after, and the driver's error carries no `field` for this PR to read (see Acceptance notes) | `400 INVALID_FIELD` "Unknown field 'late' on object 'proj'" → "The database table of object 'proj' has no column for field 'late'. ..." | | formula `doubled` | ok, created, no drop signal (unchanged) | ok, created, no drop signal (unchanged) | `201` + `droppedFields` reason `computed` | | readonly `ro` | ok, created, no drop signal (unchanged) | ok, created, no drop signal (unchanged) | `201` + `droppedFields` reason `readonly` | | writable `title` | ok, created | ok, created | `201` | ## Not in this PR: the drop signal, and why The card's first item reads: "The import row report carries the drop signal, in the dry run and in the commit alike, read from the engine's `droppedFields`". This package cannot read it on the dry run, and on the commit's bulk path it cannot place it on a row. 1. **The dry run has no source for it.** The dry run asks `p.validateData`, the `ImportProtocolLike` seam typed by `ValidateDataRequest` / `ValidateDataResponse` from `@objectstack/spec/api`. `ObjectQL.validate` reports drops through its `onFieldsDropped` option. But `ObjectStackProtocolImplementation.validateData` (`packages/metadata-protocol/src/protocol.ts`) forwards only `mode` and `context`, and `ValidateDataResponseSchema` has no `droppedFields` member. Measured at base for a formula key and a readonly key: the response is `{ object, mode, valid, results, posture }`, with nothing about the drop. 2. **The commit's bulk path names no row.** Import creates go through `insertManyData`, whose `droppedFields` is the batch-level union by design. Its docblock and `ObjectQL.insertMany`'s both say why: the union cannot be resolved back to rows, and a reconstruction at a call site names rows that are not at fault. The per-row writes (`createData`, `updateData`) report per row. So both shapes need work outside `packages/rest`: - A per-row signal needs a per-row channel from the engine's `insertMany`, plus the protocol and spec halves of the dry run. - An import-wide signal needs the dry-run channel, plus a place on `sys_import_job` for the async path. Either shape also owes a declaration in the spec's `ImportRowResultSchema` / `ImportResponseSchema`. The options, costs and a recommendation are in the report comment on #20701. ## Verification (head `901010d4d8`) All runs on this worktree, SQLite `:memory:`, heavy steps under `scripts/pm/os-verify-lock.sh`. - **Pins** (`import-row-report-field-20701.test.ts`) at `901010d4d8`: `Test Files 1 passed (1)`, `Tests 9 passed (9)`, and `pnpm --filter @objectstack/rest typecheck` exit 0 (`check:test-typecheck: OK`; `tsc --listFiles` over `tsconfig.test.json` lists the pin file). - **Package suite** (`pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2`) at `6eb88d7028`, the merge of `origin/main`: `Test Files 238 passed (238)`, `Tests 4658 passed | 103 skipped (4761)`. The one commit after it removes two `as any` casts from the pin file, and the pin file and the typecheck were re-run on it (above). The package's one `repo`-project test does not read the files changed here. - **Ablation**, fix committed first, through `scripts/ablation-replace.mjs` WRAP mode (the anchor must hit, the blob must move, and the restore is proven as `blob == HEAD` plus an empty `git diff HEAD`). The subject is read from `packages/rest/src` by relative import, so no `dist/` is involved. - Leg A, `import-runner.ts`: anchor `: e?.field;` 1 to 0, blob `60e13a17efec` to `6ba5cf4c20cd`. The row goes back to reading `field` off a finding only. Result: 4 failed, 5 passed. The four are the unknown-column cells (insert and upsert), the row-versus-door cell and the unique-conflict cell, each missing `field`. Restored: `blob == HEAD (60e13a1)`, `git diff HEAD` empty. - Leg B, `error-response.ts`: anchor `has no column for field` 1 to 0, blob `2db9a02d90f4` to `a427b6b19471`. The sentence says "Unknown field" again. Result: 1 failed (the drift cell), 7 passed. Restored: `blob == HEAD (2db9a02)`, `git diff HEAD` empty. - **Gates**: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `901010d4d8` derives 62 commands. Each was run with its exit code recorded before any pipe. `--ran` reads "62 derived famil(ies) accounted for — 60 run, 2 NOT-MEASURED". NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`, both `PREREQUISITE NOT MET` (exit 3), because each needs every package's `dist/`, which is the full build CI runs. The first pass at `6eb88d7028` caught one real red, `check:query-options-erasure` (test surface 236 to 238, the pin file's two casts); `901010d4d8` fixes it and the gate reads 236 again. - **Lint**, narrowed with proof. eslint's own config lints the three `.ts` files in this diff, and `isPathIgnored` is true for the changeset `.md`, so those three files are the diff's whole lint population. `eslint --no-inline-config --format json` over them at `901010d4d8`: `files=3 errors=0 warnings=0`. `calculateConfigForFile` shows `parserOptions.project` and `projectService` null on all three, so type-aware linting is off: this diff cannot change eslint's verdict on any file it does not touch. - **Repro before the change** at base `ca5408c629`: a throwaway probe (never committed) produced the "before" cells in the table above. ## Acceptance notes - **A declared field with no column still fails an import row with the driver's text.** The import commit row for `late` reads `code: 'SQLITE_ERROR'`, error "table proj has no column named late", while the create door answers `400 INVALID_FIELD` with the sentence above. The dry run was not measured; `validate` does not touch the driver, so it cannot see the missing column. This is the same row-versus-door agreement family as #20701, reached only through schema drift, and it is left for the seat to fold into that card's remaining half. Not filed. - **The REST row result already carries a key the spec does not declare.** `ImportRowResult.warnings` (`packages/rest/src/import-runner.ts`) is absent from `ImportRowResultSchema` in `@objectstack/spec/api`, so the typed client (`client.data.import`, typed by the spec's `ImportResponse`) cannot see it. That matters for where the drop signal is declared. Noted, not filed. - **Dispatch deviations.** The first line is `Part of #20701`, not `Fixes`, because the drop signal is not here. The Clause-② line is `no`, not the claim's `yes (widening)`, because the widening key is not here either. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9509ea1 commit 165c1d4

4 files changed

Lines changed: 265 additions & 9 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest): an import row names the column the engine refused, and a missing database column is no longer called an unknown field (#20701)
6+
7+
**`POST /api/v1/data/:object/import` — a failed row names its column.** A row the
8+
engine refuses with `INVALID_FIELD` (a column that names no field of the object)
9+
now carries `field` with that column, on the dry run and on the commit alike. It
10+
used to carry only `code: 'INVALID_FIELD'`, while `POST /api/v1/data/:object`
11+
named the field for the same key. The row reads the error's own `field` when no
12+
field-level finding names one; a field-level finding still wins. A unique
13+
conflict row now names its column too, when the database said which column it
14+
was, as the `409` does.
15+
16+
**A missing database column says what the database said.** When the database
17+
reports that a table has no column for a field, the `400 INVALID_FIELD` answer
18+
now reads "The database table of object 'X' has no column for field 'f'. If the
19+
object declares 'f', its database schema has drifted from the metadata: run
20+
'os migrate' to reconcile." It used to read "Unknown field 'f'", which was false
21+
for a field the object declares: the engine refuses an undeclared key itself,
22+
before the database is reached, and keeps its own "Unknown field" wording for
23+
that case. `code`, `status`, `field` and `object` are unchanged.

‎packages/rest/src/error-response.ts‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1249,9 +1249,9 @@ function structuredCodeAnswer(
12491249
// [#4134] Unknown field named by a READ — the protocol's list normalizer
12501250
// refusing to lower a query parameter that matches no field into an
12511251
// implicit filter that could only ever match zero rows. Emitted in the SAME
1252-
// envelope as the driver-string branch below (which catches the write-path
1253-
// form of the identical mistake), so one condition has one wire shape no
1254-
// matter which layer noticed it. Must precede the generic 4xx passthrough,
1252+
// envelope as the driver-string branch below (which classifies a driver's
1253+
// missing-column text, and since #20701 says so rather than "Unknown
1254+
// field"), so one wire shape serves both. Must precede the generic 4xx passthrough,
12551255
// which would ship the message but drop `field`.
12561256
// [commit 6d178a408] `!isSandboxOrigin`: the same clause as the arm above, and here
12571257
// it carries the sentence reason TOO — this arm really does ship
@@ -1772,6 +1772,18 @@ function classifyDataError(error: any, object?: string): { status: number; body:
17721772
// a legal missing-TABLE phrase as a substring, and `service-analytics` and
17731773
// `metadata` each had to repair the same superstring hole. Same regex as
17741774
// before, same position last in the chain — only its owner moved.
1775+
//
1776+
// [#20701] The sentence says what the DATABASE said — the table has no
1777+
// such column — and never "Unknown field". This branch sees only the
1778+
// driver's text, not the object's field map, while the engine's
1779+
// declared-field door refuses an undeclared write key before any driver
1780+
// runs, in its own words, and the read doors gate undeclared filter names
1781+
// the same way. So a request that gets this far usually names a field the
1782+
// object DECLARES whose column is missing: metadata and the physical
1783+
// schema have drifted, and "Unknown field" sent the author hunting for a
1784+
// typo in a correct declaration. (A formula column was the common case;
1785+
// the engine strips that value now, reason `computed`.) `code`, `status`,
1786+
// `field` and `object` are unchanged.
17751787
const unknownColumn =
17761788
/has no column named\s+["'`]?([a-z0-9_]+)/i.exec(raw)?.[1] ??
17771789
/no such column:\s*["'`]?([a-z0-9_.]+)/i.exec(raw)?.[1] ??
@@ -1783,8 +1795,10 @@ function classifyDataError(error: any, object?: string): { status: number; body:
17831795
status: 400,
17841796
body: {
17851797
error: field
1786-
? `Unknown field '${field}'${object ? ` on object '${object}'` : ''}`
1787-
: 'Request references a field that does not exist',
1798+
? `The database table${object ? ` of object '${object}'` : ''} has no column for field '${field}'. `
1799+
+ `If the object declares '${field}', its database schema has drifted from the metadata: `
1800+
+ `run 'os migrate' to reconcile.`
1801+
: 'The request references a column the database table does not have.',
17881802
code: 'INVALID_FIELD',
17891803
...(field ? { field } : {}),
17901804
...(object ? { object } : {}),
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20701] What the import row report and the driver-string branch say about
5+
* a column, read through the public doors — `POST /api/v1/data/:object/import`
6+
* (dry run and commit) and `POST /api/v1/data/:object` — over a REAL
7+
* {@link ObjectQL} + {@link ObjectStackProtocolImplementation} + SQLite
8+
* `:memory:`.
9+
*
10+
* Measured on the base (`ca5408c629`), with the engine half (#20805) already
11+
* landed:
12+
*
13+
* | column | dry run | commit | `POST /data/:object` |
14+
* |:--|:--|:--|:--|
15+
* | undeclared `nope` | failed `INVALID_FIELD`, **no `field`** | failed `INVALID_FIELD`, **no `field`** | `400 INVALID_FIELD`, `field: 'nope'` |
16+
* | declared `late`, no column (drift) | — | — | `400 INVALID_FIELD` "Unknown field 'late'" |
17+
* | formula `doubled` / readonly `ro` | ok, created | ok, created | `201` + `droppedFields` |
18+
* | writable `title` | ok, created | ok, created | `201` |
19+
* | a `unique` value already held | — | failed `UNIQUE_VIOLATION`, **no `field`** | `409 UNIQUE_VIOLATION`, `field` |
20+
*
21+
* The first and last rows are `toFailedResult` reading `field` only off a
22+
* `ValidationError` finding, while the engine's declared-field door and its
23+
* `DuplicateRecordError` carry it on the envelope (`field`; the declared-field
24+
* door puts the bare names in `fields`). The second is `mapDataError`'s
25+
* driver-string branch calling a DECLARED field unknown.
26+
*
27+
* ⚠️ Not pinned here: the drop signal on the formula / readonly rows. The dry
28+
* run's source, the protocol's `validateData`, returns no `droppedFields` at
29+
* all, so the import cannot read one without a change outside this package
30+
* (the card's report names it). The formula and readonly cells below pin only
31+
* that both halves SUCCEED, which is the half of the card's pin that holds.
32+
*/
33+
34+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
35+
import { ObjectQL } from '@objectstack/objectql';
36+
import { SqlDriver } from '@objectstack/driver-sql';
37+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
38+
import { RestServer } from './rest-server';
39+
40+
const OBJECT = 'proj_20701';
41+
42+
const PROJ = {
43+
name: OBJECT, label: 'Proj 20701', systemFields: false,
44+
fields: {
45+
id: { name: 'id', type: 'text' as const, primaryKey: true },
46+
title: { name: 'title', type: 'text' as const },
47+
n: { name: 'n', type: 'number' as const },
48+
doubled: { name: 'doubled', type: 'formula' as const, expression: 'record.n * 2' },
49+
ro: { name: 'ro', type: 'text' as const, readonly: true },
50+
},
51+
};
52+
53+
function createMockServer() {
54+
const noop = () => {};
55+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
56+
}
57+
58+
function makeRes() {
59+
const res: any = {
60+
write: () => true, end: () => {},
61+
header: () => res,
62+
status: (code: number) => { res._status = code; return res; },
63+
json: (body: any) => { res._json = body; return res; },
64+
};
65+
return res;
66+
}
67+
68+
const liveEngines: ObjectQL[] = [];
69+
afterEach(async () => {
70+
while (liveEngines.length) {
71+
try { await liveEngines.pop()?.destroy(); } catch { /* noop */ }
72+
}
73+
});
74+
75+
async function boot() {
76+
const engine = new ObjectQL();
77+
liveEngines.push(engine);
78+
engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true);
79+
await engine.init();
80+
engine.registry.registerObject(PROJ as any);
81+
await engine.syncSchemas();
82+
await engine.insert(OBJECT, { id: 'e1', title: 'existing', n: 1 } as any);
83+
84+
const protocol = new ObjectStackProtocolImplementation(engine as any);
85+
const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any);
86+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
87+
rest.registerRoutes();
88+
const call = async (method: string, path: string, body: unknown, object: string = OBJECT): Promise<{ status: number; body: any }> => {
89+
const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path);
90+
expect(route, `${method} ${path}`).toBeDefined();
91+
const res = makeRes();
92+
await route!.handler({ params: { object }, body, query: {}, headers: {} } as any, res);
93+
return { status: res._status ?? 200, body: res._json };
94+
};
95+
const importRows = (rows: Array<Record<string, unknown>>, opts: { dryRun: boolean; writeMode?: 'insert' | 'upsert' }) =>
96+
call('POST', '/api/v1/data/:object/import', {
97+
format: 'json', rows, dryRun: opts.dryRun,
98+
...(opts.writeMode === 'upsert' ? { writeMode: 'upsert', matchFields: ['id'] } : {}),
99+
});
100+
return { engine, call, importRows };
101+
}
102+
103+
type Boot = Awaited<ReturnType<typeof boot>>;
104+
105+
describe('[#20701] an unknown column fails the row on the dry run and on the commit alike, and both rows name it', () => {
106+
let b: Boot;
107+
beforeEach(async () => { b = await boot(); });
108+
109+
it.each([
110+
['insert', [{ title: 't', n: 2, nope: 5 }, { title: 'control', n: 3 }]],
111+
['upsert', [{ id: 'e1', title: 't', nope: 5 }, { id: 'n1', title: 'control', n: 3 }]],
112+
] as const)('writeMode %s', async (writeMode, rows) => {
113+
for (const dryRun of [true, false]) {
114+
const r = await b.importRows(rows as any, { dryRun, writeMode });
115+
expect(r.status, JSON.stringify(r.body)).toBe(200);
116+
expect(r.body.results[0], `dryRun=${dryRun}`).toMatchObject({
117+
row: 1, ok: false, action: 'failed', code: 'INVALID_FIELD', field: 'nope',
118+
});
119+
// The second row is the control: the refusal is the row's, not the file's.
120+
expect(r.body.results[1], `dryRun=${dryRun}`).toMatchObject({ row: 2, ok: true });
121+
expect(r.body.errors, `dryRun=${dryRun}`).toBe(1);
122+
}
123+
});
124+
125+
it('the row names the same field the create door names for the same key', async () => {
126+
const door = await b.call('POST', '/api/v1/data/:object', { title: 't', nope: 5 });
127+
expect(door.status).toBe(400);
128+
expect(door.body).toMatchObject({ code: 'INVALID_FIELD', field: 'nope' });
129+
const commit = await b.importRows([{ title: 't', nope: 5 }], { dryRun: false });
130+
expect(commit.body.results[0]).toMatchObject({ code: door.body.code, field: door.body.field });
131+
});
132+
});
133+
134+
describe('[#20701] a unique conflict row names the column the 409 names — the same envelope member', () => {
135+
it('the engine\'s DuplicateRecordError carries `field`; the commit row reads it', async () => {
136+
const b = await boot();
137+
const UQ = 'uq_20701';
138+
b.engine.registry.registerObject({
139+
name: UQ, systemFields: false,
140+
fields: { id: { name: 'id', type: 'text', primaryKey: true }, code: { name: 'code', type: 'text', unique: true } },
141+
} as any);
142+
await b.engine.syncSchemas();
143+
await b.engine.insert(UQ, { id: 'a', code: 'X' } as any);
144+
const door = await b.call('POST', '/api/v1/data/:object', { code: 'X' }, UQ);
145+
expect(door.status).toBe(409);
146+
expect(door.body).toMatchObject({ code: 'UNIQUE_VIOLATION', field: 'code' });
147+
const commit = await b.call('POST', '/api/v1/data/:object/import', { format: 'json', rows: [{ code: 'X' }, { code: 'Y' }], dryRun: false }, UQ);
148+
expect(commit.body.results[0]).toMatchObject({ row: 1, ok: false, action: 'failed', code: door.body.code, field: door.body.field });
149+
expect(commit.body.results[1]).toMatchObject({ row: 2, ok: true, action: 'created' });
150+
});
151+
});
152+
153+
describe('[#20701] a writable column imports — the control', () => {
154+
it('dry run and commit both create the row, and the commit stores it', async () => {
155+
const b = await boot();
156+
const preview = await b.importRows([{ id: 'w1', title: 'written', n: 4 }], { dryRun: true });
157+
expect(preview.body).toMatchObject({ ok: 1, errors: 0, created: 1 });
158+
expect(preview.body.results[0]).toMatchObject({ row: 1, ok: true, action: 'created' });
159+
const commit = await b.importRows([{ id: 'w1', title: 'written', n: 4 }], { dryRun: false });
160+
expect(commit.body).toMatchObject({ ok: 1, errors: 0, created: 1 });
161+
expect(commit.body.results[0]).toMatchObject({ row: 1, ok: true, action: 'created', id: 'w1' });
162+
const stored = await b.engine.findOne(OBJECT, { where: { id: 'w1' } });
163+
expect(stored).toMatchObject({ title: 'written', n: 4, doubled: 8 });
164+
});
165+
});
166+
167+
describe('[#20701] a formula or readonly column: the dry run and the commit both succeed', () => {
168+
it.each([
169+
['formula', { id: 'f1', title: 't', n: 2, doubled: 5 }],
170+
['readonly', { id: 'f1', title: 't', n: 2, ro: 'forged' }],
171+
] as const)('%s column', async (_label, row) => {
172+
const b = await boot();
173+
for (const dryRun of [true, false]) {
174+
const r = await b.importRows([row], { dryRun });
175+
expect(r.body, `dryRun=${dryRun}`).toMatchObject({ ok: 1, errors: 0, created: 1 });
176+
expect(r.body.results[0], `dryRun=${dryRun}`).toMatchObject({ row: 1, ok: true, action: 'created' });
177+
}
178+
// The supplied value was not stored: the formula answers from `n`, and the readonly column stays empty.
179+
const stored = await b.engine.findOne(OBJECT, { where: { id: 'f1' } });
180+
expect(stored).toMatchObject({ n: 2, doubled: 4 });
181+
expect(stored?.ro ?? null).toBeNull();
182+
});
183+
});
184+
185+
describe('[#20701] the driver-string branch does not call a declared field unknown', () => {
186+
it('a declared field whose column is missing: 400 INVALID_FIELD naming it, and the sentence is the database\'s', async () => {
187+
const b = await boot();
188+
// The object declares `late` after the DDL ran, with no re-sync — metadata and the table have drifted.
189+
b.engine.registry.registerObject({ ...PROJ, fields: { ...PROJ.fields, late: { name: 'late', type: 'text' as const } } } as any);
190+
const r = await b.call('POST', '/api/v1/data/:object', { title: 't', late: 'x' });
191+
expect(r.status, JSON.stringify(r.body)).toBe(400);
192+
expect(r.body).toMatchObject({ code: 'INVALID_FIELD', field: 'late', object: OBJECT });
193+
// The wording IS the fix here, so its first sentence is pinned.
194+
expect(String(r.body.error).split('. ')[0]).toBe(`The database table of object '${OBJECT}' has no column for field 'late'`);
195+
expect(String(r.body.error)).not.toMatch(/unknown field/i);
196+
});
197+
198+
it('an undeclared key is still refused by the engine\'s own door, in its own words — the control', async () => {
199+
const b = await boot();
200+
const r = await b.call('POST', '/api/v1/data/:object', { title: 't', nope: 'x' });
201+
expect(r.status).toBe(400);
202+
expect(r.body).toMatchObject({ code: 'INVALID_FIELD', field: 'nope', object: OBJECT });
203+
expect(String(r.body.error)).toContain("Unknown field 'nope'");
204+
});
205+
});

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

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -340,17 +340,31 @@ export function sanitizeRowError(raw: unknown): string {
340340
* `DUPLICATE_RECORD` without being the engine's class keeps its own code, as it
341341
* does at the door. The engine's thrown identity is unchanged; only the row's
342342
* wire spelling moves.
343+
*
344+
* ## The column comes from the envelope's own `field` when no finding names one
345+
*
346+
* Not every refusal is a `ValidationError`. The engine's declared-field door
347+
* throws the ADR-0112 envelope `INVALID_FIELD` / 400 with the column in its
348+
* `field` member and the bare names in `fields` — strings, not findings — and
349+
* `DuplicateRecordError` carries `field` when the dialect named the column.
350+
* Reading `field` only off a finding left those rows naming no column, while
351+
* `POST /data/:object` answered the same key with `field` set. Since
352+
* `engine.validate` runs that door too, an unknown column now fails the dry
353+
* run and the commit alike, and both rows name it. A finding still wins, as
354+
* it does for `code`; an entry of `fields` that is not an object is not a
355+
* finding.
343356
*/
344357
function toFailedResult(rowNo: number, err: unknown): ImportRowResult {
345-
const e = err as { code?: unknown; message?: unknown; fields?: unknown } | null | undefined;
346-
const fields = Array.isArray(e?.fields) ? (e.fields as Array<{ field?: unknown; code?: unknown }>) : [];
347-
const first = fields[0];
358+
const e = err as { code?: unknown; message?: unknown; fields?: unknown; field?: unknown } | null | undefined;
359+
const head: unknown = Array.isArray(e?.fields) ? e.fields[0] : undefined;
360+
const first = head !== null && typeof head === 'object' ? (head as { field?: unknown; code?: unknown }) : undefined;
348361
const thrownCode = isEngineDuplicateRecordEnvelope(e) ? 'UNIQUE_VIOLATION' : e?.code;
349362
const code = first?.code ?? thrownCode ?? 'IMPORT_ROW_FAILED';
363+
const field = first?.field != null && first.field !== '' ? first.field : e?.field;
350364
const message = sanitizeRowError(e?.message);
351365
return {
352366
row: rowNo, ok: false, action: 'failed', error: message, code: String(code),
353-
...(first?.field != null && first.field !== '' ? { field: String(first.field) } : {}),
367+
...(field != null && field !== '' ? { field: String(field) } : {}),
354368
};
355369
}
356370

0 commit comments

Comments
 (0)