Skip to content

Commit f80e2a6

Browse files
fix(rest): an import row for a missing database column answers what the create door answers (#20941)
Part of #20701 Clause-②: no Item 4 of the REST half of #20701 (the schema-drift row), as triage's answer `5917724406` scopes it. Item 1, the drop signal on the row report, is not in this PR: it waits on #20922, which waits on #20921. #20701 remains open. ## What changed `toFailedResult` (`packages/rest/src/import-runner.ts`) now classifies a write error through `mapDataError`, the mapper `POST /api/v1/data/:object` uses, for the same object. When the mapper's verdict is `INVALID_FIELD`, the row carries the door's `code`, `field` and sentence. Every other verdict leaves the row as it was. So a declared field whose database column is missing (metadata and the physical schema have drifted) now fails the commit row, and the async job's rows, the way the create door fails it: `INVALID_FIELD`, `field` set, and the sentence "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." Before, the row carried the driver's code (`SQLITE_ERROR`), the driver's text and no `field`. The sync route and the async job worker both run through `runImport`, so one function covers both. A field-level finding still wins over the door, as it does for `code`. An undeclared key reads exactly as before, because the door relays the engine's own `INVALID_FIELD` envelope in the engine's own words. `packages/rest/src/error-response.ts` is not edited: `mapDataError` is exported, side-effect free and callable as it stands. ## Repro (SQLite `:memory:`, real ObjectQL + `ObjectStackProtocolImplementation` + RestServer routes) Drift is made the way it happens: the object declares `late` after the DDL ran, with no re-sync. | surface | before (base `67c1b11a20`) | after (`132e93c05b`) | |:--|:--|:--| | `POST /api/v1/data/:object` with `late` | `400 INVALID_FIELD`, `field: 'late'`, the drift sentence | unchanged | | import commit, insert | failed, `SQLITE_ERROR`, "table proj_probe has no column named late", no `field` | failed, `INVALID_FIELD`, `field: 'late'`, the door's sentence | | import commit, upsert onto an existing row | failed, `SQLITE_ERROR`, "no such column: late", no `field` | failed, `INVALID_FIELD`, `field: 'late'`, the door's sentence | | async job, `GET .../import/jobs/:jobId/results` | failed, `SQLITE_ERROR`, the driver's text, no `field` | failed, `INVALID_FIELD`, `field: 'late'`, the door's sentence | | import dry run | ok, created | ok, created (unchanged, see Acceptance notes) | | unique conflict row (control) | `UNIQUE_VIOLATION`, `field: 'code'`, the engine's sentence | byte-identical | | NOT NULL row (control) | `SQLITE_CONSTRAINT_NOTNULL`, "must is required.", no `field` | byte-identical | | writable row beside the drifted one (control) | ok, created | ok, created | ## Why only the `INVALID_FIELD` verdict The claim's controls say a unique-conflict row and a NOT NULL row keep today's answers. The door answers both in other words, so routing the whole row through the mapper would move them: - **Unique conflict.** Row: `UNIQUE_VIOLATION`, `field`, and the engine's sentence ("Duplicate record refused on 'X': a unique constraint on 'code' already holds this value. No record was written."). Door: the same `code` and `field`, but the curated sentence "A record with this code already exists" (no period), with the engine's sentence moved to `developerMessage`. - **NOT NULL.** Row: the driver's code (`SQLITE_CONSTRAINT_NOTNULL` on SQLite), "must is required.", no `field`. Door: `400 VALIDATION_FAILED` with a `required` finding for `must`, "must is required" and a drift `hint`. These differences are named in the report on #20701. This PR does not change them. ## Other dialects (read from source, NOT MEASURED) The mapper's driver-string branch recognises SQLite `has no column named c` and `no such column: c`, MySQL `Unknown column 'c'` (the pattern is not anchored to `in 'field list'`, so an update or where-clause spelling matches too), and Postgres through `matchMissingColumnOfRelation`, which matches only the write-path template `column "c" of relation "t" does not exist`. The Postgres read-path spelling `column "c" does not exist` is not matched, so an upsert match lookup on a drifted column in Postgres would keep the row's old answer. The mapper is not widened here. ## Pins `packages/rest/src/import-row-schema-drift-20701.test.ts`, 5 cases through the public doors: the insert commit row, the upsert (update path) commit row and the async job's results row each equal the create door's `{ code, field, error }` and carry none of the driver's words; a writable row beside the drifted one is created (control); the unique-conflict and NOT NULL rows keep their answers (controls). The control pins say "unchanged by this PR", not "right": a card that converges them updates them on purpose. ## Verification (head `132e93c05b`) Heavy steps ran under `scripts/pm/os-verify-lock.sh`. - **Dependency closure** `turbo run build --filter='@objectstack/rest^...'`: 24 tasks successful. - **Pins plus the neighbouring import suites** (the new file, `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 throwaway probe): `Test Files 9 passed (9)`, `Tests 160 passed (160)`. - **Package suite** `pnpm --filter @objectstack/rest test`: `Test Files 243 passed (243)`, `Tests 4858 passed | 114 skipped (4972)`. The `--maxWorkers=2` I passed after a bare `--` was dropped by vitest, so this ran with the default worker count; it is still the whole `local` project. The package's one `repo`-project test reads none of the changed files. - **Typecheck** `pnpm --filter @objectstack/rest typecheck`: exit 0, `check:test-typecheck: OK`; `tsc -p tsconfig.test.json --listFilesOnly` lists the new pin file. - **Ablation**, with the change committed first, through `scripts/ablation-replace.mjs` WRAP mode. The subject is read from `packages/rest/src` by relative import, so no `dist/` is involved. Anchor `const door = mapDataError(err, objectName).body;` replaced by an empty door, 1 to 0; blob `e8012ff2402a` to `bf0b6d366a5c`. Result over the new file and the #20905 pin file: 3 failed, 11 passed. The three are the insert, upsert and async-job drift cells, each back at `SQLITE_ERROR` with the driver's text; the controls and the #20905 pins stayed green, as predicted. Restored: `blob == HEAD (e8012ff)`, `git diff HEAD` empty. - **Gates**: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `132e93c05b` derives 61 commands. Each ran with its exit code recorded before any pipe, and `--ran` reads "61 derived famil(ies) accounted for — 60 run, 1 NOT-MEASURED". NOT MEASURED: `check:dual-build-cjs-loads`, `PREREQUISITE NOT MET` (exit 3), because it needs every package's `dist/`, which is the full build CI runs. `check:dts-closure`, `check:sourcemap-no-sources-content` and `check:type-check-debt` were re-run after building `@objectstack/rest` and the six packages the debt re-measure needs, and read 0. - **Lint**, narrowed with proof at `132e93c05b`. (1) eslint's own config lints the two `.ts` files in this diff, and `isPathIgnored` is true for the changeset `.md`, so those two files are the diff's whole lint population. (2) `eslint --no-inline-config --format json` over them: `files=2 errors=0 warnings=0`. (3) `calculateConfigForFile` shows `parserOptions.project` and `projectService` null on both, so type-aware linting is off and this diff cannot change eslint's verdict on any file it does not touch. - Control-byte self-scan over the three changed files: clean. ## Acceptance notes - **The dry run still previews a drifted column as ok.** `engine.validate` reads the metadata, never the table, so the preview promises a row the commit then fails. That is the card's own path ("an import preview promises only rows the commit will write"), and the report names it for the seat. The new pin file does not vouch for the dry-run reading. - **A sandboxed producer that throws `INVALID_FIELD` with no field-level finding** now gets the door's answer on its row too, which is the business sentence the sandbox carried rather than the QuickJS debug wrapper. Read from source (`classifyDataError`'s sandbox unwrap), NOT MEASURED. - **Rows whose door verdict is not `INVALID_FIELD` keep `sanitizeRowError`.** That covers the two controls above, and any driver text `sanitizeRowError` passes through because it does not read as SQL. The door's answer for those was not measured here. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3fbf3ca commit f80e2a6

3 files changed

Lines changed: 260 additions & 4 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest): an import row for a missing database column answers what the create door answers (#20701)
6+
7+
**`POST /api/v1/data/:object/import` and the async import job.** When an object
8+
declares a field whose database column is missing (the schema has drifted from
9+
the metadata), a committed row that writes that field now fails with
10+
`code: 'INVALID_FIELD'`, `field` set to the field, and the sentence
11+
`POST /api/v1/data/:object` gives for the same key: "The database table of
12+
object 'X' has no column for field 'f'. If the object declares 'f', its database
13+
schema has drifted from the metadata: run 'os migrate' to reconcile." It used to
14+
fail with the database's own code and text (for example `SQLITE_ERROR` and
15+
`table X has no column named f`) and no `field`. The async job's rows, read from
16+
`GET /api/v1/data/import/jobs/:jobId/results`, change the same way.
17+
18+
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,
21+
because it checks the metadata and not the table, so it previews such a row as
22+
`ok`.
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
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`.
9+
*
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.
13+
*
14+
* Measured on the base (`67c1b11a20`):
15+
*
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+
*
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.
29+
*
30+
* ⚠️ 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.
34+
*/
35+
36+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
37+
import { ObjectQL } from '@objectstack/objectql';
38+
import { SqlDriver } from '@objectstack/driver-sql';
39+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
40+
import { SysImportJob } from '@objectstack/platform-objects/audit';
41+
import { RestServer } from './rest-server';
42+
43+
const OBJECT = 'proj_drift_20701';
44+
45+
const PROJ = {
46+
name: OBJECT, label: 'Proj drift 20701', systemFields: false,
47+
fields: {
48+
id: { name: 'id', type: 'text' as const, primaryKey: true },
49+
title: { name: 'title', type: 'text' as const },
50+
code: { name: 'code', type: 'text' as const, unique: true },
51+
// Physically NOT NULL, not `required`: the record validator lets a missing
52+
// value through, and the driver refuses it (ADR-0113).
53+
must: { name: 'must', type: 'text' as const, storage: { notNull: true } },
54+
},
55+
};
56+
57+
/** The same object, now also declaring `late`, which has no column. */
58+
const PROJ_DRIFTED = { ...PROJ, fields: { ...PROJ.fields, late: { name: 'late', type: 'text' as const } } };
59+
60+
function createMockServer() {
61+
const noop = () => {};
62+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
63+
}
64+
65+
function makeRes() {
66+
const res: any = {
67+
write: () => true, end: () => {},
68+
header: () => res,
69+
status: (code: number) => { res._status = code; return res; },
70+
json: (body: any) => { res._json = body; return res; },
71+
};
72+
return res;
73+
}
74+
75+
const liveEngines: ObjectQL[] = [];
76+
afterEach(async () => {
77+
while (liveEngines.length) {
78+
try { await liveEngines.pop()?.destroy(); } catch { /* noop */ }
79+
}
80+
});
81+
82+
async function boot() {
83+
const engine = new ObjectQL();
84+
liveEngines.push(engine);
85+
engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true);
86+
await engine.init();
87+
engine.registry.registerObject(PROJ as any);
88+
engine.registry.registerObject(SysImportJob as any);
89+
await engine.syncSchemas();
90+
await engine.insert(OBJECT, { id: 'e1', title: 'existing', code: 'X', must: 'm' } as any);
91+
// The drift: `late` is declared after the DDL ran.
92+
engine.registry.registerObject(PROJ_DRIFTED as any);
93+
94+
const protocol = new ObjectStackProtocolImplementation(engine as any);
95+
const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any);
96+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
97+
rest.registerRoutes();
98+
const call = async (method: string, path: string, body: unknown, params: Record<string, string> = { object: OBJECT }): Promise<{ status: number; body: any }> => {
99+
const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path);
100+
expect(route, `${method} ${path}`).toBeDefined();
101+
const res = makeRes();
102+
await route!.handler({ params, body, query: {}, headers: {} } as any, res);
103+
return { status: res._status ?? 200, body: res._json };
104+
};
105+
const importRows = (rows: Array<Record<string, unknown>>, opts: { writeMode?: 'insert' | 'upsert' } = {}) =>
106+
call('POST', '/api/v1/data/:object/import', {
107+
format: 'json', rows, dryRun: false,
108+
...(opts.writeMode === 'upsert' ? { writeMode: 'upsert', matchFields: ['id'] } : {}),
109+
});
110+
/** What the create door answers for `late`: the answer every drift row must carry. */
111+
const doorAnswer = async () => {
112+
const door = await call('POST', '/api/v1/data/:object', { title: 't', must: 'm', late: 'x' });
113+
expect(door.status, JSON.stringify(door.body)).toBe(400);
114+
expect(door.body).toMatchObject({ code: 'INVALID_FIELD', field: 'late', object: OBJECT });
115+
return { code: door.body.code, field: door.body.field, error: door.body.error };
116+
};
117+
return { engine, call, importRows, doorAnswer };
118+
}
119+
120+
type Boot = Awaited<ReturnType<typeof boot>>;
121+
122+
/** The driver's own words for a missing column, on the insert and on the update. */
123+
const DRIVER_TEXT = /has no column named|no such column|SQLITE_/i;
124+
125+
function expectDoorAnswer(row: any, door: { code: unknown; field: unknown; error: unknown }, rowNo: number) {
126+
expect(row, JSON.stringify(row)).toMatchObject({ row: rowNo, ok: false, action: 'failed', ...door });
127+
expect(String(row.error)).not.toMatch(DRIVER_TEXT);
128+
}
129+
130+
describe('[#20701] a drifted column fails the commit row with the create door\'s answer', () => {
131+
let b: Boot;
132+
beforeEach(async () => { b = await boot(); });
133+
134+
it('insert: the bulk create path', async () => {
135+
const door = await b.doorAnswer();
136+
const r = await b.importRows([{ title: 't', must: 'm', late: 'x' }, { id: 'w1', title: 'control', must: 'm' }]);
137+
expect(r.status, JSON.stringify(r.body)).toBe(200);
138+
expectDoorAnswer(r.body.results[0], door, 1);
139+
// A writable row in the same file imports: the refusal is the row's, not the file's.
140+
expect(r.body.results[1]).toMatchObject({ row: 2, ok: true, action: 'created', id: 'w1' });
141+
expect(r.body).toMatchObject({ ok: 1, errors: 1, created: 1 });
142+
expect(await b.engine.findOne(OBJECT, { where: { id: 'w1' } })).toMatchObject({ title: 'control' });
143+
});
144+
145+
it('upsert onto an existing row: the update path', async () => {
146+
const door = await b.doorAnswer();
147+
const r = await b.importRows([{ id: 'e1', title: 't', late: 'x' }], { writeMode: 'upsert' });
148+
expect(r.status, JSON.stringify(r.body)).toBe(200);
149+
expectDoorAnswer(r.body.results[0], door, 1);
150+
expect(await b.engine.findOne(OBJECT, { where: { id: 'e1' } })).toMatchObject({ title: 'existing' });
151+
});
152+
});
153+
154+
describe('[#20701] the async import job\'s rows carry the same answer', () => {
155+
it('the results route reports the door\'s answer for the drifted row, and the writable row is created', async () => {
156+
const b = await boot();
157+
const door = await b.doorAnswer();
158+
const created = await b.call('POST', '/api/v1/data/:object/import/jobs', {
159+
format: 'json', dryRun: false,
160+
rows: [{ title: 't', must: 'm', late: 'x' }, { id: 'w2', title: 'control', must: 'm' }],
161+
});
162+
expect(created.status, JSON.stringify(created.body)).toBe(201);
163+
const jobId = String(created.body.jobId);
164+
let progress: { status: number; body: any } | undefined;
165+
for (let i = 0; i < 200; i++) {
166+
progress = await b.call('GET', '/api/v1/data/import/jobs/:jobId', undefined, { jobId });
167+
if (['succeeded', 'failed', 'cancelled'].includes(progress.body?.status)) break;
168+
await new Promise((r) => setTimeout(r, 5));
169+
}
170+
expect(progress?.body).toMatchObject({ status: 'succeeded', created: 1, errors: 1 });
171+
const results = await b.call('GET', '/api/v1/data/import/jobs/:jobId/results', undefined, { jobId });
172+
const rows: any[] = results.body.results;
173+
expectDoorAnswer(rows.find((r) => r.row === 1), door, 1);
174+
expect(rows.find((r) => r.row === 2)).toMatchObject({ row: 2, ok: true, action: 'created', id: 'w2' });
175+
});
176+
});
177+
178+
describe('[#20701] controls: a unique conflict and a NOT NULL row keep the answers they had', () => {
179+
let b: Boot;
180+
beforeEach(async () => { b = await boot(); });
181+
182+
it('unique conflict: UNIQUE_VIOLATION naming the column, with the engine\'s sentence', async () => {
183+
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+
});
191+
});
192+
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');
199+
});
200+
});

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

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { ValidationMessageTranslator } from '@objectstack/spec/system';
77
import type { CreateDataRequest, FindDataRequest, UpdateDataRequest, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api';
88
import { bulkWrite, withTransientRetry, defaultIsTransientError, type BulkWriteRowResult } from '@objectstack/core';
99
import { isUniqueViolationError, uniqueViolationColumn } from '@objectstack/types';
10-
import { isEngineDuplicateRecordEnvelope } from './error-response.js';
10+
import { isEngineDuplicateRecordEnvelope, mapDataError } from './error-response.js';
1111

1212
/**
1313
* import-runner — the shared row-processing core for bulk import.
@@ -353,11 +353,45 @@ export function sanitizeRowError(raw: unknown): string {
353353
* run and the commit alike, and both rows name it. A finding still wins, as
354354
* it does for `code`; an entry of `fields` that is not an object is not a
355355
* finding.
356+
*
357+
* ## A column the create door judges gets the create door's answer (#20701)
358+
*
359+
* A write can also fail in the DRIVER on a column: the object declares a
360+
* field whose column the table does not have, because metadata and the
361+
* physical schema drifted apart. The engine's declared-field door passes the
362+
* key, since the field is declared, and the driver refuses it in its own words
363+
* (SQLite `table X has no column named c` on insert, `no such column: c` on
364+
* update). `POST /data/:object` classifies that error through `mapDataError`'s
365+
* driver-string branch into `400 INVALID_FIELD`, with `field` and a sentence
366+
* that names the drift and its fix. The row used to relay the driver's code
367+
* (`SQLITE_ERROR`) and its text instead. So the error is classified here by
368+
* the same mapper, for the same object, and when the door's verdict is
369+
* `INVALID_FIELD` the row carries the door's `code`, `field` and sentence. The
370+
* commit and the async job's rows both come through this function. An
371+
* undeclared key reads as before: the door relays the engine's own envelope,
372+
* in the engine's own words.
373+
*
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.
356381
*/
357-
function toFailedResult(rowNo: number, err: unknown): ImportRowResult {
382+
function toFailedResult(rowNo: number, err: unknown, objectName: string): ImportRowResult {
358383
const e = err as { code?: unknown; message?: unknown; fields?: unknown; field?: unknown } | null | undefined;
359384
const head: unknown = Array.isArray(e?.fields) ? e.fields[0] : undefined;
360385
const first = head !== null && typeof head === 'object' ? (head as { field?: unknown; code?: unknown }) : undefined;
386+
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+
}
394+
}
361395
const thrownCode = isEngineDuplicateRecordEnvelope(e) ? 'UNIQUE_VIOLATION' : e?.code;
362396
const code = first?.code ?? thrownCode ?? 'IMPORT_ROW_FAILED';
363397
const field = first?.field != null && first.field !== '' ? first.field : e?.field;
@@ -776,7 +810,7 @@ export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
776810
...(flushSummaryStale ? { code: 'SUMMARY_RECOMPUTE_FAILED' } : {}) };
777811
} else {
778812
errCount++;
779-
results[index] = toFailedResult(rowNo, res.error);
813+
results[index] = toFailedResult(rowNo, res.error, objectName);
780814
}
781815
}
782816
};
@@ -892,7 +926,7 @@ export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
892926
}
893927
} catch (err: any) {
894928
errCount++;
895-
results[i] = toFailedResult(rowNo, err);
929+
results[i] = toFailedResult(rowNo, err, objectName);
896930
}
897931

898932
const processed = i + 1;

0 commit comments

Comments
 (0)