diff --git a/.changeset/28933-dropped-fields-header-every-write-door.md b/.changeset/28933-dropped-fields-header-every-write-door.md new file mode 100644 index 00000000000..d06e91bb006 --- /dev/null +++ b/.changeset/28933-dropped-fields-header-every-write-door.md @@ -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. diff --git a/content/docs/api/data-api.mdx b/content/docs/api/data-api.mdx index 8b6b89c465a..5881a600f1d 100644 --- a/content/docs/api/data-api.mdx +++ b/content/docs/api/data-api.mdx @@ -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. --- diff --git a/content/docs/references/api/batch.mdx b/content/docs/references/api/batch.mdx index 04fc0c6f77b..eeadbc72a67 100644 --- a/content/docs/references/api/batch.mdx +++ b/content/docs/references/api/batch.mdx @@ -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` | 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]` @@ -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` | 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. | --- diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 6e1fe2386da..feda4f59e89 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -453,7 +453,7 @@ Canonical cross-paradigm action/node descriptor (ADR-0018) | **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` | 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. | --- @@ -494,7 +494,7 @@ Canonical cross-paradigm action/node descriptor (ADR-0018) | **id** | `string` | ✅ | The ID of the newly created clone. | | **sourceId** | `string` | ✅ | The ID of the record the clone was copied from. | | **record** | `Record` | ✅ | The created clone, including server-generated fields. Engine-owned values (injected system/audit columns, autonumbers, computed formula/summary fields) are re-derived by the insert path rather than copied from the source; caller-supplied `overrides` win over copied values. | -| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: fields that were LEGALLY stripped before the clone was written — a non-system clone cannot seed a static `readonly` column, whether the value was COPIED from the source row or supplied through `overrides` (the strip runs inside `engine.insert`, after the `beforeInsert` hooks, `isSystem`-gated, exactly as on `createData`), so those keys are dropped and the field re-derives its default. Present ONLY when ≥1 field was dropped; the clone still succeeded without them (status/success semantics unchanged). Carried in the 201 body only — this route relays the producer verbatim and sets no `X-ObjectStack-Dropped-Fields` header. Optional — omit-when-empty keeps the shape backward-compatible for existing clients. | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: fields that were LEGALLY stripped before the clone was written — a non-system clone cannot seed a static `readonly` column, whether the value was COPIED from the source row or supplied through `overrides` (the strip runs inside `engine.insert`, after the `beforeInsert` hooks, `isSystem`-gated, exactly as on `createData`), so those keys are dropped and the field re-derives its default. Present ONLY when ≥1 field was dropped; the clone still succeeded without them (status/success semantics unchanged). REST additionally surfaces this as the `X-ObjectStack-Dropped-Fields` response header. Optional — omit-when-empty keeps the shape backward-compatible for existing clients. | | **warnings** | `{ field: string; code: string; message: string; rule?: string; … }[]` | optional | The advisory validation-rule hits of THIS write: each `severity: 'warning'` or `'info'` rule whose verdict on the written record was "violated", with its `rule` name, `severity`, `field`, `code` and author-written `message` — the same element the validate-only preview reports in a row's `warnings`. Advisory rules never block, so the write succeeded; this is the advisory to show the person who made it. Present ONLY when at least one advisory rule hit; a write with no hit omits the key. Hits of a nested write a hook or a flow made inside this one are not included, and a rule that could not be evaluated is not a hit. | ### Nested Shape: `CloneDataResponse.droppedFields[number]` @@ -603,7 +603,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | **object** | `string` | ✅ | Object name | | **records** | `Record[]` | ✅ | Created records | | **count** | `number` | ✅ | Number of records created | -| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: caller-supplied `readonly` fields the in-engine create-side strip (`engine.insert`, `isSystem`-gated) removed before the rows were written. AGGREGATED across the batch (one event per object/reason with the UNION of dropped field names) rather than per-row, because this response is `{ object, records, count }` and has no per-row slot to hang a drop set on — a union is the only view it can represent. So read a name here as "at least one row dropped this field", NOT "every row dropped the same set": the strip runs INSIDE `engine.insert` after the `beforeInsert` hooks and exempts keys a hook itself wrote, tracked per row: rows where a hook stamped a protected key drop a different set from rows where it did not. Present ONLY when ≥1 field was dropped; the creates still succeeded without them (count/success unchanged). Optional — omit-when-empty keeps the shape backward-compatible. (The same reading applies to the partial-success bulk create, whose batch-level `droppedFields` names no row for the same reason. The paths that DO carry per-row `droppedFields` on each result are the bulk UPDATE and the mixed batch, where each row is its own engine call — see BatchOperationResultSchema.) | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: caller-supplied `readonly` fields the in-engine create-side strip (`engine.insert`, `isSystem`-gated) removed before the rows were written. AGGREGATED across the batch (one event per object/reason with the UNION of dropped field names) rather than per-row, because this response is `{ object, records, count }` and has no per-row slot to hang a drop set on — a union is the only view it can represent. So read a name here as "at least one row dropped this field", NOT "every row dropped the same set": the strip runs INSIDE `engine.insert` after the `beforeInsert` hooks and exempts keys a hook itself wrote, tracked per row: rows where a hook stamped a protected key drop a different set from rows where it did not. Present ONLY when ≥1 field was dropped; the creates still succeeded without them (count/success unchanged). REST additionally surfaces this union as the `X-ObjectStack-Dropped-Fields` response header, each field and reason once. Optional — omit-when-empty keeps the shape backward-compatible. (The same reading applies to the partial-success bulk create, whose batch-level `droppedFields` names no row for the same reason. The paths that DO carry per-row `droppedFields` on each result are the bulk UPDATE and the mixed batch, where each row is its own engine call — see BatchOperationResultSchema.) | ### Nested Shape: `CreateManyDataResponse.droppedFields[number]` @@ -713,7 +713,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` | 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. | --- @@ -3178,7 +3178,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` | 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. | --- diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index de8859359b8..76fa83d3fb0 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -543,8 +543,8 @@ export interface CloneDataResult { * a non-system clone cannot seed a static `readonly` column, whether the value * was COPIED from the source row or supplied through `overrides`, so those * keys are dropped and the field re-derives its default. Present only when - * ≥1 field was dropped; the clone still succeeded. Body only: unlike `create`, - * the clone route sets no `X-ObjectStack-Dropped-Fields` header. + * ≥1 field was dropped; the clone still succeeded. REST also mirrors this in + * the `X-ObjectStack-Dropped-Fields` response header, as it does for `create`. */ droppedFields?: DroppedFieldsEvent[]; /** diff --git a/packages/qa/dogfood/test/showcase-static-readonly.dogfood.test.ts b/packages/qa/dogfood/test/showcase-static-readonly.dogfood.test.ts index 79d3ff4a698..19a9eae2bbc 100644 --- a/packages/qa/dogfood/test/showcase-static-readonly.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-static-readonly.dogfood.test.ts @@ -117,3 +117,66 @@ describe('showcase: static readonly write enforcement (#2948 / #3003 / #3043)', expect(after.lead_score ?? null, 'readonly value unchanged by an all-forged payload').toBeNull(); }); }); + +// The same strip on the two other single-object create doors. `POST +// /data/:object` and `PATCH /data/:object/:id` advertise it in the body and the +// `X-ObjectStack-Dropped-Fields` header (#3431); clone and createMany used to +// put it in the body only, so a caller reading the header missed it. Every 2xx +// write answer whose body carries `droppedFields` now sends the header too, in +// the single create's format; createMany's names the union of its rows' +// strips, each field once, and the body keeps its own report. +describe('showcase: clone and createMany advertise the readonly strip in the header too', () => { + let stack: VerifyStack; + let admin: string; + let sourceId: string; + + beforeAll(async () => { + stack = await getSharedShowcase(); + admin = await stack.signIn(); + const created = await stack.apiAs(admin, 'POST', OBJ, { name: 'Header Probe Source', email: 'hdr-src@verify.test' }); + expect(created.status).toBe(201); + sourceId = idOf(await created.json()); + expect(sourceId).toBeTruthy(); + }, 60_000); + + const namesReadonlyLeadScore = (body: any) => + ((body?.droppedFields ?? []) as Array<{ fields: string[]; reason: string }>) + .some((d) => d.reason === 'readonly' && d.fields.includes('lead_score')); + + it('clone with overrides.lead_score: body and header name lead_score', async () => { + const res = await stack.apiAs(admin, 'POST', `${OBJ}/${sourceId}/clone`, { overrides: { lead_score: 42 } }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(namesReadonlyLeadScore(body), JSON.stringify(body?.droppedFields)).toBe(true); + // A plain clone copies the source's other readonly columns too, so the + // header may name more than lead_score; it names what the body names. + expect(res.headers.get('X-ObjectStack-Dropped-Fields') ?? '').toContain('lead_score;reason=readonly'); + }); + + it('a two-row createMany carrying lead_score: body and header name lead_score once', async () => { + const res = await stack.apiAs(admin, 'POST', `${OBJ}/createMany`, [ + { name: 'Header Probe A', email: 'hdr-a@verify.test', lead_score: 10 }, + { name: 'Header Probe B', email: 'hdr-b@verify.test', lead_score: 11 }, + ]); + const body = await res.json(); + expect(res.status, JSON.stringify(body)).toBe(201); + expect(namesReadonlyLeadScore(body), JSON.stringify(body?.droppedFields)).toBe(true); + expect(res.headers.get('X-ObjectStack-Dropped-Fields')).toBe('lead_score;reason=readonly'); + }); + + it('a createMany where only one row strips still names the field once', async () => { + const res = await stack.apiAs(admin, 'POST', `${OBJ}/createMany`, [ + { name: 'Header Probe C', email: 'hdr-c@verify.test', lead_score: 12 }, + { name: 'Header Probe D', email: 'hdr-d@verify.test' }, + ]); + expect(res.status).toBe(201); + expect(res.headers.get('X-ObjectStack-Dropped-Fields')).toBe('lead_score;reason=readonly'); + }); + + it('a createMany that strips nothing sends no header', async () => { + const many = await stack.apiAs(admin, 'POST', `${OBJ}/createMany`, [{ name: 'Header Probe E', email: 'hdr-e@verify.test' }]); + expect(many.status).toBe(201); + expect((await many.json())?.droppedFields).toBeUndefined(); + expect(many.headers.get('X-ObjectStack-Dropped-Fields')).toBeNull(); + }); +}); diff --git a/packages/rest/src/batch-routes.ts b/packages/rest/src/batch-routes.ts index 3377d89e108..9e1ae1979ad 100644 --- a/packages/rest/src/batch-routes.ts +++ b/packages/rest/src/batch-routes.ts @@ -60,14 +60,14 @@ import { apiKeyManagementCredentialRefusal } from '@objectstack/core'; // The engine's own by-id predicates, which `ObjectQL.update` / `.delete` // dispatch on — the cross-object batch's up-front id check asks them. import { resolveEngineDeleteDispatch, resolveEngineUpdateDispatch } from '@objectstack/metadata-core'; -import { handleRouteError } from './error-response.js'; +import { applyDroppedFieldsHeader, handleRouteError } from './error-response.js'; import type { ServerScopedDataRequest } from './data-action-routes.js'; import { apiAccessDenialFromEnable, wiredEngineOrLoud, type RestServer, } from './rest-server.js'; -import { earnedAnswerLogged } from './stale-summary-answer.js'; +import { earnedWriteDespiteStaleSummary } from './stale-summary-answer.js'; /** * The `RestServer` members {@link registerBatchEndpoints} reads — every one of @@ -424,7 +424,12 @@ export function registerBatchEndpoints(this: BatchRouteHost, basePath: string): return out; }, context); - res.json({ results, ...(dropped.length > 0 ? { droppedFields: dropped } : {}) }); + const answer = { results, ...(dropped.length > 0 ? { droppedFields: dropped } : {}) }; + // [#3431] The ops' strips in the header too: one token per + // field and reason, the union across ops (the header names no + // op or object); the body keeps each op's event with its index. + applyDroppedFieldsHeader(res, answer); + res.json(answer); } catch (error: any) { // Log only genuine server faults; client 4xx (validation, // unresolved ref, atomic rollback of a bad op) are expected. @@ -486,6 +491,9 @@ export function registerBatchEndpoints(this: BatchRouteHost, basePath: string): ...(context ? { context } : {}), }; const result = await p.batchData!(batchRequest); + // [#3431] Each row's strips (`results[i].droppedFields`) in + // the header too, as their union; the body keeps each row's. + applyDroppedFieldsHeader(res, result, (result as { results?: unknown[] } | null)?.results); res.json(result); } catch (error: any) { handleRouteError(res, error, req.params?.object); @@ -539,10 +547,15 @@ export function registerBatchEndpoints(this: BatchRouteHost, basePath: string): ...(context ? { context } : {}), }; const result = await p.createManyData!(createManyRequest); + // [#3431] The strips in the header too. `createManyData`'s + // `droppedFields` is already the rows' union (its response + // has no per-row slot), so the header names each field once. + applyDroppedFieldsHeader(res, result); res.status(201).json(result); } catch (error: any) { - // [#28782] Rows stored under a failing roll-up: the answer they earned, 201. - const earned = earnedAnswerLogged(error, req.params?.object); + // [#28782] Rows stored under a failing roll-up: the answer they earned, 201, + // with the header the success path sets. + const earned = earnedWriteDespiteStaleSummary(error, res, req.params?.object); if (earned) { res.status(201).json(earned); return; } handleRouteError(res, error, req.params?.object); } @@ -598,6 +611,8 @@ export function registerBatchEndpoints(this: BatchRouteHost, basePath: string): ...(context ? { context } : {}), }; const result = await p.updateManyData!(updateManyRequest); + // [#3431] Each row's strips in the header too, as their union. + applyDroppedFieldsHeader(res, result, (result as { results?: unknown[] } | null)?.results); res.json(result); } catch (error: any) { handleRouteError(res, error, req.params?.object); diff --git a/packages/rest/src/data-action-routes.ts b/packages/rest/src/data-action-routes.ts index 4de52b417f5..356e0ffc16b 100644 --- a/packages/rest/src/data-action-routes.ts +++ b/packages/rest/src/data-action-routes.ts @@ -72,7 +72,7 @@ import { importControlOptOutRefusal, prepareImportRequest } from './import-prepa import { readTemplateMode, templateFieldDefs, templateText } from './import-template.js'; import { refuseRepeatedQueryParams } from './query-multiplicity.js'; import { refuseUnknownQueryParams } from './query-allowlist.js'; -import { handleRouteError, logUnexpectedRouteError, mapDataError, nestedRefusalEnvelope } from './error-response.js'; +import { applyDroppedFieldsHeader, handleRouteError, logUnexpectedRouteError, mapDataError, nestedRefusalEnvelope } from './error-response.js'; // [#24348] The shared ADR-0112 nested envelope for the import doors' control // opt-out refusal — the flat sibling-`code` dialect the doors' older answers // use is ratcheted down by `check:route-envelope`. @@ -99,7 +99,7 @@ import { rowsToCsv, type RestServer, } from './rest-server.js'; -import { earnedAnswerLogged } from './stale-summary-answer.js'; +import { earnedWriteDespiteStaleSummary } from './stale-summary-answer.js'; // [#23050 slice 1] Moved here from `rest-server.ts`, verbatim, because this module is // the first one outside that file to need it: exporting it from there changed the @@ -438,13 +438,16 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: ...(environmentId ? { environmentId } : {}), ...(context ? { context } : {}), }); + // [#3431] The strips in the header too, as single create sends them. + applyDroppedFieldsHeader(res, result); res.status(201).json(result); } catch (error: any) { // Clone's domain errors (CLONE_DISABLED/RECORD_NOT_FOUND) // carry an explicit `.status`; `handleRouteError` resolves // that passthrough itself and logs only genuine faults. - // [#28782] A copy stored under a failing roll-up: the answer it earned, 201. - const earned = earnedAnswerLogged(error, req.params?.object); + // [#28782] A copy stored under a failing roll-up: the answer it earned, 201, + // with the header the success path sets. + const earned = earnedWriteDespiteStaleSummary(error, res, req.params?.object); if (earned) { res.status(201).json(earned); return; } handleRouteError(res, error, req.params?.object); } @@ -598,6 +601,11 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: translate: await this.resolveMessageTranslator(environmentId, req), }); + // [#3431] A commit's row strips (`results[i].droppedFields`) in + // the header too, as their union. A dry run writes nothing, so + // it is no write answer and sends none; its rows still preview + // the strip in the body. + if (!dryRun) applyDroppedFieldsHeader(res, undefined, summary.results); res.json({ object: objectName, dryRun, diff --git a/packages/rest/src/error-response.ts b/packages/rest/src/error-response.ts index 873024a0b6d..bd698f69e36 100644 --- a/packages/rest/src/error-response.ts +++ b/packages/rest/src/error-response.ts @@ -37,7 +37,7 @@ * decorate a SUCCESSFUL write (#3431). They travelled with the * block because they are the rest of "what this boundary puts * on the wire", and splitting them out would have created a - * third module for two functions. + * third module for two functions (and the small reader beside them). * * Export surface: `mapDataError` is the only member this code has ever been * public in, and `rest-server.ts` re-exports it from here unchanged — the three @@ -1157,21 +1157,42 @@ export function handleRouteError(res: any, error: any, object?: string): void { * nothing was dropped. The same events also ride the response body's * `droppedFields` (the structured/cross-origin-safe channel). */ -function droppedFieldsHeaderValue(events: DroppedFieldsEvent[] | undefined): string { - if (!events?.length) return ''; - return events - .flatMap((e) => e.fields.map((f) => `${f};reason=${e.reason}`)) - .join(', '); +function droppedFieldsHeaderValue(events: readonly DroppedFieldsEvent[]): string { + // Each token once: a bulk answer's events repeat a field across rows (or + // ops), and the header cannot say which row dropped it, so it names the + // union. A single-record answer never repeats a token, so its value is + // what it always was. + const tokens = new Set(); + for (const e of events) { + if (!Array.isArray(e?.fields)) continue; + for (const f of e.fields) tokens.add(`${f};reason=${e.reason}`); + } + return [...tokens].join(', '); +} + +/** The events of one `droppedFields` member, or none when it is absent or not a list. */ +function droppedFieldsOf(carrier: unknown): readonly DroppedFieldsEvent[] { + const events = (carrier as { droppedFields?: unknown } | null | undefined)?.droppedFields; + return Array.isArray(events) ? events as DroppedFieldsEvent[] : []; } /** - * Set the `X-ObjectStack-Dropped-Fields` header from a data-write protocol - * result, tolerating both the Hono-style `res.header(name, value)` used - * elsewhere in this file and the node/Express-style `res.setHeader`. No-ops when - * the result carried no drops (or the response object supports neither method). + * Set the `X-ObjectStack-Dropped-Fields` header from a data-write answer, + * tolerating both the Hono-style `res.header(name, value)` used elsewhere in + * this file and the node/Express-style `res.setHeader`. No-ops when nothing was + * dropped (or the response object supports neither method). + * + * Every 2xx REST write answer whose body carries `droppedFields` calls this, + * once, before it answers. `result` is the answer whose own `droppedFields` + * member is read: single create / update, clone, createMany (whose member is + * already the rows' union), the cross-object `/batch`. A door whose body reports + * per row (`results[i].droppedFields`: per-object batch, updateMany, import) + * passes those rows as `rows`, and the header names the union of their events, + * each token once; the body keeps the per-row report. */ -export function applyDroppedFieldsHeader(res: any, result: unknown): void { - const header = droppedFieldsHeaderValue((result as { droppedFields?: DroppedFieldsEvent[] } | null)?.droppedFields); +export function applyDroppedFieldsHeader(res: any, result: unknown, rows?: readonly unknown[]): void { + const events = [...droppedFieldsOf(result), ...(Array.isArray(rows) ? rows.flatMap(droppedFieldsOf) : [])]; + const header = droppedFieldsHeaderValue(events); if (!header) return; if (typeof res?.header === 'function') res.header('X-ObjectStack-Dropped-Fields', header); else if (typeof res?.setHeader === 'function') res.setHeader('X-ObjectStack-Dropped-Fields', header); diff --git a/packages/rest/src/rest-dropped-fields-every-write-door.test.ts b/packages/rest/src/rest-dropped-fields-every-write-door.test.ts new file mode 100644 index 00000000000..c8baefb2e94 --- /dev/null +++ b/packages/rest/src/rest-dropped-fields-every-write-door.test.ts @@ -0,0 +1,318 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `X-ObjectStack-Dropped-Fields` (#3431) on every 2xx REST write answer whose + * body carries `droppedFields`, through the one seam `applyDroppedFieldsHeader`. + * + * Single `POST /data/:object` and `PATCH /data/:object/:id` sent the header. + * These doors put the same strip in the body and sent no header, so a caller + * reading the header was told nothing: + * + * | door | the body's report | header | + * |:--|:--|:--| + * | `POST /data/:object/:id/clone` | top-level `droppedFields` | the clone's own events | + * | `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' events | + * | `POST /data/:object/updateMany` | `results[i].droppedFields` | the union of the rows' events | + * | `POST /data/:object/import` (commit) | `results[i].droppedFields` | the union of the rows' events | + * | `POST /batch` (cross-object) | top-level `droppedFields`, one per op with its `index` | the union | + * + * The header has one `field;reason=REASON` token per stripped field, so it + * cannot say which row (or op, or object) dropped what: a bulk door's header + * names the union, each token once, and the body keeps the per-row report. + * An import DRY RUN writes nothing, so it is not a write answer and sends no + * header, though its rows preview the strip. Bodies are unchanged. + * + * Harness: `RestServer` → `ObjectStackProtocolImplementation` → a real + * `ObjectQL` over better-sqlite3 `:memory:`, the route handlers called with a + * response double that records headers. The stale-roll-up answer (#28782) of + * clone and createMany is read through a protocol double, at the end. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysImportJob } from '@objectstack/platform-objects/audit'; +import { withEarnedAnswer } from '@objectstack/types'; +import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; + +const OBJECT = 'hdr_drops_doors'; +const HEADER = 'X-ObjectStack-Dropped-Fields'; + +const PROJ = { + name: OBJECT, label: 'Header drops', systemFields: false, + enable: { apiEnabled: true, clone: true }, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + title: { name: 'title', type: 'text' as const }, + status: { name: 'status', type: 'text' as const }, + ro: { name: 'ro', type: 'text' as const, readonly: true }, + ro2: { name: 'ro2', type: 'text' as const, readonly: true }, + locked: { name: 'locked', type: 'text' as const, readonlyWhen: "record.status == 'closed'" }, + }, +}; + +// Clone copies every column of the source it does not reset, readonly ones +// included, so a clone of a PROJ row also reports the source's `ro2`. Clone is +// read on its own objects: one readonly column, and none. +const CLONE_OBJECT = 'hdr_drops_clone'; +const PLAIN_OBJECT = 'hdr_drops_plain'; +const CLONE_PROJ = { + name: CLONE_OBJECT, label: 'Header drops clone', systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + title: { name: 'title', type: 'text' as const }, + ro: { name: 'ro', type: 'text' as const, readonly: true }, + }, +}; +const PLAIN_PROJ = { + name: PLAIN_OBJECT, label: 'Header drops plain', systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + title: { name: 'title', type: 'text' as const }, + }, +}; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { + headers: {} as Record, + write: () => true, end: () => {}, + header: (k: string, v: string) => { res.headers[k] = v; return res; }, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +type Answer = { status: number; body: any; header: string | undefined }; + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +async function boot() { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true); + await engine.init(); + engine.registry.registerObject(PROJ as any); + engine.registry.registerObject(CLONE_PROJ as any); + engine.registry.registerObject(PLAIN_PROJ as any); + engine.registry.registerObject(SysImportJob as any); + await engine.syncSchemas(); + await installDefaultOrganization(engine); + await engine.insert(OBJECT, { id: 'open1', title: 'open', status: 'open' } as any); + await engine.insert(OBJECT, { id: 'open2', title: 'open too', status: 'open' } as any); + // `readonlyWhen` already true; seeded under `isSystem`. + await engine.insert(OBJECT, { id: 'closed1', title: 'closed', status: 'closed', locked: 'L' } as any, { context: { isSystem: true } } as any); + await engine.insert(CLONE_OBJECT, { id: 'src1', title: 'source', ro: 'seeded' } as any, { context: { isSystem: true } } as any); + await engine.insert(PLAIN_OBJECT, { id: 'plain1', title: 'source' } as any); + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer( + createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, + async () => engine, // objectQLProvider: the cross-object `/batch` door's transaction + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const call = async (path: string, body: unknown, params: Record = { object: OBJECT }): Promise => { + const route = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === path); + expect(route, `POST ${path}`).toBeDefined(); + const res = makeRes(); + await route!.handler({ method: 'POST', params, body, query: {}, headers: {} } as any, res); + return { status: res._status ?? 200, body: res._json, header: res.headers[HEADER] }; + }; + return { engine, call }; +} + +const RO = { object: OBJECT, fields: ['ro'], reason: 'readonly' }; + +describe('clone: the header names what the body reports', () => { + it('overrides carrying a readonly field: 201, body droppedFields, header `ro;reason=readonly`', async () => { + const b = await boot(); + const r = await b.call('/api/v1/data/:object/:id/clone', { overrides: { ro: 'forged', title: 'copy' } }, { object: CLONE_OBJECT, id: 'src1' }); + expect(r.status, JSON.stringify(r.body)).toBe(201); + expect(r.body.droppedFields).toEqual([{ object: CLONE_OBJECT, fields: ['ro'], reason: 'readonly' }]); + expect(r.header).toBe('ro;reason=readonly'); + }); + + it('a clone that strips nothing: no droppedFields, no header (the control)', async () => { + const b = await boot(); + const r = await b.call('/api/v1/data/:object/:id/clone', { overrides: { title: 'plain copy' } }, { object: PLAIN_OBJECT, id: 'plain1' }); + expect(r.status, JSON.stringify(r.body)).toBe(201); + expect(r.body).not.toHaveProperty('droppedFields'); + expect(r.header).toBeUndefined(); + }); +}); + +describe('createMany: one header naming the union of the rows\' strips, each field once', () => { + it('two rows both stripping `ro`: header `ro;reason=readonly`', async () => { + const b = await boot(); + const r = await b.call('/api/v1/data/:object/createMany', [{ id: 'm1', title: 'a', ro: 'x' }, { id: 'm2', title: 'b', ro: 'y' }]); + expect(r.status, JSON.stringify(r.body)).toBe(201); + expect(r.body.droppedFields).toEqual([RO]); + expect(r.header).toBe('ro;reason=readonly'); + }); + + it('only one row strips: the field is still named once', async () => { + const b = await boot(); + const r = await b.call('/api/v1/data/:object/createMany', [{ id: 'm3', title: 'a', ro: 'x' }, { id: 'm4', title: 'b' }]); + expect(r.status, JSON.stringify(r.body)).toBe(201); + expect(r.header).toBe('ro;reason=readonly'); + }); + + it('rows stripping different fields: the header names each one', async () => { + const b = await boot(); + const r = await b.call('/api/v1/data/:object/createMany', [{ id: 'm5', ro: 'x' }, { id: 'm6', ro2: 'y' }]); + expect(r.status, JSON.stringify(r.body)).toBe(201); + expect(r.header).toBe('ro;reason=readonly, ro2;reason=readonly'); + }); + + it('no row strips: no droppedFields, no header (the control)', async () => { + const b = await boot(); + const r = await b.call('/api/v1/data/:object/createMany', [{ id: 'm7', title: 'clean' }]); + expect(r.status, JSON.stringify(r.body)).toBe(201); + expect(r.body).not.toHaveProperty('droppedFields'); + expect(r.header).toBeUndefined(); + }); +}); + +describe('the per-row bulk doors: the header names the union, the body keeps each row\'s report', () => { + const rows = [ + { id: 'open1', data: { title: 'u1', ro: 'x' } }, + { id: 'open2', data: { title: 'u2', ro: 'y' } }, + { id: 'closed1', data: { locked: 'forged' } }, + ]; + + it('updateMany: header names `ro` once and `locked;reason=readonly_when`', async () => { + const b = await boot(); + const r = await b.call('/api/v1/data/:object/updateMany', { records: rows }); + expect(r.status, JSON.stringify(r.body)).toBe(200); + expect(r.body.results.map((x: any) => x.droppedFields)).toEqual([ + [RO], [RO], [{ object: OBJECT, fields: ['locked'], reason: 'readonly_when' }], + ]); + expect(r.header).toBe('ro;reason=readonly, locked;reason=readonly_when'); + }); + + it('batch (operation update): the same union', async () => { + const b = await boot(); + const r = await b.call('/api/v1/data/:object/batch', { operation: 'update', records: rows }); + expect(r.status, JSON.stringify(r.body)).toBe(200); + expect(r.body.results.map((x: any) => x.droppedFields)).toEqual([ + [RO], [RO], [{ object: OBJECT, fields: ['locked'], reason: 'readonly_when' }], + ]); + expect(r.header).toBe('ro;reason=readonly, locked;reason=readonly_when'); + }); + + it('updateMany and batch that strip nothing send no header (the control)', async () => { + const b = await boot(); + const clean = [{ id: 'open1', data: { title: 'clean' } }]; + const many = await b.call('/api/v1/data/:object/updateMany', { records: clean }); + expect(many.status, JSON.stringify(many.body)).toBe(200); + expect(many.header).toBeUndefined(); + const batch = await b.call('/api/v1/data/:object/batch', { operation: 'update', records: clean }); + expect(batch.status, JSON.stringify(batch.body)).toBe(200); + expect(batch.header).toBeUndefined(); + }); + + it('import commit: header names the rows\' union; the dry run writes nothing and sends none', async () => { + const b = await boot(); + const importRows = [{ id: 'i1', title: 'a', ro: 'x' }, { id: 'i2', title: 'b' }, { id: 'i3', title: 'c', ro: 'z' }]; + const dry = await b.call('/api/v1/data/:object/import', { format: 'json', rows: importRows, dryRun: true }); + expect(dry.status, JSON.stringify(dry.body)).toBe(200); + expect(dry.body.results.map((x: any) => x.droppedFields)).toEqual([[RO], undefined, [RO]]); + expect(dry.header).toBeUndefined(); + const commit = await b.call('/api/v1/data/:object/import', { format: 'json', rows: importRows, dryRun: false }); + expect(commit.status, JSON.stringify(commit.body)).toBe(200); + expect(commit.body.results.map((x: any) => x.droppedFields)).toEqual([[RO], undefined, [RO]]); + expect(commit.header).toBe('ro;reason=readonly'); + }); + + it('an import commit that strips nothing sends no header (the control)', async () => { + const b = await boot(); + const r = await b.call('/api/v1/data/:object/import', { format: 'json', rows: [{ id: 'i4', title: 'clean' }], dryRun: false }); + expect(r.status, JSON.stringify(r.body)).toBe(200); + expect(r.header).toBeUndefined(); + }); +}); + +describe('cross-object POST /batch: the header names the ops\' union', () => { + it('two ops stripping `ro`: body keeps one event per op with its index, header names `ro` once', async () => { + const b = await boot(); + const r = await b.call('/api/v1/batch', { + operations: [ + { object: OBJECT, action: 'create', data: { id: 'x1', title: 'a', ro: 'x' } }, + { object: OBJECT, action: 'update', id: 'open1', data: { ro: 'y' } }, + ], + }, {}); + expect(r.status, JSON.stringify(r.body)).toBe(200); + expect(r.body.droppedFields).toEqual([{ ...RO, index: 0 }, { ...RO, index: 1 }]); + expect(r.header).toBe('ro;reason=readonly'); + }); + + it('a batch that strips nothing sends no header (the control)', async () => { + const b = await boot(); + const r = await b.call('/api/v1/batch', { operations: [{ object: OBJECT, action: 'create', data: { id: 'x2', title: 'clean' } }] }, {}); + expect(r.status, JSON.stringify(r.body)).toBe(200); + expect(r.body).not.toHaveProperty('droppedFields'); + expect(r.header).toBeUndefined(); + }); +}); + +describe('the stale-roll-up answer (#28782) of clone and createMany carries the header too', () => { + /** objectql's `SummaryRecomputeError`, as a write's caller reads it: by `code`, carrying `written`. */ + function staleRefusal(written: unknown): Error { + return Object.assign(new Error('Failed to recompute 1 roll-up summary value(s) after write; the triggering records WERE written.'), { + code: 'ERR_SUMMARY_RECOMPUTE', + written, + failures: [{ childObject: OBJECT, parentObject: 'hdr_parent', parentId: 'p1', field: 'probe_sf', error: new Error('sum over text') }], + }); + } + + function restOver(protocolMembers: Record) { + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([]), + ...protocolMembers, + }; + const rest = new RestServer(createMockServer() as any, protocol, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + return async (path: string, body: unknown, params: Record): Promise => { + const route = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === path)!; + const res = makeRes(); + await route.handler({ method: 'POST', params, body, query: {}, headers: {} } as any, res); + return { status: res._status ?? 200, body: res._json, header: res.headers[HEADER] }; + }; + } + + it('clone: 201 with the earned body and its header', async () => { + const answer = { object: OBJECT, id: 'c1', sourceId: 'open1', record: { id: 'c1' }, droppedFields: [RO] }; + const call = restOver({ cloneData: vi.fn(async () => { throw withEarnedAnswer(staleRefusal(answer.record) as any, answer); }) }); + const r = await call('/api/v1/data/:object/:id/clone', { overrides: { ro: 'x' } }, { object: OBJECT, id: 'open1' }); + expect(r.status).toBe(201); + expect(r.body).toEqual(answer); + expect(r.header).toBe('ro;reason=readonly'); + }); + + it('createMany: 201 with the earned body and its header', async () => { + const answer = { object: OBJECT, records: [{ id: 'm1' }, { id: 'm2' }], count: 2, droppedFields: [RO] }; + const call = restOver({ createManyData: vi.fn(async () => { throw withEarnedAnswer(staleRefusal(answer.records) as any, answer); }) }); + const r = await call('/api/v1/data/:object/createMany', [{ ro: 'x' }, { ro: 'y' }], { object: OBJECT }); + expect(r.status).toBe(201); + expect(r.body).toEqual(answer); + expect(r.header).toBe('ro;reason=readonly'); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index a8d145c65f2..c69b4ad7d9b 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -430,7 +430,8 @@ import { thrownAnswerIsBareNotFound, // `logUnexpectedRouteError` and `applyDroppedFieldsHeader`, read only by the CRUD doors' catches // and their two write answers, moved with `registerCrudEndpoints`' body to `crud-routes.ts` - // (#23050 slice 12). + // (#23050 slice 12); the header is now also set by the batch, createMany, updateMany, clone + // and import doors, in their own modules. // `isExpectedRouteError`, read only by the public form submit door's catch, moved with // `registerFormEndpoints`' body to `form-routes.ts` (#23050 slice 11). } from './error-response.js'; diff --git a/packages/rest/src/stale-summary-answer.ts b/packages/rest/src/stale-summary-answer.ts index ea7bb68688d..d13442c8525 100644 --- a/packages/rest/src/stale-summary-answer.ts +++ b/packages/rest/src/stale-summary-answer.ts @@ -35,7 +35,8 @@ export function earnedWriteDespiteStaleSummary(error: unknown, res: any, object: /** * [#28782] The same reading for a door whose success path sets no - * dropped-fields header (public form submit, `createMany`, clone): the answer + * dropped-fields header (public form submit, whose answer carries no + * `droppedFields`): the answer * the write `error` earned, logged once per stale field; `undefined` for every * other error. The door relays it as its own success path relays the * protocol's result. diff --git a/packages/spec/src/api/batch.zod.ts b/packages/spec/src/api/batch.zod.ts index 351b77b84a1..3bf06f3cdab 100644 --- a/packages/spec/src/api/batch.zod.ts +++ b/packages/spec/src/api/batch.zod.ts @@ -220,8 +220,9 @@ export const BatchOperationResultSchema = lazySchema(() => z.object({ ' 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. ' + + '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.' ), })); diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 5a069f4ff14..59c1daeffb6 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -2854,8 +2854,8 @@ export const CloneDataResponseSchema = lazySchema(() => z.object({ + '`engine.insert`, after the `beforeInsert` hooks, `isSystem`-gated, exactly as on ' + '`createData`), so those keys are dropped and the field re-derives its default. ' + 'Present ONLY when ≥1 field was dropped; the clone still succeeded without them ' - + '(status/success semantics unchanged). Carried in the 201 body only — this route ' - + 'relays the producer verbatim and sets no `X-ObjectStack-Dropped-Fields` header. ' + + '(status/success semantics unchanged). REST additionally surfaces this as the ' + + '`X-ObjectStack-Dropped-Fields` response header. ' + 'Optional — omit-when-empty keeps the shape backward-compatible for existing clients.' ), warnings: z.array(ValidateDataIssueSchema).optional().describe(WRITE_ANSWER_WARNINGS_DESCRIPTION), @@ -3228,8 +3228,9 @@ export const CreateManyDataResponseSchema = lazySchema(() => z.object({ '`engine.insert` after the `beforeInsert` hooks and exempts keys a hook itself wrote, ' + 'tracked per row: rows where a hook stamped a protected key drop a different set from ' + 'rows where it did not. Present ONLY when ≥1 field was dropped; the creates still succeeded ' + - 'without them (count/success unchanged). Optional — omit-when-empty keeps the shape ' + - 'backward-compatible. (The same reading applies to the partial-success bulk create, whose ' + + 'without them (count/success unchanged). REST additionally surfaces this union as the ' + + '`X-ObjectStack-Dropped-Fields` response header, each field and reason once. Optional — ' + + 'omit-when-empty keeps the shape backward-compatible. (The same reading applies to the partial-success bulk create, whose ' + 'batch-level `droppedFields` names no row for the same reason. The paths that DO carry ' + 'per-row `droppedFields` on each result are the bulk UPDATE and the mixed batch, where each ' + 'row is its own engine call — see BatchOperationResultSchema.)'