Skip to content

Commit 2f21b48

Browse files
fix(core): an import row with several bad cells reports every one in errors (#25582) (#26543)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X9e2hLC2g1DGQjmADaUWxvb)_ Fixes #25582 This closes the card: maintainer ruling `6377844879` letter A ruled proposals 1, 2 and 4, and not 3. Proposals 1 and 2 landed in #25596; this PR is proposal 4, so every ruled proposal has then landed. Proposal 3 (option `aliases`) is not ruled and nothing here implements it. Clause-②: yes (widening: a failed import row whose cells did not coerce gains the optional `errors` list, `ImportRowResultSchema.errors`; every existing key keeps its value) Metadata-protocol: none ## 维护者速读 - **改了什么**:导入时一行有多个坏单元格,行报告新增 `errors` 列表,按列序列出每个坏单元格(`field`、`code`、`message`);原来的 `field` / `code` / `error` 不变,仍指第一个。 - **为什么改**:以前只报第一个坏单元格,操作员改一格、重导一次才看到下一个,试导时每行要两三轮(#25582 裁决 A 第 4 条)。 - **风险与回滚**:只是响应里多一个可选键,同步导入、异步任务、试运行、正式提交都一样;回滚即撤销 `import-runner.ts` 那一处赋值。 - **先看**:`packages/core/src/utils/import-runner.ts`(行报告赋值处与 `ImportRowResult.errors` 注释),`packages/rest/src/import-row-cell-errors.test.ts`(四条路径加两种不带 `errors` 的失败)。 - **两分钟验证**:`pnpm --filter @objectstack/rest exec vitest run src/import-row-cell-errors.test.ts`,预期 6 个用例全过;用例里一行同时填坏日期、坏数字、坏选项,结果的 `errors` 按列序列出这三项。 - **需要你做的**:无,席位落地。 ## What changes - `runImport` (`packages/core/src/utils/import-runner.ts`) copies `coerceRow`'s cell errors onto the failed row as `errors`, one `{ field, code, message }` element per failed cell, in column order. The first-error keys `field`, `code` and `error` are unchanged and are element 0, as `ImportRowResultSchema.errors` (landed in #26454) states. - Filled at the one place a row fails on cell coercion, which comes before the dry-run / commit split, so it reaches the dry run and the commit, on the synchronous `POST /api/v1/data/:object/import` and on the async job's `GET /api/v1/data/import/jobs/:jobId/results` (the job stores `summary.results` and the results route returns the items verbatim). - Not filled, per the spec seat's fill rule (`6434667735`): a validate-verdict row (`error` is `composeValidationMessage(...)`), a refused write (`toFailedResult` / `adoptDoorVerdict`), and the duplicate / ambiguous / blank match-key rows. - `ImportRowResult` (`@objectstack/core`) gains the optional `errors?: ValidateDataIssue[]`, documented with that rule. - Changeset: `@objectstack/core` patch. ## Why An operator importing a sheet with several bad cells in a row fixed one cell, re-ran, and only then saw the next. The trial in the card took two to three rounds per row. ## Tests - `packages/core/src/utils/import-runner-cell-errors.test.ts` (new, 5 cases, against an `ImportProtocolLike` double): three bad cells around a good one report all three in column order on the dry run and on the commit (the commit writes nothing), element 0 equals the first-error keys, every element has exactly `field` / `code` / `message`; one bad cell gives a one-element list; a validate-verdict refusal and a refused write carry no `errors`. - `packages/rest/src/import-row-cell-errors.test.ts` (new, 6 cases, real `ObjectQL` + protocol + SQLite with `sys_import_job`): the same row through the sync route and the async job, each on the dry run and the commit; and the absence pin for a dry-run validate verdict and a commit refused write (a missing `required` field), sync and async. - Red first on `main` (`45de8c6cba`), tests committed alone as `46ca67cf`: core 3 failed / 2 passed, route 4 failed / 2 passed. The four route reds read the first cell only (`field: opened`, `code: invalid_date`) with no `errors` key. The two absence pins pass on `main` too, as they should. ## Acceptance notes - A JSON-format route test must register the stored-metadata family (`sys_metadata` and siblings) for the protocol to hand the route the object's field metadata. Without it every cell passes through uncoerced and the engine's validator answers instead, so a row with bad cells reports a joined validation message and no cell errors. The new route test registers them, as `import-year-first-day-option-case.test.ts` does. That is test setup, not a product path. - Proposal 3 (`aliases` on options) stays unruled; closing this card does not decide it. ## Verification - Head `7a40605863` (after merging `origin/main`; the merge touched no import file). - `pnpm --filter @objectstack/core test`: 123 files / 2793 tests passed; `pnpm --filter @objectstack/core typecheck` passed (test layer included). Measured at `a975cbab`, before the merge. - `pnpm --filter @objectstack/rest test`: 325 files, 6489 passed / 326 skipped; `pnpm --filter @objectstack/rest typecheck` passed. Measured at `a975cbab`. - At `7a40605863`: the two new files plus `import-year-first-day-option-case`, `import-dryrun-parity`, `import-row-schema-drift-20701` and `import-job-integration`: 6 files / 49 tests passed. - Ablation, once, through `scripts/ablation-replace.mjs`: the `errors:` fill line deleted (anchor 1 to 0, blob `4983d0e39a26` to `923db228c535`), core rebuilt, `ablation-dist-preflight --absent` passed (marker absent from all 14 built files). Core test 3 failed / 2 passed, route test 4 failed / 2 passed. Restored: blob equals HEAD, `git diff HEAD` empty; rebuilt, preflight found the marker back in `dist/` and the tree clean. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 61 commands at `7a40605863`; all 61 exit 0. Two first ran exit 3 for a missing prerequisite (`check-plugin-teardown-shape --self-test` on a shallow clone; `check:dual-build-cjs-loads` before a full build) and were re-run green after `git fetch --unshallow` and the full build `check:type-check-debt` performs. `--ran`: "61 derived famil(ies) accounted for — 61 run, 0 NOT-MEASURED". - `pnpm exec eslint --no-inline-config packages/core packages/rest`: exit 0. - Not run locally: `test:repo` (no repo-project test touched; CI Test Core runs it) and the full dogfood battery (no `!` in the title and no narrowing arm). ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No `packages/spec` change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (scoped to the touched packages). - [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: n/a. - [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 maintainer brief is in Chinese by convention). --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5otdGFpkLtVKZRmYpa)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6a7c0fe commit 2f21b48

4 files changed

Lines changed: 351 additions & 1 deletion

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
fix(core): an import row with several bad cells reports every one of them in `errors` (#25582)
6+
7+
Clause-②: yes (widening: a failed import row whose cells did not coerce gains the optional `errors` list, `ImportRowResultSchema.errors`; every existing key keeps its value)
8+
Metadata-protocol: none
9+
Ruling-ref: #25582 ruling `6377844879` letter A, proposal 4; the spec seat's fill rule `6434667735`
10+
11+
A row with several bad cells used to report only the first one, so an operator fixed one cell, imported again, and only then saw the next. The row now carries `errors`, one `{ field, code, message }` element per failed cell, in column order. The first-error keys `field`, `code` and `error` are unchanged and name element 0, so a reader of those keys sees the same answer as before.
12+
13+
- It reaches every path that reports rows: the dry run and the commit, on the synchronous `POST /api/v1/data/:object/import` and on the async import job's results.
14+
- It is filled only from the import's cell-coercion errors (`invalid_date`, `invalid_number`, `invalid_option` and the rest). A row that failed for another reason omits it: the dry run's validate verdict, a refused write, a duplicate or ambiguous match key.
15+
- `ImportRowResult` (`@objectstack/core`) gains the optional `errors?: ValidateDataIssue[]`.
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25582, maintainer ruling 6377844879 letter A, proposal 4] A row with
5+
* several bad cells reports every one of them in `errors`, one element per
6+
* failed cell in column order, beside the first-error keys `field` / `code` /
7+
* `error`, which keep naming element 0 (`ImportRowResultSchema.errors`).
8+
*
9+
* The list is filled from `coerceRow`'s cell errors only. A row the validate
10+
* verdict refuses (the dry run) and a row whose write is refused (the commit,
11+
* through `toFailedResult`) carry no `errors`: on those paths `error` is not
12+
* element 0's message, and the spec's describe names cell-coercion errors as
13+
* the list's only source.
14+
*
15+
* Driven against an `ImportProtocolLike` double; the route, the async job and
16+
* a real engine are pinned in `packages/rest/src/import-row-cell-errors.test.ts`.
17+
*/
18+
19+
import { describe, it, expect, vi } from 'vitest';
20+
import { runImport, type ImportProtocolLike } from './import-runner';
21+
import type { ExportFieldMeta } from './import-field-meta.js';
22+
23+
type ValidateArgs = Parameters<NonNullable<ImportProtocolLike['validateData']>>[0];
24+
25+
const OBJECT = 'supplier';
26+
27+
const metaMap = new Map<string, ExportFieldMeta>([
28+
['name', { name: 'name', type: 'text', label: 'Name' }],
29+
['opened', { name: 'opened', type: 'date', label: 'Opened' }],
30+
['qty', { name: 'qty', type: 'number', label: 'Qty' }],
31+
['country', { name: 'country', type: 'select', label: 'Country', options: [{ label: 'China', value: 'cn' }] }],
32+
]);
33+
34+
const baseOpts = {
35+
objectName: OBJECT,
36+
metaMap,
37+
writeMode: 'insert' as const,
38+
matchFields: [] as string[],
39+
runAutomations: false,
40+
trimWhitespace: true,
41+
createMissingOptions: false,
42+
skipBlankMatchKey: false,
43+
};
44+
45+
/** Three bad cells, in this column order, around a good one. */
46+
const BAD_ROW = { opened: 'not a day', name: 'Acme', qty: 'many', country: 'DE' };
47+
48+
function protocol(over: Partial<ImportProtocolLike> = {}): ImportProtocolLike {
49+
return {
50+
findData: vi.fn(async () => []),
51+
createData: vi.fn(async (args: any) => ({ record: { id: 'r1', ...args.data } })),
52+
updateData: vi.fn(),
53+
validateData: vi.fn(async (args: ValidateArgs) => ({
54+
object: OBJECT, mode: args.mode ?? 'insert', valid: true,
55+
results: [{ valid: true, errors: [], warnings: [] }],
56+
posture: { valueShapeStrict: false, mediaValueShapeStrict: false },
57+
})),
58+
...over,
59+
};
60+
}
61+
62+
function expectEveryCellError(row: any) {
63+
expect(row, JSON.stringify(row)).toMatchObject({ row: 1, ok: false, action: 'failed' });
64+
expect(row.errors.map((e: any) => [e.field, e.code])).toEqual([
65+
['opened', 'invalid_date'],
66+
['qty', 'invalid_number'],
67+
['country', 'invalid_option'],
68+
]);
69+
// Element 0 is the first-error keys, and every element is `{ field, code, message }`.
70+
expect({ field: row.field, code: row.code, error: row.error })
71+
.toEqual({ field: row.errors[0].field, code: row.errors[0].code, error: row.errors[0].message });
72+
for (const e of row.errors) expect(Object.keys(e).sort()).toEqual(['code', 'field', 'message']);
73+
}
74+
75+
describe('[#25582] a row with several bad cells reports every cell error', () => {
76+
it('the dry run: every cell error, in column order, element 0 = the first-error keys', async () => {
77+
const p = protocol();
78+
const s = await runImport({ ...baseOpts, p, dryRun: true, rows: [BAD_ROW, { name: 'Good' }] });
79+
expect(s).toMatchObject({ ok: 1, errors: 1 });
80+
expectEveryCellError(s.results[0]);
81+
// The good row has no list.
82+
expect(s.results[1]).not.toHaveProperty('errors');
83+
});
84+
85+
it('the commit: the same report, and the row is never written', async () => {
86+
const p = protocol();
87+
const s = await runImport({ ...baseOpts, p, dryRun: false, rows: [BAD_ROW] });
88+
expect(s).toMatchObject({ ok: 0, errors: 1, created: 0 });
89+
expectEveryCellError(s.results[0]);
90+
expect(p.createData).not.toHaveBeenCalled();
91+
});
92+
93+
it('one bad cell: a one-element list naming it', async () => {
94+
const s = await runImport({ ...baseOpts, p: protocol(), dryRun: true, rows: [{ name: 'Acme', qty: 'many' }] });
95+
expect(s.results[0]).toMatchObject({ field: 'qty', code: 'invalid_number' });
96+
expect(s.results[0].errors).toEqual([{ field: 'qty', code: 'invalid_number', message: s.results[0].error }]);
97+
});
98+
});
99+
100+
describe('[#25582] a row that failed for another reason carries no `errors`', () => {
101+
it('a validate-verdict refusal (dry run)', async () => {
102+
const p = protocol({
103+
validateData: vi.fn(async (args: ValidateArgs) => ({
104+
object: OBJECT, mode: args.mode ?? 'insert', valid: false,
105+
results: [{
106+
valid: false,
107+
errors: [
108+
{ field: 'name', code: 'required', message: 'Name is required' },
109+
{ field: 'qty', code: 'min', message: 'Qty must be at least 1' },
110+
],
111+
warnings: [],
112+
}],
113+
posture: { valueShapeStrict: false, mediaValueShapeStrict: false },
114+
})),
115+
});
116+
const s = await runImport({ ...baseOpts, p, dryRun: true, rows: [{ qty: '0' }] });
117+
expect(s.results[0]).toMatchObject({ row: 1, ok: false, action: 'failed', field: 'name', code: 'required' });
118+
expect(s.results[0]).not.toHaveProperty('errors');
119+
});
120+
121+
it('a refused write (commit, through the create door\'s verdict)', async () => {
122+
const refusal = Object.assign(new Error('Name is required'), {
123+
statusCode: 400, code: 'VALIDATION_FAILED',
124+
fields: [{ field: 'name', code: 'required', message: 'Name is required' }],
125+
});
126+
const p = protocol({ createData: vi.fn(async () => { throw refusal; }) });
127+
const s = await runImport({ ...baseOpts, p, dryRun: false, rows: [{ qty: '3' }] });
128+
expect(s.results[0]).toMatchObject({ row: 1, ok: false, action: 'failed' });
129+
expect(s.results[0]).not.toHaveProperty('errors');
130+
});
131+
});

‎packages/core/src/utils/import-runner.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,16 @@ export interface ImportRowResult {
8484
field?: string;
8585
error?: string;
8686
code?: string;
87+
/**
88+
* [#25582] Every cell error of a failed row, one element per failed cell in
89+
* column order, each `{ field, code, message }` (`ImportRowResultSchema.errors`),
90+
* so one import names every bad cell instead of one per re-import. Filled
91+
* only from `coerceRow`'s cell errors, where `field` / `code` / `error` above
92+
* are element 0. ⛔ Never on a validate-verdict row or a refused write: their
93+
* `error` is not element 0's message, and the spec names cell errors as the
94+
* list's only source.
95+
*/
96+
errors?: ValidateDataIssue[];
8797
/**
8898
* Findings this deployment ADMITS rather than rejects — today, ADR-0104
8999
* value shapes under a warn-first posture (#4633). The row is `ok`: the
@@ -1220,7 +1230,10 @@ export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
12201230
if (errors.length > 0) {
12211231
const first = errors[0];
12221232
errCount++;
1223-
results[i] = { row: rowNo, ok: false, action: 'failed', field: first.field, code: first.code, error: first.message };
1233+
results[i] = {
1234+
row: rowNo, ok: false, action: 'failed', field: first.field, code: first.code, error: first.message,
1235+
errors: errors.map(({ field, code, message }) => ({ field, code, message })),
1236+
};
12241237
} else if (shared) {
12251238
// [#26133] Another row of this import carries the same key.
12261239
errCount++;
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25582, maintainer ruling 6377844879 letter A, proposal 4] An import row
5+
* with several bad cells reports every one of them, so one import tells the
6+
* operator about every bad cell instead of one per re-import. Through the real
7+
* routes over a real {@link ObjectQL} + {@link ObjectStackProtocolImplementation}
8+
* + SQLite `:memory:`, with the platform's own `sys_import_job`: the
9+
* synchronous `POST /data/:object/import` and the async job's
10+
* `GET /data/import/jobs/:jobId/results`, each on the dry run and the commit.
11+
*
12+
* The row carries `errors` (`ImportRowResultSchema.errors`): one element per
13+
* failed cell, in column order, each `{ field, code, message }`. The
14+
* first-error keys `field` / `code` / `error` stay and name element 0. Measured
15+
* at the base (`45de8c6cba`): the same row reported its first cell only, with
16+
* no `errors` key, on all four paths.
17+
*
18+
* A row that failed for another reason omits the key: the dry run's validate
19+
* verdict (`required` here) and the commit's refused write (the create door's
20+
* `required` verdict) both name a field, but not as a cell error.
21+
*
22+
* The runner's own cases are `packages/core/src/utils/import-runner-cell-errors.test.ts`.
23+
*/
24+
25+
import { describe, it, expect, afterEach } from 'vitest';
26+
import { ObjectQL } from '@objectstack/objectql';
27+
import { SqlDriver } from '@objectstack/driver-sql';
28+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
29+
import { SysImportJob } from '@objectstack/platform-objects/audit';
30+
import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core';
31+
import { RestServer } from './rest-server';
32+
import { installDefaultOrganization } from './default-organization.testkit.js';
33+
34+
const OBJECT = 'supplier_cells_25582';
35+
36+
const SUPPLIER = {
37+
name: OBJECT, label: 'Supplier cells 25582', systemFields: false,
38+
fields: {
39+
id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' },
40+
name: { name: 'name', type: 'text' as const, label: 'Name', required: true },
41+
opened: { name: 'opened', type: 'date' as const, label: 'Opened' },
42+
qty: { name: 'qty', type: 'number' as const, label: 'Qty' },
43+
country: {
44+
name: 'country', type: 'select' as const, label: 'Country',
45+
options: [{ label: '中国', value: 'cn' }, { label: '美国', value: 'us' }],
46+
},
47+
},
48+
};
49+
50+
function createMockServer() {
51+
const noop = () => {};
52+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
53+
}
54+
55+
function makeRes() {
56+
const res: any = {
57+
write: () => true, end: () => {},
58+
header: () => res,
59+
status: (code: number) => { res._status = code; return res; },
60+
json: (body: any) => { res._json = body; return res; },
61+
};
62+
return res;
63+
}
64+
65+
const liveEngines: ObjectQL[] = [];
66+
afterEach(async () => {
67+
while (liveEngines.length) {
68+
try { await liveEngines.pop()?.destroy(); } catch { /* noop */ }
69+
}
70+
});
71+
72+
async function boot() {
73+
const engine = new ObjectQL();
74+
liveEngines.push(engine);
75+
engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true);
76+
await engine.init();
77+
engine.registry.registerObject(SUPPLIER as any);
78+
engine.registry.registerObject(SysImportJob as any);
79+
await engine.syncSchemas();
80+
// A real `single` install's Default Organization: the job row's system
81+
// writes derive it (ADR-0131 D9).
82+
await installDefaultOrganization(engine);
83+
// The route reads the object's field metadata through the protocol, which
84+
// reads the stored-metadata family: without it every cell passes through
85+
// uncoerced and the engine's validator answers instead.
86+
for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) {
87+
if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any);
88+
}
89+
90+
const protocol = new ObjectStackProtocolImplementation(engine as any);
91+
const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any);
92+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
93+
rest.registerRoutes();
94+
const call = async (method: string, path: string, body: unknown, params: Record<string, string> = { object: OBJECT }): Promise<{ status: number; body: any }> => {
95+
const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path);
96+
expect(route, `${method} ${path}`).toBeDefined();
97+
const res = makeRes();
98+
await route!.handler({ params, body, query: {}, headers: {} } as any, res);
99+
return { status: res._status ?? 200, body: res._json };
100+
};
101+
102+
/** The synchronous route's per-row report. */
103+
const syncRows = async (rows: Array<Record<string, unknown>>, dryRun: boolean) => {
104+
const r = await call('POST', '/api/v1/data/:object/import', { format: 'json', rows, dryRun });
105+
expect(r.status, JSON.stringify(r.body)).toBe(200);
106+
return r.body.results as any[];
107+
};
108+
109+
/** The async job's per-row report, read off the results route once the job is terminal. */
110+
const jobRows = async (rows: Array<Record<string, unknown>>, dryRun: boolean) => {
111+
const created = await call('POST', '/api/v1/data/:object/import/jobs', { format: 'json', rows, dryRun });
112+
expect(created.status, JSON.stringify(created.body)).toBe(201);
113+
const jobId = String(created.body.jobId);
114+
let progress: { status: number; body: any } | undefined;
115+
for (let i = 0; i < 200; i++) {
116+
progress = await call('GET', '/api/v1/data/import/jobs/:jobId', undefined, { jobId });
117+
if (['succeeded', 'failed', 'cancelled'].includes(progress.body?.status)) break;
118+
await new Promise((r) => setTimeout(r, 5));
119+
}
120+
expect(progress?.body?.status, JSON.stringify(progress?.body)).toBe('succeeded');
121+
const results = await call('GET', '/api/v1/data/import/jobs/:jobId/results', undefined, { jobId });
122+
return [...(results.body.results as any[])].sort((a, b) => a.row - b.row);
123+
};
124+
125+
return { engine, syncRows, jobRows };
126+
}
127+
128+
/** Three bad cells around good ones, in this column order. */
129+
const BAD_ROW = { id: 'b1', name: 'Acme', opened: '2011-13-45', qty: 'many', country: 'DE' };
130+
const GOOD_ROW = { id: 'g1', name: 'Good', opened: '2011-07-01', qty: '3', country: 'cn' };
131+
132+
function expectEveryCellError(row: any) {
133+
expect(row, JSON.stringify(row)).toMatchObject({ row: 1, ok: false, action: 'failed' });
134+
expect(row.errors?.map((e: any) => [e.field, e.code]), JSON.stringify(row)).toEqual([
135+
['opened', 'invalid_date'],
136+
['qty', 'invalid_number'],
137+
['country', 'invalid_option'],
138+
]);
139+
for (const e of row.errors) {
140+
expect(Object.keys(e).sort()).toEqual(['code', 'field', 'message']);
141+
expect(typeof e.message).toBe('string');
142+
}
143+
// The first-error keys are element 0, as they were before the list.
144+
expect({ field: row.field, code: row.code, error: row.error })
145+
.toEqual({ field: row.errors[0].field, code: row.errors[0].code, error: row.errors[0].message });
146+
}
147+
148+
describe('[#25582] every cell error of a row reaches the report', () => {
149+
for (const dryRun of [true, false]) {
150+
const leg = dryRun ? 'dry run' : 'commit';
151+
152+
it(`sync route, ${leg}`, async () => {
153+
const b = await boot();
154+
const rows = await b.syncRows([BAD_ROW, GOOD_ROW], dryRun);
155+
expectEveryCellError(rows[0]);
156+
expect(rows[1]).toMatchObject({ row: 2, ok: true, action: 'created' });
157+
expect(rows[1]).not.toHaveProperty('errors');
158+
expect(await b.engine.findOne(OBJECT, { where: { id: 'b1' } })).toBeNull();
159+
});
160+
161+
it(`async job, ${leg}`, async () => {
162+
const b = await boot();
163+
const rows = await b.jobRows([BAD_ROW, GOOD_ROW], dryRun);
164+
expectEveryCellError(rows[0]);
165+
expect(rows[1]).toMatchObject({ row: 2, ok: true, action: 'created' });
166+
expect(rows[1]).not.toHaveProperty('errors');
167+
});
168+
}
169+
});
170+
171+
describe('[#25582] a row that failed for another reason carries no `errors`', () => {
172+
/** Every cell coerces; `name` is required and missing. */
173+
const NO_NAME = { id: 'n1', qty: '3' };
174+
175+
it('the dry run\'s validate verdict (sync and async)', async () => {
176+
const b = await boot();
177+
for (const rows of [await b.syncRows([NO_NAME], true), await b.jobRows([NO_NAME], true)]) {
178+
expect(rows[0], JSON.stringify(rows[0])).toMatchObject({ row: 1, ok: false, action: 'failed', field: 'name', code: 'required' });
179+
expect(rows[0]).not.toHaveProperty('errors');
180+
}
181+
});
182+
183+
it('the commit\'s refused write (sync and async)', async () => {
184+
const b = await boot();
185+
for (const rows of [await b.syncRows([NO_NAME], false), await b.jobRows([NO_NAME], false)]) {
186+
expect(rows[0], JSON.stringify(rows[0])).toMatchObject({ row: 1, ok: false, action: 'failed', field: 'name', code: 'required' });
187+
expect(rows[0]).not.toHaveProperty('errors');
188+
}
189+
expect(await b.engine.findOne(OBJECT, { where: { id: 'n1' } })).toBeNull();
190+
});
191+
});

0 commit comments

Comments
 (0)