Skip to content

Commit 078e581

Browse files
fix(rest): every 2xx write answer whose body reports droppedFields also sends the X-ObjectStack-Dropped-Fields header (#29034)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XMdXANAVvC6ZVwF6mcg1tiB)_ Fixes #28933 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:克隆、批量创建(createMany)、按对象 batch、updateMany、导入提交和跨对象 /batch 这六个写入口,只要响应正文里带 `droppedFields`,现在也会发 `X-ObjectStack-Dropped-Fields` 响应头,和单条创建、更新一样,走同一个 `applyDroppedFieldsHeader`。 - **为什么改**:只看响应头的 API 集成方,在批量创建和克隆上收不到"只读字段被剥掉"的提示,会以为字段写进去了;同一个契约在不同写入口说法不一。 - **风险与回滚**:只加响应头,正文和状态码一个字节不变,没剥字段时不发头。批量入口的头只能写并集(说不清哪一行剥了什么),逐行明细仍在正文。回滚即 revert 本 PR。 - **先看**:`packages/rest/src/error-response.ts`(头的格式去重、读逐行报告);`packages/rest/src/batch-routes.ts`(createMany、batch、updateMany、跨对象 batch 四处调用);`packages/rest/src/data-action-routes.ts`(克隆与导入提交;导入预演不发头)。 - **两分钟验证**:`pnpm --filter @objectstack/rest exec vitest run src/rest-dropped-fields-every-write-door.test.ts`,预期 15 个用例全过。 - **需要你做的**:无,席位落地。 ## What changes Single `POST /data/:object` and `PATCH /data/:object/:id` advertised a legal write strip (`readonly`, a true `readonlyWhen`) in the body and in the `X-ObjectStack-Dropped-Fields` header (#3431). Every other write door that reports the same strip in its body now sends the header too, through the existing `applyDroppedFieldsHeader` and in the same `field;reason=REASON` format. There is one seam and no second formatter. | door | where the body reports it | header | |:--|:--|:--| | `POST /data/:object/:id/clone` | top-level `droppedFields` | the clone's own strips | | `POST /data/:object/createMany` | top-level `droppedFields`, already the rows' union | that union | | `POST /data/:object/batch` | `results[i].droppedFields` | the union of the rows' strips | | `POST /data/:object/updateMany` | `results[i].droppedFields` | the union of the rows' strips | | `POST /data/:object/import`, commit | `results[i].droppedFields` | the union of the rows' strips | | `POST /batch`, cross-object | top-level `droppedFields`, one event per op with its `index` | the union | - **The seam.** `applyDroppedFieldsHeader(res, result, rows?)` reads `result.droppedFields` and, when a door passes them, each row's `droppedFields`. The formatter now emits each `field;reason` token once. A single-record answer never repeats a token, so its header value is unchanged (the existing #3431 cases pass unedited). - **The union.** The header cannot say which row, op or object dropped a field. On a bulk door it names the union, each token once, and the body keeps the per-row report. A field one row strips and another does not is named once. - **The stale roll-up answer.** Clone's and createMany's `201` over a write that landed under a failing roll-up (#28782) now goes through `earnedWriteDespiteStaleSummary`, which sets the header as single create's earned answer already did. - **Import dry run.** It writes nothing, so it is not a write answer and sends no header. Its rows still preview the strip in the body. - **Words that went false.** The spec `.describe()` text on clone's and createMany's `droppedFields` and on the batch row (`BatchOperationResultSchema`) said these answers carry no header. So did the `CloneDataResult` docblock in `@objectstack/client` and the clone section of `content/docs/api/data-api.mdx`. They now say the header is sent; `content/docs/references/api/{batch,protocol}.mdx` are regenerated (`gen:schema && gen:docs`). No key, type or status changes. - **Changeset.** `@objectstack/rest`, `@objectstack/spec` and `@objectstack/client` patch. It states that a bulk header is the union and the body keeps the per-row detail. ## Tests - `packages/rest/src/rest-dropped-fields-every-write-door.test.ts` (new, 15 cases): `RestServer` over the real `ObjectStackProtocolImplementation` and `ObjectQL` on better-sqlite3. It covers every door in the table: header value, body unchanged, a mixed createMany naming the field once, rows stripping different fields, and a no-strip control per door. It also covers the import dry run sending no header, and the stale roll-up answer of clone and createMany through a protocol double. - `packages/qa/dogfood/test/showcase-static-readonly.dogfood.test.ts` (four cases added) on the stock showcase as admin: clone with `overrides.lead_score` and a two-row createMany carrying `lead_score` send `lead_score;reason=readonly`; a mixed createMany names it once; a createMany that strips nothing sends no header. ## Acceptance notes - **The order's hypotheses, measured.** Red first: confirmed. On main the new rest pin went 11 red with the 4 controls green, and the dogfood clone case answered no header. Controls: confirmed, and the single create and update headers are unchanged. Ablation: confirmed, each call reds only its own door's cases (table below). - **The card's createMany path.** The card wrote the bare-array createMany as `POST /data/:object`. That route refuses an array body (`400 VALIDATION_FAILED`, #3899). The bare-array door is `POST /data/:object/createMany`, which is the door fixed here. - **A clone reports the source's empty readonly columns.** `cloneData` copies every column it does not reset, null ones included, and the engine's strip reports a present key whatever its value. So a plain clone of a row whose readonly column is empty answers `droppedFields` naming that column (measured on main: a clone of a row with null `ro2`, and a clone of a showcase contact with no override, both name the column). The header now repeats what the body says. This PR does not change the producer. That is why the clone cases use objects of their own and the dogfood clone case asserts with `toContain`. It is handed to the seat as a finding. - **Reversal of a recorded stance.** #3455's changeset said the header is "deliberately not emitted for batches" because one header cannot express per-row drops. The order's ruling settles that: the header is the union and the body keeps the per-row detail. The batch-row `.describe()` text is updated to say so. - **Not changed.** Public form submit: its answer carries no `droppedFields`. The async import job: its `202` carries none, and its results route is a read. The runtime dispatcher's `POST`/`PATCH /data` arms (the `@objectstack/hono` catch-all twin) also set no header, for single create and update too. That is outside this card's REST doors; observed, not measured live. - **Overlap.** Before opening, I re-read the open PRs touching `crud-routes.ts`, `batch-routes.ts`, `data-action-routes.ts`, `error-response.ts` and the two spec files. #28731 is the only nearby one, and its files are in `metadata-protocol`, `runtime` and dogfood. No shared file. ## Verification Everything below was measured at `a6b0d839`. <details><summary>Red first on main (135054e + the new tests only)</summary> ```text rest-dropped-fields-every-write-door.test.ts Tests 11 failed | 4 passed (15) every header assertion: expected undefined to be 'ro;reason=readonly' (…) showcase-static-readonly.dogfood.test.ts clone with overrides.lead_score: expected null to be 'lead_score;reason=readonly' ``` </details> <details><summary>Green</summary> ```text pnpm --filter @objectstack/rest exec vitest run rest-dropped-fields-every-write-door rest-dropped-fields rest-batch-endpoint import-row-dropped-fields-20701 data-write-summary-recompute-other-doors rest-stored-expression-dropped-fields rest-update-path-id-not-a-dropped-field Test Files 7 passed (7) · Tests 66 passed (66) pnpm --filter @objectstack/dogfood exec vitest run test/showcase-static-readonly.dogfood.test.ts Tests 8 passed (8) pnpm --filter @objectstack/rest typecheck && pnpm --filter @objectstack/client typecheck VERDICT command-exit 0 pnpm --filter @objectstack/rest test Test Files 367 passed (367) · Tests 6951 passed | 332 skipped (7283) · VERDICT command-exit 0 ``` </details> <details><summary>Ablation (scripts/ablation-replace.mjs, anchor hits 1, restore proven blob == HEAD each time)</summary> | call removed | red cases (of 15) | |:--|:--| | clone success | clone overrides case (1) | | clone stale answer | clone earned case (1) | | createMany success | the three createMany strip cases (3) | | createMany stale answer | createMany earned case (1) | | per-object batch | batch case (1) | | updateMany | updateMany case (1) | | import commit | import commit case (1) | | import dry-run guard (header sent on a dry run) | import case (1) | | cross-object batch | cross-object case (1) | | seam: each token once | updateMany, batch, import, cross-object (4) | | seam: per-row reading | updateMany, batch, import (3) | Dist-resolved dogfood leg: the clone and createMany calls were replaced by a marker, rest was rebuilt, and `ablation-dist-preflight` found the marker in dist. Three dogfood cases went red (clone, two-row createMany, mixed createMany), with 5 passed. After the restore, rest was rebuilt, the marker was absent from dist and the tree was clean, and all 8 passed. </details> <details><summary>Derived gates (dispatch-gates --commands, local subset)</summary> ```text 106 derived, 106 run, every one :: exit 0 at a6b0d83 (dispatch-gates --ran: 0 NOT-MEASURED, 0 UNRUN). First pass: 8 gates answered exit 3 (PREREQUISITE NOT MET: spec dist predated the describe edit; the teardown self-test needed its anchor commit fetched) and check:generated exit 1 on the same stale dist. After pnpm --filter @objectstack/spec build and the fetch, all 9 reran exit 0. The full list is in the os-dev-report comment. ``` </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5nE2GswUTcEX8HbTmt --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5nE2GswUTcEX8HbTmt)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent de68479 commit 078e581

14 files changed

Lines changed: 491 additions & 40 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/rest': patch
3+
'@objectstack/spec': patch
4+
'@objectstack/client': patch
5+
---
6+
7+
fix(rest): every 2xx write answer whose body reports `droppedFields` also sends the `X-ObjectStack-Dropped-Fields` header
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
Single `POST /data/:object` and `PATCH /data/:object/:id` advertise a legal write strip (a static `readonly` or a true `readonlyWhen` field) in the body and in the `X-ObjectStack-Dropped-Fields` header. The other write doors reported the same strip in the body only, so a caller that reads the header was told nothing. They now send the header too, through the same `applyDroppedFieldsHeader` seam and in the same `field;reason=REASON` format:
13+
14+
- **Clone** (`POST /data/:object/:id/clone`): the clone's own strips.
15+
- **createMany** (`POST /data/:object/createMany`): the body's `droppedFields` is already the union of the rows' strips (its answer has no per-row slot), and the header names that union.
16+
- **Per-row bulk doors**: the per-object `POST /data/:object/batch`, `POST /data/:object/updateMany` and an import commit (`POST /data/:object/import`) report each row's strips in `results[i].droppedFields`. The header names their union.
17+
- **Cross-object `POST /batch`**: its body reports one event per op, with the op's `index`. The header names their union.
18+
19+
The header has one token per field and reason, so it cannot say which row, op or object dropped a field. On a bulk door it names the union, each token once, and the body keeps the per-row report. A field one row strips and another does not is still named once. The stale roll-up answer (`201` over a write that landed) of clone and createMany carries the header as their success answers do. Each door sends no header when nothing was stripped, and an import dry run sends none: it writes nothing, though its rows preview the strip in the body. Response bodies and statuses are unchanged.
20+
21+
The spec's `.describe()` text on the clone, createMany and batch-row `droppedFields`, and the client's `CloneDataResult` docblock, stop saying that these answers carry no header.

‎content/docs/api/data-api.mdx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -418,8 +418,9 @@ bare `CloneDataResponseSchema` body, no envelope. `droppedFields` appears only
418418
when the engine stripped a static `readonly` column from the copy — one the
419419
source row carried (a clone is the one create that holds a read-only value the
420420
caller never typed) or one supplied through `overrides` — and names the dropped
421-
keys; the clone still succeeded and the field re-derived its default. It rides
422-
the body only: this route sets no `X-ObjectStack-Dropped-Fields` header.
421+
keys; the clone still succeeded and the field re-derived its default. The
422+
`X-ObjectStack-Dropped-Fields` response header names the same fields, as on a
423+
single create.
423424

424425
---
425426

‎content/docs/references/api/batch.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ const result = BatchConfigSchema.parse(data);
6868
| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error. A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back. |
6969
| **data** | `Record<string, any>` | optional | Full record data (if returnRecords=true) |
7070
| **index** | `number` | optional | Index of the record in the request array |
71-
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` / TRUE `readonlyWhen` on update, or the in-engine static `readonly` strip on create. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. |
71+
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` / TRUE `readonlyWhen` on update, or the in-engine static `readonly` strip on create. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot say which row dropped what, so this body field stays the canonical per-row channel; REST also sends the `X-ObjectStack-Dropped-Fields` response header on the answer, naming the union of the rows' drops (each field and reason once). Optional — omit-when-empty keeps the shape backward-compatible. |
7272

7373
### Nested Shape: `BatchOperationResult.errors[number]`
7474

@@ -213,7 +213,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa
213213
| **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error. A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back. |
214214
| **data** | `Record<string, any>` | optional | Full record data (if returnRecords=true) |
215215
| **index** | `number` | optional | Index of the record in the request array |
216-
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` / TRUE `readonlyWhen` on update, or the in-engine static `readonly` strip on create. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. |
216+
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` / TRUE `readonlyWhen` on update, or the in-engine static `readonly` strip on create. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot say which row dropped what, so this body field stays the canonical per-row channel; REST also sends the `X-ObjectStack-Dropped-Fields` response header on the answer, naming the union of the rows' drops (each field and reason once). Optional — omit-when-empty keeps the shape backward-compatible. |
217217

218218

219219
---

0 commit comments

Comments
 (0)