Repository navigation
Commit 078e581
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
File tree
- .changeset
- content/docs
- api
- references/api
- packages
- client/src
- qa/dogfood/test
- rest/src
- spec/src/api
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
418 | 418 | | |
419 | 419 | | |
420 | 420 | | |
421 | | - | |
422 | | - | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
423 | 424 | | |
424 | 425 | | |
425 | 426 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
68 | 68 | | |
69 | 69 | | |
70 | 70 | | |
71 | | - | |
| 71 | + | |
72 | 72 | | |
73 | 73 | | |
74 | 74 | | |
| |||
213 | 213 | | |
214 | 214 | | |
215 | 215 | | |
216 | | - | |
| 216 | + | |
217 | 217 | | |
218 | 218 | | |
219 | 219 | | |
| |||
0 commit comments