Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/26425-import-row-every-cell-error.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 22 additions & 9 deletions content/docs/references/api/export.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down Expand Up @@ -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. |

Expand All @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
77 changes: 77 additions & 0 deletions packages/spec/src/api/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<NonNullable<ImportRowResult['errors']>[number]>().toEqualTypeOf<ValidateDataIssue>();
});
});

// ==========================================
// applyOn (create-only import columns) and ordered match key sets
// ==========================================
Expand Down
25 changes: 22 additions & 3 deletions packages/spec/src/api/export.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,16 +397,35 @@ export type ImportRequestParsed = z.infer<typeof ImportRequestSchema>;
* 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'),
ok: z.boolean().describe('Whether the row succeeded'),
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 '
Expand Down
Loading