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
15 changes: 15 additions & 0 deletions .changeset/clone-data-dropped-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/spec': minor
'@objectstack/metadata-protocol': minor
'@objectstack/client': minor
---

`cloneData` reports `droppedFields` like every other create face: `CloneDataResponseSchema` (`@objectstack/spec/api`) gains an optional `droppedFields` member of the same shape as `CreateDataResponseSchema`'s, and the `POST /data/:object/:id/clone` 201 body carries it whenever the engine stripped a static `readonly` column from the clone.

A clone IS a create, and it is the one create shape that can carry a read-only column without the caller typing it: the source row is copied whole (`approval_status: 'approved'` included), `overrides` are applied on top, and the copy is inserted. Since the create-side strip moved into `engine.insert` that column has been stripped and logged at `warn` — but the 201 body said nothing, so a caller that cloned an approved record and read `record.approval_status: 'draft'` back had no field in the response telling it why, while `createData`, `createManyData`, `insertManyData` and every `batchData` row that created already answered on the wire. Maintainer ruling 2026-09-08 (option 1 on #15703): report it, the same way.

- **`@objectstack/spec`** — `CloneDataResponseSchema.droppedFields`: `DroppedFieldsEvent[]`, optional, omit-when-empty — present ONLY when ≥1 field was dropped, and the clone still succeeded without them (status unchanged). The schema is declared AS PRODUCED, so the member and the producer land in one change. Additive: a client that reads only `object` / `id` / `sourceId` / `record` sees no difference.
- **`@objectstack/metadata-protocol`** — `cloneData` passes the engine the same `onFieldsDropped` listener `createData` wires and spreads the collected events onto its return as `droppedFields`. The strip itself is unchanged and still the engine's (`isSystem`-gated, `defaultValue` re-derived); what is new is that a copied-in or overridden readonly key is now named in the body instead of only in the server log.
- **`@objectstack/client`** — `CloneDataResult` (the declared mirror of `CloneDataResponseSchema`, the return type of `client.data.clone`) gains the same optional `droppedFields?: DroppedFieldsEvent[]`, so a TypeScript caller reads the member without a cast; its docblock no longer states that the clone producer emits no write-observability event.

Body only, deliberately: the clone route relays the producer verbatim and sets no `X-ObjectStack-Dropped-Fields` header (the single-record `POST /data/:object` and `PATCH /data/:object/:id` mounts do); the schema's `.describe()` says so rather than promising a header the route does not send.
8 changes: 7 additions & 1 deletion content/docs/api/data-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,13 @@ Gated by the object's `enable.clone` capability (default `true`); an object with
top of the copied values (a bare field map is also accepted). The natural place
to set a new name or clear a unique field.

**Response** `201`: `{ object, id, sourceId, record }`
**Response** `201`: `{ object, id, sourceId, record, droppedFields? }` — the
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.

---

Expand Down
11 changes: 11 additions & 0 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,17 @@ 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<string, any>` | ✅ | 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'> }[]` | 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. |

### Nested Shape: `CloneDataResponse.droppedFields[number]`

A write-path strip event: caller-supplied fields legally dropped from the payload

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **object** | `string` | ✅ | Object the write targeted (resolved object name) |
| **fields** | `string[]` | ✅ | Caller-supplied field names the engine removed from the write payload |
| **reason** | `Enum<'readonly' \| 'readonly_when' \| 'primary_key'>` | ✅ | Why the fields were dropped: static readonly, a TRUE readonlyWhen predicate, or the primary-key strip of a payload id the engine ruled is not an identifier |


---
Expand Down
14 changes: 12 additions & 2 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,14 +430,24 @@ export interface CreateDataResult<T = any> {
* Spec: CloneDataResponseSchema (#11924)
*
* `CreateDataResult`'s structural sibling plus `sourceId` — `id` names the NEW
* record, `sourceId` the record it was copied from. No `droppedFields`: unlike
* `createData`, the clone producer emits no write-observability event.
* record, `sourceId` the record it was copied from. Since #15703 it carries
* `droppedFields` too: the clone producer reports the engine's readonly-strip
* verdict exactly as `createData` does.
*/
export interface CloneDataResult<T = any> {
object: string;
id: string;
sourceId: string;
record: T;
/**
* [#15703] Fields the server 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`, 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.
*/
droppedFields?: DroppedFieldsEvent[];
}

/** Spec: UpdateDataResponseSchema */
Expand Down
57 changes: 36 additions & 21 deletions packages/metadata-protocol/src/protocol.readonly-insert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@
// `batchData`'s `create` rows AND both arms of `upsert` that create;
// - every create face whose RESPONSE carries `droppedFields` surfaces the
// ENGINE's `onFieldsDropped` there, which is the channel the ingress used
// to FAKE with a before/after payload diff. `cloneData` is the one face
// that does not: `CloneDataResponseSchema` declares no such member (pinned
// in the firing-control block at the bottom).
// to FAKE with a before/after payload diff. Since #15703 that is every one
// of the six: `cloneData` was the face that did not — its contract
// (`CloneDataResponseSchema`, declared AS PRODUCED) had no such member —
// until the member and the listener landed together (maintainer ruling
// 2026-09-08, option 1); the firing-control block at the bottom enumerates it.
// The enforcement itself is pinned where it now runs, against a real engine:
// `packages/objectql/src/engine-insert-static-readonly-strip.test.ts`. This
// package does not depend on `@objectstack/objectql`, so a strip assertion here
Expand Down Expand Up @@ -131,9 +133,9 @@ describe('#14147 — the create ingress DELEGATES the readonly strip to engine.i
expect(inserts[0].options.context).toEqual({ isSystem: true });
});

it('cloneData forwards the copied row AND the caller overrides whole', async () => {
it('cloneData forwards the copied row AND the caller overrides whole — and reports the engine’s verdict on both', async () => {
const { p, inserts } = makeProtocol();
await p.cloneData({
const res: any = await p.cloneData({
object: 'approval_case',
id: 'src-1',
overrides: { source: 'forged' },
Expand All @@ -143,6 +145,15 @@ describe('#14147 — the create ingress DELEGATES the readonly strip to engine.i
// through them is still the engine's to strip (#3043's carried-over case).
expect(inserts[0].data.source).toBe('forged');
expect(inserts[0].data.approval_status, 'the copied readonly column travels too').toBe('approved');
// [#15703] ...and the 201 body says what the engine dropped — the column
// the caller never typed (copied from the source) and the one it forged
// through `overrides`, in the engine's one event. Until #15703 the clone
// stripped and warned but reported nothing on the wire.
expect(res.droppedFields).toEqual([
{ object: 'approval_case', fields: ['approval_status', 'source'], reason: 'readonly' },
]);
expect(res.record).not.toHaveProperty('approval_status');
expect(res.record).not.toHaveProperty('source');
});

it('createManyData forwards every row whole and AGGREGATES the engine’s event', async () => {
Expand Down Expand Up @@ -223,13 +234,15 @@ describe('#14147 — the create ingress DELEGATES the readonly strip to engine.i

describe('#14147 — engine listener wiring (the firing control for every assertion above)', () => {
// The faces enumerated here are the ones whose RESPONSE carries
// `droppedFields`: `CreateDataResponse`, `CreateManyDataResponse`, and the
// per-row results of `insertManyData` / `batchData`. `cloneData` is
// deliberately NOT among them — its contract has no such member; the case
// after this one pins that exclusion so "every" stays true of what is listed.
// `droppedFields`: `CreateDataResponse`, `CloneDataResponse` (since #15703),
// `CreateManyDataResponse`, and the per-row results of `insertManyData` /
// `batchData`. That is every create face; the case after this one pins the
// clone by name so the enumeration cannot silently lose the face that was
// the exclusion until its contract gained the member.
it('every create face whose response carries droppedFields passes an onFieldsDropped listener to the engine', async () => {
const { p, inserts } = makeProtocol();
await p.createData({ object: 'approval_case', data: { title: 'A' } });
await p.cloneData({ object: 'approval_case', id: 'src-1' } as any);
await p.createManyData({ object: 'approval_case', records: [{ title: 'A' }] });
await p.batchData({
object: 'approval_case',
Expand All @@ -244,26 +257,28 @@ describe('#14147 — engine listener wiring (the firing control for every assert
request: { operation: 'upsert', records: [{ id: 'new-1', data: { title: 'A' } }] },
} as any);
await p.insertManyData({ object: 'approval_case', records: [{ title: 'A' }] });
expect(inserts, 'createData · createManyData · batchData create · batchData upsert-create ×2 (no id / unknown id) · insertManyData')
.toHaveLength(6);
expect(inserts, 'createData · cloneData · createManyData · batchData create · batchData upsert-create ×2 (no id / unknown id) · insertManyData')
.toHaveLength(7);
for (const call of inserts) {
expect(typeof call.options?.onFieldsDropped, 'a face with no listener reports a silent drop').toBe('function');
}
});

it('cloneData is the one create face that passes NO listener — its response contract declares no droppedFields', async () => {
// `CloneDataResponseSchema` (#11924, declared AS PRODUCED) is exactly
// `{ object, id, sourceId, record }`; `search-clone-schema-conformance.test.ts`
// holds the producer to that key set and asserts `droppedFields` in
// particular is absent. So a listener here would have nowhere contracted
// to report to. The engine still strips a copied-over or overridden
// readonly column and still logs the `warn` line — the clone simply does
// not carry the event on the wire. Reporting it means a new response key,
// which is a spec change with its own card, not a delegation detail.
it('cloneData passes the listener too — the sixth face, now that its response contract declares droppedFields (#15703)', async () => {
// Until #15703 this case pinned the ABSENCE of the listener, with its
// reason: `CloneDataResponseSchema` (#11924, declared AS PRODUCED) was
// exactly `{ object, id, sourceId, record }`, so a listener had nowhere
// contracted to report to, and the engine's strip of a copied-over or
// overridden readonly column reached only the `warn` log. The maintainer
// ruling of 2026-09-08 (option 1) added the optional member and this
// listener in one change — the schema stays declared as produced — and
// `search-clone-schema-conformance.test.ts` now measures the produced
// member on the wire. This case pins the presence by name, so the
// enumeration above cannot drop the clone without a red here.
const { p, inserts } = makeProtocol();
await p.cloneData({ object: 'approval_case', id: 'src-1' } as any);
expect(inserts).toHaveLength(1);
expect(inserts[0].options?.onFieldsDropped).toBeUndefined();
expect(typeof inserts[0].options?.onFieldsDropped, 'a clone with no listener reports a silent drop').toBe('function');
});

it('a create that drops NOTHING reports no droppedFields at all', async () => {
Expand Down
14 changes: 13 additions & 1 deletion packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10891,7 +10891,18 @@ export class ObjectStackProtocolImplementation implements
// goes over whole and the insert re-derives the field's `defaultValue`,
// symmetric with createData. `overrides` are applied ABOVE this line, so
// a readonly key smuggled through them is still judged by the strip.
const result = await this.engine.insert(request.object, data, ctxOpt as any);
//
// [#15703] And the verdict is REPORTED, the same listener `createData`
// wires: a clone is the one create shape that carries a read-only column
// without the caller typing it (the source's `approval_status` travels in
// the copy), so the 201 body says which keys the engine dropped instead of
// leaving the caller to diff `record` against the source. Maintainer
// ruling 2026-09-08 (option 1); `CloneDataResponseSchema` declares the
// member in the same change, because that schema is declared AS PRODUCED.
const dropped: DroppedFieldsEvent[] = [];
const opts: any = { onFieldsDropped: (e: DroppedFieldsEvent) => { dropped.push(e); } };
if (ctx !== undefined) opts.context = ctx;
const result = await this.engine.insert(request.object, data, opts);
// [#7823] Same ingress strip as `createData` — a clone's 201 body is
// the same generic-data-path surface. (The SOURCE row was read through
// the engine's find path, which already omits internal fields, so the
Expand All @@ -10903,6 +10914,7 @@ export class ObjectStackProtocolImplementation implements
id: result.id,
sourceId: request.id,
record: result,
...(dropped.length > 0 ? { droppedFields: dropped } : {}),
};
}

Expand Down
Loading
Loading