diff --git a/.changeset/26425-import-row-every-cell-error.md b/.changeset/26425-import-row-every-cell-error.md new file mode 100644 index 00000000000..dcc14c107c3 --- /dev/null +++ b/.changeset/26425-import-row-every-cell-error.md @@ -0,0 +1,10 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): an import row result can report every cell error of a failed row in `errors` + +Clause-②: yes (widening) +Metadata-protocol: added + +`ImportRowResultSchema` (and so `ImportResponseSchema.results` and the async job's `results` sample) declares one optional member, `errors`: an array with one element per failed cell of a failed row, in the validate verdict's issue shape (`ValidateDataIssueSchema`: `field`, `code`, `message`), the same element the row's `warnings` already uses. The list holds the import's cell-coercion errors, and a cell error never carries the advisory-only `rule` or `severity`. It is present only on a failed row that has at least one cell error. The first-error keys `field`, `code` and `error` stay and keep naming element 0, so every current reader sees the same answer. Before, the schema had no such key, so `ImportRowResultSchema.parse` would strip one. This is the contract half: the import runner fills `errors` in a later change, and until then no server sends it. diff --git a/content/docs/references/api/export.mdx b/content/docs/references/api/export.mdx index 76916827fd5..ff0373a61e7 100644 --- a/content/docs/references/api/export.mdx +++ b/content/docs/references/api/export.mdx @@ -229,9 +229,10 @@ const result = CreateImportJobRequestSchema.parse(data); | **ok** | `boolean` | ✅ | Whether the row succeeded | | **action** | `Enum<'created' \| 'updated' \| 'skipped' \| 'failed'>` | ✅ | What happened to the row | | **id** | `string` | optional | Record id (created/updated rows) | -| **field** | `string` | optional | Field that caused a coercion/validation error | -| **code** | `string` | optional | Error code (failed rows) | -| **error** | `string` | optional | Human-readable error message (failed rows) | +| **field** | `string` | optional | Field that caused a coercion/validation error (element 0 of `errors` when present) | +| **code** | `string` | optional | Error code (failed rows; element 0 of `errors` when present) | +| **error** | `string` | optional | Human-readable error message (failed rows; element 0 of `errors` when present) | +| **errors** | `{ field: string; code: string; message: string; rule?: string; … }[]` | optional | Every cell error of a failed row, one element per failed cell, in the validate verdict's issue shape (`field`, `code`, `message`). The list holds the import's cell-coercion errors, and a cell error never carries the advisory-only `rule` or `severity`. Present only on a failed row that has at least one cell error, so a row that failed for another reason (a duplicate or ambiguous match key, say) omits it. The first-error keys `field`, `code` and `error` stay and name element 0, so a reader of those keys sees the same answer as before. A server that does not produce this list omits the key, and the row then reports its first error only. | | **warnings** | `{ field: string; code: string; message: string; rule?: string; … }[]` | optional | Findings this deployment ADMITS rather than rejects, copied verbatim from the validate verdict's row `warnings`: ADR-0104 value shapes under a warn-first posture, then the hits of advisory validation rules (`severity: 'warning'` or `'info'`, each naming its `rule` and `severity`). Set only on a dry-run row the verdict accepted: the row is `ok` because the write would store it. A committed row does not carry it: the import does not copy a write answer's `warnings` onto the row. | | **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: caller-supplied fields the engine LEGALLY strips from THIS row, one event per reason, in the engine's own `droppedFields` shape and reason vocabulary. A committed row reports the strips its write made. A dry-run row reports the strips its preview runs, which for a row the import would update excludes `readonlyWhen` and primary-key strips, so that row can name fewer fields than its commit. The row still succeeds: `ok` and `action` are unchanged. Present only when at least one field was dropped. A server or write path that does not produce this report omits the key too, so an absent key alone does not prove nothing was dropped. In an async job's `results`, an ok row's drops reach the reader only if the row falls inside that capped, failures-first sample. | @@ -358,9 +359,10 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | **ok** | `boolean` | ✅ | Whether the row succeeded | | **action** | `Enum<'created' \| 'updated' \| 'skipped' \| 'failed'>` | ✅ | What happened to the row | | **id** | `string` | optional | Record id (created/updated rows) | -| **field** | `string` | optional | Field that caused a coercion/validation error | -| **code** | `string` | optional | Error code (failed rows) | -| **error** | `string` | optional | Human-readable error message (failed rows) | +| **field** | `string` | optional | Field that caused a coercion/validation error (element 0 of `errors` when present) | +| **code** | `string` | optional | Error code (failed rows; element 0 of `errors` when present) | +| **error** | `string` | optional | Human-readable error message (failed rows; element 0 of `errors` when present) | +| **errors** | `{ field: string; code: string; message: string; rule?: string; … }[]` | optional | Every cell error of a failed row, one element per failed cell, in the validate verdict's issue shape (`field`, `code`, `message`). The list holds the import's cell-coercion errors, and a cell error never carries the advisory-only `rule` or `severity`. Present only on a failed row that has at least one cell error, so a row that failed for another reason (a duplicate or ambiguous match key, say) omits it. The first-error keys `field`, `code` and `error` stay and name element 0, so a reader of those keys sees the same answer as before. A server that does not produce this list omits the key, and the row then reports its first error only. | | **warnings** | `{ field: string; code: string; message: string; rule?: string; … }[]` | optional | Findings this deployment ADMITS rather than rejects, copied verbatim from the validate verdict's row `warnings`: ADR-0104 value shapes under a warn-first posture, then the hits of advisory validation rules (`severity: 'warning'` or `'info'`, each naming its `rule` and `severity`). Set only on a dry-run row the verdict accepted: the row is `ok` because the write would store it. A committed row does not carry it: the import does not copy a write answer's `warnings` onto the row. | | **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: caller-supplied fields the engine LEGALLY strips from THIS row, one event per reason, in the engine's own `droppedFields` shape and reason vocabulary. A committed row reports the strips its write made. A dry-run row reports the strips its preview runs, which for a row the import would update excludes `readonlyWhen` and primary-key strips, so that row can name fewer fields than its commit. The row still succeeds: `ok` and `action` are unchanged. Present only when at least one field was dropped. A server or write path that does not produce this report omits the key too, so an absent key alone does not prove nothing was dropped. In an async job's `results`, an ok row's drops reach the reader only if the row falls inside that capped, failures-first sample. | @@ -377,12 +379,23 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | **ok** | `boolean` | ✅ | Whether the row succeeded | | **action** | `Enum<'created' \| 'updated' \| 'skipped' \| 'failed'>` | ✅ | What happened to the row | | **id** | `string` | optional | Record id (created/updated rows) | -| **field** | `string` | optional | Field that caused a coercion/validation error | -| **code** | `string` | optional | Error code (failed rows) | -| **error** | `string` | optional | Human-readable error message (failed rows) | +| **field** | `string` | optional | Field that caused a coercion/validation error (element 0 of `errors` when present) | +| **code** | `string` | optional | Error code (failed rows; element 0 of `errors` when present) | +| **error** | `string` | optional | Human-readable error message (failed rows; element 0 of `errors` when present) | +| **errors** | `{ field: string; code: string; message: string; rule?: string; … }[]` | optional | Every cell error of a failed row, one element per failed cell, in the validate verdict's issue shape (`field`, `code`, `message`). The list holds the import's cell-coercion errors, and a cell error never carries the advisory-only `rule` or `severity`. Present only on a failed row that has at least one cell error, so a row that failed for another reason (a duplicate or ambiguous match key, say) omits it. The first-error keys `field`, `code` and `error` stay and name element 0, so a reader of those keys sees the same answer as before. A server that does not produce this list omits the key, and the row then reports its first error only. | | **warnings** | `{ field: string; code: string; message: string; rule?: string; … }[]` | optional | Findings this deployment ADMITS rather than rejects, copied verbatim from the validate verdict's row `warnings`: ADR-0104 value shapes under a warn-first posture, then the hits of advisory validation rules (`severity: 'warning'` or `'info'`, each naming its `rule` and `severity`). Set only on a dry-run row the verdict accepted: the row is `ok` because the write would store it. A committed row does not carry it: the import does not copy a write answer's `warnings` onto the row. | | **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: caller-supplied fields the engine LEGALLY strips from THIS row, one event per reason, in the engine's own `droppedFields` shape and reason vocabulary. A committed row reports the strips its write made. A dry-run row reports the strips its preview runs, which for a row the import would update excludes `readonlyWhen` and primary-key strips, so that row can name fewer fields than its commit. The row still succeeds: `ok` and `action` are unchanged. Present only when at least one field was dropped. A server or write path that does not produce this report omits the key too, so an absent key alone does not prove nothing was dropped. In an async job's `results`, an ok row's drops reach the reader only if the row falls inside that capped, failures-first sample. | +### Nested Shape: `ImportRowResult.errors[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | The field the finding is about (`_record` for an object-level rule). | +| **code** | `string` | ✅ | Machine-readable finding code, e.g. `required`, `invalid_type`, `rule_violation`. | +| **message** | `string` | ✅ | Human-readable message — a validation rule's author-written text where one exists. | +| **rule** | `string` | optional | The `name` of the ADVISORY validation rule this finding reports. Present only on an advisory rule hit. | +| **severity** | `Enum<'warning' \| 'info'>` | optional | The advisory rule's declared severity, `warning` or `info`. Present only on an advisory rule hit. | + ### Nested Shape: `ImportRowResult.warnings[number]` | Property | Type | Required | Description | diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 2b7d2eda8ed..b518780b24e 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -949,6 +949,7 @@ "api/ImportRowResult:code", "api/ImportRowResult:droppedFields", "api/ImportRowResult:error", + "api/ImportRowResult:errors", "api/ImportRowResult:field", "api/ImportRowResult:id", "api/ImportRowResult:ok", diff --git a/packages/spec/src/api/export.test.ts b/packages/spec/src/api/export.test.ts index 2afddab094b..bbab7db8a1a 100644 --- a/packages/spec/src/api/export.test.ts +++ b/packages/spec/src/api/export.test.ts @@ -11,9 +11,11 @@ import { ImportRequestSchema, CreateImportJobRequestSchema, ImportRowResultSchema, + ImportResponseSchema, ImportJobResultsSchema, isMatchKeySets, } from './export.zod'; +import { ValidateDataIssueSchema } from './protocol.zod'; import type { ValidateDataIssue } from './protocol.zod'; import { DroppedFieldsEventSchema } from '../data/data-engine.zod'; import type { DroppedFieldsEvent } from '../data/data-engine.zod'; @@ -478,6 +480,81 @@ describe('ImportRowResultSchema — droppedFields and warnings', () => { }); }); +// ========================================== +// Import Row Result — every cell error of a failed row +// ========================================== + +/** + * A failed row reports every bad cell in `errors`, and the first-error keys + * `field` / `code` / `error` keep naming element 0. As above, the schema STRIPS + * an undeclared key rather than refusing it, so the pin asserts `errors` + * survives a parse, never bare `success`. + */ +describe('ImportRowResultSchema — errors (every cell error of a failed row)', () => { + const cellErrors = [ + { field: 'close_date', code: 'invalid_date', message: 'Close Date: "31/02/2026" is not a valid date' }, + { field: 'stage', code: 'invalid_option', message: 'Stage: "Wonn" is not one of the options' }, + { field: 'amount', code: 'invalid_number', message: 'Amount: "1,2,3" is not a number' }, + ]; + const first = cellErrors[0]!; + const failedRow = { + row: 4, ok: false, action: 'failed' as const, + field: first.field, code: first.code, error: first.message, + errors: cellErrors, + }; + + it('keeps every cell error through a parse, in order', () => { + const parsed = ImportRowResultSchema.parse(failedRow); + expect(parsed.errors).toEqual(cellErrors); + }); + + it('the first-error keys stay, and agree with element 0', () => { + const parsed = ImportRowResultSchema.parse(failedRow); + expect(parsed.field).toBe(parsed.errors?.[0]?.field); + expect(parsed.code).toBe(parsed.errors?.[0]?.code); + expect(parsed.error).toBe(parsed.errors?.[0]?.message); + }); + + it('is optional: a failed row with only the first-error keys parses unchanged', () => { + const { errors: _omitted, ...firstOnly } = failedRow; + const parsed = ImportRowResultSchema.parse(firstOnly); + expect(parsed).toEqual(firstOnly); + expect('errors' in parsed).toBe(false); + }); + + it('the element is ValidateDataIssueSchema itself — no third issue shape', () => { + const element = ImportRowResultSchema.shape.errors.unwrap().element; + expect(element).toBe(ValidateDataIssueSchema); + expect(element).toBe(ImportRowResultSchema.shape.warnings.unwrap().element); + }); + + it('refuses an element without a message', () => { + const partial = ImportRowResultSchema.safeParse({ ...failedRow, errors: [{ field: 'x', code: 'y' }] }); + expect(partial.success).toBe(false); + const issue = partial.success ? undefined : partial.error.issues[0]; + expect(issue?.path).toEqual(['errors', 0, 'message']); + }); + + it('the response and the async job sample carry it', () => { + const response = ImportResponseSchema.parse({ + object: 'opportunity', dryRun: true, writeMode: 'insert', + total: 1, ok: 0, errors: 1, created: 0, updated: 0, skipped: 0, results: [failedRow], + }); + expect(response.results[0]?.errors).toEqual(cellErrors); + const job = ImportJobResultsSchema.parse({ + jobId: 'imp_1', object: 'opportunity', status: 'succeeded', dryRun: false, writeMode: 'insert', + total: 1, processed: 1, created: 0, updated: 0, skipped: 0, errors: 1, percentComplete: 100, + undoable: false, createdAt: '2026-10-27T00:00:00.000Z', + results: [failedRow], resultsTruncated: false, + }); + expect(job.results[0]?.errors).toEqual(cellErrors); + }); + + it('the typed reader sees the verdict\'s issue type', () => { + expectTypeOf[number]>().toEqualTypeOf(); + }); +}); + // ========================================== // applyOn (create-only import columns) and ordered match key sets // ========================================== diff --git a/packages/spec/src/api/export.zod.ts b/packages/spec/src/api/export.zod.ts index 7345eda9e8c..d8c27692cfb 100644 --- a/packages/spec/src/api/export.zod.ts +++ b/packages/spec/src/api/export.zod.ts @@ -397,6 +397,15 @@ export type ImportRequestParsed = z.infer; * so that row's dry run can name fewer fields than its commit. The element * IS {@link DroppedFieldsEventSchema}, so the `reason` vocabulary is the * engine's — ⛔ never a second enum here. + * + * One optional report key rides on a `failed` row: `errors`, every cell error + * of that row, so one import tells the user about every bad cell instead of + * one per re-import. The first-error keys `field`, `code` and `error` stay and + * keep naming element 0, so a reader of those keys is unchanged. The element + * IS {@link ValidateDataIssueSchema}, the shape the row's `warnings` and the + * validate verdict's `errors` already use, rather than a third issue shape: a + * cell error is `{ field, code, message }`, and it never carries the + * advisory-only `rule` / `severity`. */ export const ImportRowResultSchema = lazySchema(() => z.object({ row: z.number().int().describe('1-based row number in the source data'), @@ -404,9 +413,19 @@ export const ImportRowResultSchema = lazySchema(() => z.object({ action: z.enum(['created', 'updated', 'skipped', 'failed']) .describe('What happened to the row'), id: z.string().optional().describe('Record id (created/updated rows)'), - field: z.string().optional().describe('Field that caused a coercion/validation error'), - code: z.string().optional().describe('Error code (failed rows)'), - error: z.string().optional().describe('Human-readable error message (failed rows)'), + field: z.string().optional().describe('Field that caused a coercion/validation error (element 0 of `errors` when present)'), + code: z.string().optional().describe('Error code (failed rows; element 0 of `errors` when present)'), + error: z.string().optional().describe('Human-readable error message (failed rows; element 0 of `errors` when present)'), + errors: z.array(ValidateDataIssueSchema).optional().describe( + 'Every cell error of a failed row, one element per failed cell, in the validate verdict\'s issue shape ' + + '(`field`, `code`, `message`). The list holds the import\'s cell-coercion errors, and a cell error never ' + + 'carries the advisory-only `rule` or `severity`. ' + + 'Present only on a failed row that has at least one cell error, so a row ' + + 'that failed for another reason (a duplicate or ambiguous match key, say) omits it. The first-error keys ' + + '`field`, `code` and `error` stay and name element 0, so a reader of those keys sees the same answer ' + + 'as before. A server that does not produce this list omits the key, and the row then reports its first ' + + 'error only.', + ), warnings: z.array(ValidateDataIssueSchema).optional().describe( 'Findings this deployment ADMITS rather than rejects, copied verbatim from the validate verdict\'s row ' + '`warnings`: ADR-0104 value shapes under a warn-first posture, then the hits of advisory validation rules '