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
26 changes: 26 additions & 0 deletions .changeset/batch-row-error-codes-registered.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@objectstack/spec": minor
---

feat(spec): register `ROLLED_BACK` / `NOT_ATTEMPTED` batch-row error codes; record the batch-row shape migration (#4793)

Support for the `@objectstack/metadata-protocol` v17 batch-row migration
(#4793 — see its major changeset for the wire change itself):

- `ERROR_CODE_LEDGER` registers two codes under `@objectstack/metadata-protocol`:
`ROLLED_BACK` (atomic data-batch row was written, then undone by the batch
rollback) and `NOT_ATTEMPTED` (row never ran — an earlier row's failure
aborted the batch). They are the structured, `ApiError.code`-level form of
the message-string prefixes #4620 introduced; `ApiErrorSchema.code` now
accepts them and clients branch on the code instead of regexing messages.
- The ADR-0087 migration registry gains the protocol-17 semantic entry
`batch-row-result-schema-shape` (a RESPONSE surface — nothing stored to
rewrite, so it is a documented TODO for readers of the legacy `row.error` /
`row.record` keys), and `docs/protocol-upgrade-guide.md` is regenerated
with it.
- `BatchOptionsSchema.atomic` / `BatchOperationResultSchema.errors` describe
strings now document the code-based rollback marking (reference docs
regenerated).

No schema *shape* changes: `BatchOperationResultSchema` already declared
`errors` / `data` / `index` — the runtime caught up to it.
48 changes: 48 additions & 0 deletions .changeset/batch-row-result-schema-shape.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@objectstack/metadata-protocol": major
---

fix(metadata-protocol)!: batch per-row results now deliver the declared `BatchOperationResultSchema` shape (#4793)

**Breaking wire change** on the per-row `results` entries of the three
bulk-write endpoints — `POST /data/:object/batch`, `/updateMany`,
`/deleteMany`. The rows had drifted from the schema that declares them:
`BatchOperationResultSchema`, the client SDK's exported `BatchOperationResult`
type and the reference docs all said `errors: ApiError[]` / `data` / `index`,
while the wire carried `error: string` / `record` and never sent `index`. A
TypeScript consumer written against the published type compiled, validated,
and read `undefined` at runtime. The wire now delivers exactly what is
declared (a conformance pin parses every emitted row against the schema, so
the two cannot silently fork again).

**FROM → TO, per row:**

| Before (legacy wire) | After (declared schema) | Your fix |
| --- | --- | --- |
| `row.error` (string) | `row.errors` (`ApiError[]`) | read `row.errors?.[0]?.message`; branch on `row.errors?.[0]?.code` |
| `row.record` | `row.data` | rename the read |
| — (never sent) | `row.index` (number) | new — the row's position in the request array; use it to correlate failure rows that carry no `id` |
| `row.droppedFields` | `row.droppedFields` | unchanged |

**Rollback marking is structured now.** The `ROLLED_BACK:` /
`NOT_ATTEMPTED:` message-string prefixes that #4620 introduced (see the
`many-data-atomic-real-or-refused` changeset — its description of those
markers is superseded by this entry) are promoted to first-class
`ApiError.code` values, registered in the spec's ERROR_CODE_LEDGER:

- `errors[0].code === 'ROLLED_BACK'` — the row was written, then undone by the
atomic batch rollback; `message` carries the causal row's index and error.
- `errors[0].code === 'NOT_ATTEMPTED'` — the row never ran; an earlier row's
failure aborted the batch.
- the causal row keeps its own error code (e.g. `RECORD_NOT_FOUND`,
`VALIDATION_FAILED`; an unclassified engine throw maps to `INTERNAL_ERROR`,
with `httpStatus` mirrored when the error carried one).

Branch on the code — do **not** regex message prefixes; the prefixes are gone.

**Who is affected:** only readers of the *legacy* keys — which were never in
the schema or the SDK types, so they were reachable only via `as any` or bare
JS. Code written against `BatchOperationResult` (the published contract) needed
this change to start working and needs no migration. There is no
dual-emission or compatibility fallback: this is a hard cut inside the v17
major window, and the old keys simply no longer exist on the wire.
10 changes: 7 additions & 3 deletions content/docs/api/client-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -508,9 +508,13 @@ The `find` method accepts an options object with **canonical** (recommended) fie
| `returnRecords` | `boolean` | `false` | Include full records in response |
| `continueOnError` | `boolean` | `false` | Continue after errors (when atomic is false) |

A rolled-back atomic batch reports `succeeded: 0`, with each row carrying
`ROLLED_BACK:`, the causal error, or `NOT_ATTEMPTED:` — no row is reported as a
success, because none of them survived.
A rolled-back atomic batch reports `succeeded: 0`, with each row's
`errors[0].code` set to `ROLLED_BACK` (written, then undone), the causal row's
own error code, or `NOT_ATTEMPTED` (never reached) — no row is reported as a
success, because none of them survived. Per-row results are the declared
`BatchOperationResult` shape: failures ride `row.errors` (an `ApiError[]` —
message in `errors[0].message`), records ride `row.data` when
`returnRecords: true`, and `row.index` correlates the row to the request array.

---

Expand Down
14 changes: 9 additions & 5 deletions content/docs/api/data-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,9 @@ Execute a batch operation (create / update / upsert / delete) on multiple record
}
```

**Response**: `BatchUpdateResponse` with `succeeded`, `failed`, `total`, and a per-record `results` array. Each entry in `results` has `id`, `success`, an optional `errors` array, and optional `data` (the full record, present when `returnRecords` is `true`).
**Response**: `BatchUpdateResponse` with `succeeded`, `failed`, `total`, and a per-record `results` array. Each entry in `results` has `id`, `success`, `index` (the row's position in the request array), an optional `errors` array (`ApiError[]` — read `errors[0].message`, branch on `errors[0].code`), and optional `data` (the full record, present when `returnRecords` is `true`).

`options.atomic` defaults to `false` (sequential best-effort, stopping at the first failure). Set it to `true` and the whole batch runs inside one transaction: the first failure rolls back every prior write, and the response reports `succeeded: 0` with each row marked `ROLLED_BACK:`, the causal error, or `NOT_ATTEMPTED:`. A deployment whose driver cannot roll back rejects an atomic request with `501 NOT_IMPLEMENTED` instead of running it best-effort — probe `capabilities.transactionalBatch` on `/discovery` first. `atomic` takes precedence over `continueOnError`.
`options.atomic` defaults to `false` (sequential best-effort, stopping at the first failure). Set it to `true` and the whole batch runs inside one transaction: the first failure rolls back every prior write, and the response reports `succeeded: 0` each row's `errors[0].code` says what happened: `ROLLED_BACK` (written, then undone), the causal row's own error code, or `NOT_ATTEMPTED` (never reached). A deployment whose driver cannot roll back rejects an atomic request with `501 NOT_IMPLEMENTED` instead of running it best-effort — probe `capabilities.transactionalBatch` on `/discovery` first. `atomic` takes precedence over `continueOnError`.

### `POST /data/:object/createMany`

Expand Down Expand Up @@ -273,9 +273,13 @@ deleted one at a time by primary key, so each honours `deleteBehavior`
at the first failure; `continueOnError: true` processes the remaining ids and
reports the failures instead.

Note that on this route `atomic` only stops the run — deletes already performed
are **not** rolled back. Unlike `/batch`, `deleteMany` has no wrapping
transaction yet.
`options.atomic: true` is honoured here the same way as on `/batch` (#4620): the
whole id list runs inside one transaction, the first failure rolls back every
prior delete, and the response reports `succeeded: 0` with each row's
`errors[0].code` set to `ROLLED_BACK`, the causal error code, or
`NOT_ATTEMPTED`. A runtime that cannot roll back refuses the request with
`501 NOT_IMPLEMENTED` rather than degrading to best-effort. The same applies to
`/updateMany`.

### Batch size

Expand Down
14 changes: 7 additions & 7 deletions content/docs/api/wire-format.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ Process many records of a **single** operation type in one request. The body car

By default (`options.atomic` omitted or `false`) processing stops at the first failing record, and records written earlier in the same batch are **not** rolled back — there is no wrapping transaction. Add `options.continueOnError: true` to keep going instead and collect a full partial-success report.

Send `options.atomic: true` to run the whole batch inside one database transaction: the first failure rolls back every prior write, and the response reports zero successes with each row marked `ROLLED_BACK:`, the causal error, or `NOT_ATTEMPTED:`. A deployment whose driver cannot roll back **rejects** an atomic request with `501 NOT_IMPLEMENTED` rather than quietly running it best-effort — probe `capabilities.transactionalBatch` on `/discovery` to know in advance. `atomic` takes precedence over `continueOnError`.
Send `options.atomic: true` to run the whole batch inside one database transaction: the first failure rolls back every prior write, and the response reports zero successes each row's `errors[0].code` says what happened: `ROLLED_BACK` (written, then undone), the causal row's own error code, or `NOT_ATTEMPTED` (never reached). A deployment whose driver cannot roll back **rejects** an atomic request with `501 NOT_IMPLEMENTED` rather than quietly running it best-effort — probe `capabilities.transactionalBatch` on `/discovery` to know in advance. `atomic` takes precedence over `continueOnError`.

### Request

Expand All @@ -495,7 +495,7 @@ Send `options.atomic: true` to run the whole batch inside one database transacti

### Response — `200 OK`

The response is the `BatchUpdateResponse` envelope: a top-level `success` flag plus `total` / `succeeded` / `failed` counts and a per-record `results` array. Each successful entry echoes the written `record`; pass `options.returnRecords: false` to get back just `{ id, success }` per result.
The response is the `BatchUpdateResponse` envelope: a top-level `success` flag plus `total` / `succeeded` / `failed` counts and a per-record `results` array — each entry is a `BatchOperationResult` (`id`, `success`, `index`, optional `errors`, optional `data`). `index` is the row's position in the request `records` array. Each successful entry echoes the written record on `data`; pass `options.returnRecords: false` to get back just `{ id, success, index }` per result.

```json
{
Expand All @@ -505,15 +505,15 @@ The response is the `BatchUpdateResponse` envelope: a top-level `success` flag p
"succeeded": 2,
"failed": 0,
"results": [
{ "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "success": true, "record": { "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "status": "done" } },
{ "id": "tsk_01HQ4B8C0E4G6H9K3L5M", "success": true, "record": { "id": "tsk_01HQ4B8C0E4G6H9K3L5M", "status": "done" } }
{ "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "success": true, "index": 0, "data": { "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "status": "done" } },
{ "id": "tsk_01HQ4B8C0E4G6H9K3L5M", "success": true, "index": 1, "data": { "id": "tsk_01HQ4B8C0E4G6H9K3L5M", "status": "done" } }
]
}
```

### Partial Failure Response

When the batch is not atomic and some records fail, the failing entries carry a single `error` message string (not an array). An atomic batch never returns this shape — it either commits everything or reports every row as failed:
When the batch is not atomic and some records fail, each failing entry carries an `errors` array of `ApiError` objects — read the human-readable cause from `errors[0].message` and branch on the machine-readable `errors[0].code`. An atomic batch never returns this shape — it either commits everything or reports every row as failed (with `errors[0].code` `ROLLED_BACK` / the causal code / `NOT_ATTEMPTED`):

```json
{
Expand All @@ -523,8 +523,8 @@ When the batch is not atomic and some records fail, the failing entries carry a
"succeeded": 1,
"failed": 1,
"results": [
{ "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "success": true, "record": { "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "status": "done" } },
{ "id": "tsk_invalid_id", "success": false, "error": "Record tsk_invalid_id not found in task" }
{ "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "success": true, "index": 0, "data": { "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "status": "done" } },
{ "id": "tsk_invalid_id", "success": false, "index": 1, "errors": [{ "code": "RECORD_NOT_FOUND", "message": "Record tsk_invalid_id not found in task", "httpStatus": 404 }] }
]
}
```
Expand Down
Loading
Loading