Skip to content

Commit ac37fc6

Browse files
os-zhuangclaude
andauthored
fix(metadata-protocol)!: batch 逐行结果迁移到 BatchOperationResultSchema 形状 —— 方案 B 硬切 (#4793) (#4841)
* fix(metadata-protocol)!: batch 逐行结果迁移到 BatchOperationResultSchema 形状 (#4793) - protocol.ts 三个 bulk-write 端点(batchData/updateManyData/deleteManyData)的 逐行结果迁到 spec 声明形状:error:string → errors:ApiError[],record → data, 补发 index(请求数组下标);行类型直接别名 BatchOperationResult,tsc 双向钉死 - ROLLED_BACK / NOT_ATTEMPTED 从 message 前缀提升为 ApiError.code 一级结构化信息 (ERROR_CODE_LEDGER 注册),message 保留人类可读致因;删除 ADR-0119 D4 留下的 「刻意分歧」注释(存在理由已消灭) - 新增 conformance 钉子 protocol.batch-row-conformance.test.ts:三端点真实响应行 逐行过 BatchOperationResultSchema.parse + 旧键缺席 + errors/index 在场断言 (sabotage 实证:改回 error/record 时 8/10 红) - spec:ledger 注册两码;migrations/registry step17 增 semantic 条目 batch-row-result-schema-shape;atomic/errors describe 更新;衍生物再生成 - 仓内消费方清扫:#4798 三个测试套件 + delete-many/dropped-fields/record-not-found 测试迁移断言;client.batch-transaction.test.ts:135 双形状对冲清除; docs(data-api/wire-format/client-sdk)示例与文字更新,data-api 中 #4798 遗留的 「deleteMany 无事务」陈述一并修正;ADR-0119 追加 addendum 记录编码演进 - changesets:@objectstack/metadata-protocol major(升级指引 + 与 #4798 changeset 的叠加关系),@objectstack/spec minor Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176qgxgCXTJCUv4YFLtusP9 * chore(spec): 合并 main(#4831/#4657)后整体重生成 ADR-0087 生成物 os-regen 合并驱动对 spec-changes.json / protocol-upgrade-guide.md 不做文本合并, 本次合并零冲突标记地只保留了本分支一侧,静默吞掉 #4657 的条目(实测: origin/main 的 spec-changes.json 含 4 条 activationEvents 条目,合并后为 0)。 按协议 git checkout origin/main -- <生成物> 后从合并后的源整体重新生成: - spec-changes.json:现同时含 #4657 的 activationEvents 4 条与本单 batch-row-result-schema-shape 2 条 - protocol-upgrade-guide.md:同时含 plugin-activation-events-retired 与 batch-row-result-schema-shape - 四张 ratchet(api-surface / api-surface-signatures / authorable-surface / json-schema.manifest)与 origin/main 逐字节相同 —— 本单不增删任何导出 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176qgxgCXTJCUv4YFLtusP9 * chore(spec): 合并 main(#4846)后整体重生成 ADR-0087 生成物 —— 三方并存 os-regen 第三轮实测:#4846 对 app-dead-authoring-keys-removed summary 的散文修正 ('WAS read by objectui')在合并中被静默吞掉(origin/main 2 处命中,合并后 0)。 按协议 checkout origin/main 后从合并源整体重生成;spec-changes.json 现三方并存: #4657 activationEvents 4 条、#4846 散文 2 处、#4793 batch-row 2 条。 四张 ratchet 与 origin/main 逐字节相同(本单导出增删 0)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176qgxgCXTJCUv4YFLtusP9 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c8ea853 commit ac37fc6

31 files changed

Lines changed: 673 additions & 155 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): register `ROLLED_BACK` / `NOT_ATTEMPTED` batch-row error codes; record the batch-row shape migration (#4793)
6+
7+
Support for the `@objectstack/metadata-protocol` v17 batch-row migration
8+
(#4793 — see its major changeset for the wire change itself):
9+
10+
- `ERROR_CODE_LEDGER` registers two codes under `@objectstack/metadata-protocol`:
11+
`ROLLED_BACK` (atomic data-batch row was written, then undone by the batch
12+
rollback) and `NOT_ATTEMPTED` (row never ran — an earlier row's failure
13+
aborted the batch). They are the structured, `ApiError.code`-level form of
14+
the message-string prefixes #4620 introduced; `ApiErrorSchema.code` now
15+
accepts them and clients branch on the code instead of regexing messages.
16+
- The ADR-0087 migration registry gains the protocol-17 semantic entry
17+
`batch-row-result-schema-shape` (a RESPONSE surface — nothing stored to
18+
rewrite, so it is a documented TODO for readers of the legacy `row.error` /
19+
`row.record` keys), and `docs/protocol-upgrade-guide.md` is regenerated
20+
with it.
21+
- `BatchOptionsSchema.atomic` / `BatchOperationResultSchema.errors` describe
22+
strings now document the code-based rollback marking (reference docs
23+
regenerated).
24+
25+
No schema *shape* changes: `BatchOperationResultSchema` already declared
26+
`errors` / `data` / `index` — the runtime caught up to it.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/metadata-protocol": major
3+
---
4+
5+
fix(metadata-protocol)!: batch per-row results now deliver the declared `BatchOperationResultSchema` shape (#4793)
6+
7+
**Breaking wire change** on the per-row `results` entries of the three
8+
bulk-write endpoints — `POST /data/:object/batch`, `/updateMany`,
9+
`/deleteMany`. The rows had drifted from the schema that declares them:
10+
`BatchOperationResultSchema`, the client SDK's exported `BatchOperationResult`
11+
type and the reference docs all said `errors: ApiError[]` / `data` / `index`,
12+
while the wire carried `error: string` / `record` and never sent `index`. A
13+
TypeScript consumer written against the published type compiled, validated,
14+
and read `undefined` at runtime. The wire now delivers exactly what is
15+
declared (a conformance pin parses every emitted row against the schema, so
16+
the two cannot silently fork again).
17+
18+
**FROM → TO, per row:**
19+
20+
| Before (legacy wire) | After (declared schema) | Your fix |
21+
| --- | --- | --- |
22+
| `row.error` (string) | `row.errors` (`ApiError[]`) | read `row.errors?.[0]?.message`; branch on `row.errors?.[0]?.code` |
23+
| `row.record` | `row.data` | rename the read |
24+
| — (never sent) | `row.index` (number) | new — the row's position in the request array; use it to correlate failure rows that carry no `id` |
25+
| `row.droppedFields` | `row.droppedFields` | unchanged |
26+
27+
**Rollback marking is structured now.** The `ROLLED_BACK:` /
28+
`NOT_ATTEMPTED:` message-string prefixes that #4620 introduced (see the
29+
`many-data-atomic-real-or-refused` changeset — its description of those
30+
markers is superseded by this entry) are promoted to first-class
31+
`ApiError.code` values, registered in the spec's ERROR_CODE_LEDGER:
32+
33+
- `errors[0].code === 'ROLLED_BACK'` — the row was written, then undone by the
34+
atomic batch rollback; `message` carries the causal row's index and error.
35+
- `errors[0].code === 'NOT_ATTEMPTED'` — the row never ran; an earlier row's
36+
failure aborted the batch.
37+
- the causal row keeps its own error code (e.g. `RECORD_NOT_FOUND`,
38+
`VALIDATION_FAILED`; an unclassified engine throw maps to `INTERNAL_ERROR`,
39+
with `httpStatus` mirrored when the error carried one).
40+
41+
Branch on the code — do **not** regex message prefixes; the prefixes are gone.
42+
43+
**Who is affected:** only readers of the *legacy* keys — which were never in
44+
the schema or the SDK types, so they were reachable only via `as any` or bare
45+
JS. Code written against `BatchOperationResult` (the published contract) needed
46+
this change to start working and needs no migration. There is no
47+
dual-emission or compatibility fallback: this is a hard cut inside the v17
48+
major window, and the old keys simply no longer exist on the wire.

content/docs/api/client-sdk.mdx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -508,9 +508,13 @@ The `find` method accepts an options object with **canonical** (recommended) fie
508508
| `returnRecords` | `boolean` | `false` | Include full records in response |
509509
| `continueOnError` | `boolean` | `false` | Continue after errors (when atomic is false) |
510510

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

515519
---
516520

content/docs/api/data-api.mdx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -226,9 +226,9 @@ Execute a batch operation (create / update / upsert / delete) on multiple record
226226
}
227227
```
228228

229-
**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`).
229+
**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`).
230230

231-
`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`.
231+
`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`.
232232

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

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

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

280284
### Batch size
281285

content/docs/api/wire-format.mdx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,7 @@ Process many records of a **single** operation type in one request. The body car
475475

476476
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.
477477

478-
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`.
478+
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`.
479479

480480
### Request
481481

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

496496
### Response — `200 OK`
497497

498-
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.
498+
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.
499499

500500
```json
501501
{
@@ -505,15 +505,15 @@ The response is the `BatchUpdateResponse` envelope: a top-level `success` flag p
505505
"succeeded": 2,
506506
"failed": 0,
507507
"results": [
508-
{ "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "success": true, "record": { "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "status": "done" } },
509-
{ "id": "tsk_01HQ4B8C0E4G6H9K3L5M", "success": true, "record": { "id": "tsk_01HQ4B8C0E4G6H9K3L5M", "status": "done" } }
508+
{ "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "success": true, "index": 0, "data": { "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "status": "done" } },
509+
{ "id": "tsk_01HQ4B8C0E4G6H9K3L5M", "success": true, "index": 1, "data": { "id": "tsk_01HQ4B8C0E4G6H9K3L5M", "status": "done" } }
510510
]
511511
}
512512
```
513513

514514
### Partial Failure Response
515515

516-
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:
516+
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`):
517517

518518
```json
519519
{
@@ -523,8 +523,8 @@ When the batch is not atomic and some records fail, the failing entries carry a
523523
"succeeded": 1,
524524
"failed": 1,
525525
"results": [
526-
{ "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "success": true, "record": { "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "status": "done" } },
527-
{ "id": "tsk_invalid_id", "success": false, "error": "Record tsk_invalid_id not found in task" }
526+
{ "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "success": true, "index": 0, "data": { "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "status": "done" } },
527+
{ "id": "tsk_invalid_id", "success": false, "index": 1, "errors": [{ "code": "RECORD_NOT_FOUND", "message": "Record tsk_invalid_id not found in task", "httpStatus": 404 }] }
528528
]
529529
}
530530
```

0 commit comments

Comments
 (0)