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
21 changes: 21 additions & 0 deletions .changeset/28933-dropped-fields-header-every-write-door.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/rest': patch
'@objectstack/spec': patch
'@objectstack/client': patch
---

fix(rest): every 2xx write answer whose body reports `droppedFields` also sends the `X-ObjectStack-Dropped-Fields` header

Clause-②: yes (widening)
Metadata-protocol: none

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:

- **Clone** (`POST /data/:object/:id/clone`): the clone's own strips.
- **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.
- **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.
- **Cross-object `POST /batch`**: its body reports one event per op, with the op's `index`. The header names their union.

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.

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.
5 changes: 3 additions & 2 deletions content/docs/api/data-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -418,8 +418,9 @@ bare `CloneDataResponseSchema` body, no envelope. `droppedFields` appears only
when the engine stripped a static `readonly` column from the copy — one the
source row carried (a clone is the one create that holds a read-only value the
caller never typed) or one supplied through `overrides` — and names the dropped
keys; the clone still succeeded and the field re-derived its default. It rides
the body only: this route sets no `X-ObjectStack-Dropped-Fields` header.
keys; the clone still succeeded and the field re-derived its default. The
`X-ObjectStack-Dropped-Fields` response header names the same fields, as on a
single create.

---

Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/api/batch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const result = BatchConfigSchema.parse(data);
| **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. |
| **data** | `Record<string, any>` | optional | Full record data (if returnRecords=true) |
| **index** | `number` | optional | Index of the record in the request array |
| **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. |
| **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. |

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

Expand Down Expand Up @@ -213,7 +213,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa
| **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. |
| **data** | `Record<string, any>` | optional | Full record data (if returnRecords=true) |
| **index** | `number` | optional | Index of the record in the request array |
| **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. |
| **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. |


---
Expand Down
Loading
Loading