Skip to content

Commit ada2869

Browse files
os-muskclaude
andauthored
fix(metadata-protocol): insertManyData reports the dropped-field union at BATCH level instead of naming rows it cannot identify (#17688)
* wip: insertManyData reports droppedFields at batch level Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM * wip: changeset * wip: adr-0087 disposition * wip: regenerate protocol reference docs --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e7ff9c2 commit ada2869

8 files changed

Lines changed: 289 additions & 73 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/objectql': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
fix(metadata-protocol): `insertManyData` reports the dropped-field union at BATCH level instead of naming rows it cannot identify (#17290)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) nothing authored or stored moves: no authorable key, no Zod schema and no stored `sys_metadata` shape changes — `packages/spec` declares no response schema for this face at all, so `objectstack migrate meta` has nothing to visit, `spec-changes.json` has nothing to project and the upgrade guide gains no row. What moves is one optional member on an inline TypeScript response type of a runtime protocol method, and the channel that reaches every affected consumer is the compiler at their own call site, which names the site more precisely than a ledger line could. The `packages/spec` file in this diff is a `.describe()` STRING — customer-facing prose that this change would otherwise leave false — not a schema, a key or an accept set; nothing it declares moves. -->
10+
11+
**BREAKING**`@objectstack/metadata-protocol`'s `insertManyData` no longer hangs
12+
`droppedFields` on each entry of `outcomes`; the response itself carries it, beside
13+
`outcomes`, exactly as `createManyData` already does. A TypeScript consumer that read
14+
the per-row member stops compiling, and the compiler names the site. The set reported
15+
is the same set — what is gone is a per-row attribution that could not be computed
16+
here and was wrong whenever it mattered. Nothing authored or stored changes shape.
17+
18+
**What it got wrong.** Every create-side strip is the engine's, and its
19+
`onFieldsDropped` event is the UNION over the batch — the listener signature
20+
carries no row index. This seam reconstructed a row set from that union by
21+
asking which rows SUPPLIED each dropped name
22+
(`[...engineDropped].filter((f) => f in supplied)`), on the stated premise that
23+
"the strip only removes keys the ROW ITSELF supplied, so a dropped name belongs
24+
to exactly the rows whose supplied payload carried it". Maintainer ruling C
25+
falsifies the premise: the static-`readonly` strip runs INSIDE `engine.insert`,
26+
AFTER the `beforeInsert` hooks, and exempts keys a hook itself assigned —
27+
recorded per row (`hookWrittenKeys: rowHookWrittenKeys[i]`). So in a batch where
28+
a hook stamps a protected key on some rows and not others:
29+
30+
- row A supplied `approval_status`, no hook write ⇒ stripped, enters the union;
31+
- row B supplied `approval_status`, its hook re-assigned it ⇒ **kept and
32+
written**;
33+
- and row B's outcome carried `droppedFields: [{ fields: ['approval_status'] }]`
34+
on a record that still held `approval_status`.
35+
36+
A row the batch culled before the strip ran (a per-row validation failure) was
37+
named on the same test, having dropped nothing at all.
38+
39+
⇒ A wrong attribution costs the reader a wrong investigation, and the import
40+
surface — which prefers this path over `createManyData` — is the consumer most
41+
likely to act on it while reconciling what landed.
42+
43+
**Why not attribute per row instead.** The honest set is `{rows whose payload
44+
carried N}` minus `{rows whose beforeInsert hook assigned N}`, and the second
45+
half is computed per row upstream but does not cross this seam. The outcome's
46+
own `record` cannot stand in for it: a stripped `readonly` field is RE-DEFAULTED
47+
over exactly the keys the strip took, and a stripped `autonumber` is refilled by
48+
`applyAutonumbers` — so on both, the key is PRESENT on the row that really did
49+
drop it, and a post-hoc "is the key still there?" check would delete true
50+
attributions while leaving the hook-exempt false one standing. Comparing values
51+
fails on the very case `hookWrittenKeys` exists for: the hook assigning the
52+
value the caller also sent. Restoring row precision means giving the engine's
53+
drop report a per-row channel, not a reconstruction at the call site.
54+
55+
**Prose corrected with it**, by CLAIM rather than by spelling — the docblock
56+
that authorised the inference is the thing that re-authorises the next author:
57+
`insertManyData`'s own docblock and `createManyData`'s parenthetical
58+
(`@objectstack/metadata-protocol`), `mergeDroppedFieldEvents`'s closing
59+
sentence, `engine.insertMany`'s docblock claim that "a caller holding the input
60+
rows can attribute each name back to the rows that carried it"
61+
(`@objectstack/objectql`, TSDoc emitted into its published `.d.ts`), and
62+
`CreateManyDataResponseSchema.droppedFields`'s `.describe()` parenthetical
63+
(`@objectstack/spec`, a string printed AT the customer).
64+
65+
**Unchanged.** `updateManyData` and `batchData` keep per-row `droppedFields`,
66+
and they always could: each row is its own `engine.update` / `engine.insert`
67+
call, so that call's events are that row's — earned mechanically, not inferred.
68+
`createManyData`'s aggregated shape is untouched. No strip changes, no row
69+
changes, and the same field names are reported.

content/docs/references/api/protocol.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -570,7 +570,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa
570570
| **object** | `string` || Object name |
571571
| **records** | `Record<string, any>[]` || Created records |
572572
| **count** | `number` || Number of records created |
573-
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | 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 per-row `insertMany`/`batch` paths carry per-row `droppedFields` on each result instead — see BatchOperationResultSchema.) |
573+
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key'> }[]` | 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.) |
574574

575575
### Nested Shape: `CreateManyDataResponse.droppedFields[number]`
576576

packages/metadata-protocol/src/protocol.dropped-fields.bulk.test.ts

Lines changed: 104 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,16 @@
77
// single-write now reports, and (b) thread the caller's execution `context` to
88
// the engine so RLS/FLS/`readonlyWhen` run under the caller — a gap the
99
// pre-#3455 `updateManyData`/`batchData` loops had. Channels:
10-
// - updateManyData / batchData → per-row `droppedFields` on each result row;
11-
// - insertManyData → per-row `droppedFields` on each outcome;
12-
// - createManyData → aggregated top-level `droppedFields` (its
13-
// response has no per-row slot, so a union is the only view it can
14-
// represent; read a name there as "at least one row dropped this field",
15-
// never "every row dropped the same set" — ruling C (#14147) exempts keys a
16-
// `beforeInsert` hook assigned, recorded per row, so rows CAN differ).
10+
// - updateManyData / batchData → per-row `droppedFields` on each result row,
11+
// earned mechanically: one engine call per row, so that call's events are
12+
// that row's;
13+
// - createManyData / insertManyData → aggregated top-level `droppedFields`.
14+
// `createManyData`'s response has no per-row slot, so a union is the only
15+
// view it can represent. `insertManyData` HAS one (`outcomes[i]`) and still
16+
// reports at the top level, because ruling C (#14147) exempts keys a
17+
// `beforeInsert` hook assigned — recorded per row — so rows CAN differ and
18+
// the union cannot be resolved back to rows. Read a name in either as "at
19+
// least one row dropped this field", never "this row dropped it".
1720

1821
import { describe, it, expect, vi } from 'vitest';
1922
import { assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core';
@@ -130,22 +133,48 @@ describe('createManyData — aggregated top-level droppedFields (#3455)', () =>
130133
});
131134
});
132135

133-
describe('insertManyData — per-row droppedFields on outcomes (#3455)', () => {
134-
it('attaches the create strip to the matching outcome row only', async () => {
135-
// [#14147] The engine's listener carries no row index — it reports the
136-
// batch UNION — so row precision here is recovered by asking which row
137-
// SUPPLIED each dropped name. That recovery is what this case pins.
138-
const insertMany = vi.fn(async (object: string, rows: any[], options?: any) => {
139-
if (rows.some((r) => r && 'approval_status' in r)) {
136+
describe('insertManyData — BATCH-LEVEL droppedFields that names no row (#3455)', () => {
137+
// This block used to pin the opposite: a per-row `droppedFields` on each
138+
// outcome, reconstructed from the batch union by asking which row SUPPLIED
139+
// each dropped name. Ruling C (#14147) falsifies that reconstruction — the
140+
// strip runs after `beforeInsert` and exempts keys a hook assigned, per row —
141+
// so the cases are REPLACED rather than amended. The engine double below
142+
// models the exemption, which the old one had no concept of; that is why the
143+
// old case stayed green through exactly the shape it was written for.
144+
145+
/**
146+
* `hookStamps` names the rows whose `beforeInsert` hook re-assigns
147+
* `approval_status`. Those rows KEEP it (ruling C); the others are stripped.
148+
* Either way the engine reports ONE event, the union over the batch, with no
149+
* row index — which is the real `engine.insert` contract this stands in for.
150+
*/
151+
function makeInsertMany(hookStamps: ReadonlySet<number> = new Set(), dead: ReadonlySet<number> = new Set()) {
152+
return vi.fn(async (object: string, rows: any[], options?: any) => {
153+
const strippedAny = rows.some((r, i) => r && 'approval_status' in r && !hookStamps.has(i) && !dead.has(i));
154+
if (strippedAny) {
140155
options?.onFieldsDropped?.({ object, fields: ['approval_status'], reason: 'readonly' });
141156
}
142157
return rows.map((r, i) => {
143-
const { approval_status: _forged, ...kept } = r ?? {};
144-
return { ok: true, record: { id: `rec-${i + 1}`, ...kept } };
158+
if (dead.has(i)) return { ok: false, error: { code: 'VALIDATION_FAILED' } };
159+
const { approval_status: forged, ...kept } = r ?? {};
160+
// A stripped `readonly` field falls back to its `defaultValue` (#3043),
161+
// so the key is present on the row that DID lose the caller's value —
162+
// the reason a post-hoc check against `outcomes[i].record` cannot
163+
// recover row precision either.
164+
const value = hookStamps.has(i) ? forged : SCHEMA.fields.approval_status.defaultValue;
165+
return { ok: true, record: { id: `rec-${i + 1}`, ...kept, approval_status: value } };
145166
});
146167
});
168+
}
169+
170+
function makeProtocol(insertMany: ReturnType<typeof makeInsertMany>) {
147171
const engine = { registry: { getObject: () => SCHEMA }, insertMany };
148-
const p = new ObjectStackProtocolImplementation(engine as any);
172+
return new ObjectStackProtocolImplementation(engine as any);
173+
}
174+
175+
it('surfaces the batch union on the response and hangs nothing on any outcome', async () => {
176+
const insertMany = makeInsertMany();
177+
const p = makeProtocol(insertMany);
149178

150179
const res: any = await p.insertManyData({
151180
object: 'approval_case',
@@ -156,15 +185,71 @@ describe('insertManyData — per-row droppedFields on outcomes (#3455)', () => {
156185
context: { userId: 'u1' },
157186
});
158187

159-
expect(res.outcomes[0]).not.toHaveProperty('droppedFields');
160-
expect(res.outcomes[1].droppedFields).toEqual([
188+
expect(res.droppedFields).toEqual([
161189
{ object: 'approval_case', fields: ['approval_status'], reason: 'readonly' },
162190
]);
191+
for (const o of res.outcomes) expect(o).not.toHaveProperty('droppedFields');
163192
// [#14147] The ingress hands the caller's row over WHOLE — judging it is
164193
// the engine's job now, and this assertion is what would catch a
165194
// reintroduced second strip at this seam.
166195
expect(insertMany.mock.calls[0][1][1]).toHaveProperty('approval_status');
167196
});
197+
198+
it('a hook exempts one row of the batch: the row that KEPT the value is not named', async () => {
199+
// Row 0 and row 1 both forge `approval_status`. Row 1's `beforeInsert` hook
200+
// re-assigns it, so ruling C keeps row 1's value and only row 0 is
201+
// stripped. `f in supplied` — the reconstruction this response shape
202+
// replaces — is true for BOTH, so it reported a dropped field on an outcome
203+
// whose record carries the value that was written.
204+
const p = makeProtocol(makeInsertMany(new Set([1])));
205+
206+
const res: any = await p.insertManyData({
207+
object: 'approval_case',
208+
records: [
209+
{ title: 'A', approval_status: 'approved' },
210+
{ title: 'B', approval_status: 'approved' },
211+
],
212+
context: { userId: 'u1' },
213+
});
214+
215+
expect(res.outcomes[1].record.approval_status, 'the hook wrote it — ruling C keeps it').toBe('approved');
216+
expect(res.outcomes[1], 'a written value must never be reported as dropped').not.toHaveProperty('droppedFields');
217+
expect(res.outcomes[0], 'and the stripped row is not named either — the set is batch-level')
218+
.not.toHaveProperty('droppedFields');
219+
expect(res.droppedFields).toEqual([
220+
{ object: 'approval_case', fields: ['approval_status'], reason: 'readonly' },
221+
]);
222+
});
223+
224+
it('a row the batch culled supplied the name and is still not named', async () => {
225+
// The engine's strip loop skips a row that already failed, so a dead row
226+
// dropped nothing — but it supplied the key, which was enough for the
227+
// reconstruction to name it on an outcome that carries no record at all.
228+
const p = makeProtocol(makeInsertMany(new Set(), new Set([0])));
229+
230+
const res: any = await p.insertManyData({
231+
object: 'approval_case',
232+
records: [
233+
{ title: 'A', approval_status: 'approved' },
234+
{ title: 'B', approval_status: 'approved' },
235+
],
236+
});
237+
238+
expect(res.outcomes[0].ok).toBe(false);
239+
expect(res.outcomes[0]).not.toHaveProperty('droppedFields');
240+
expect(res.droppedFields).toEqual([
241+
{ object: 'approval_case', fields: ['approval_status'], reason: 'readonly' },
242+
]);
243+
});
244+
245+
it('nothing dropped ⇒ the key is absent, keeping the omit-when-empty shape', async () => {
246+
const p = makeProtocol(makeInsertMany());
247+
const res: any = await p.insertManyData({
248+
object: 'approval_case',
249+
records: [{ title: 'A' }],
250+
});
251+
expect(res).not.toHaveProperty('droppedFields');
252+
});
168253
});
169254

170255
describe('batchData — per-row droppedFields + context threading (#3455)', () => {

packages/metadata-protocol/src/protocol.readonly-insert.test.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -216,27 +216,32 @@ describe('#14147 — the create ingress DELEGATES the readonly strip to engine.i
216216
]);
217217
});
218218

219-
it('insertManyData forwards every row whole and keeps ROW precision from the union', async () => {
219+
it('insertManyData forwards every row whole and reports the union at BATCH level', async () => {
220220
const { p, inserts } = makeProtocol();
221221
const res: any = await p.insertManyData({
222222
object: 'approval_case',
223223
records: [{ title: 'A', approval_status: 'approved' }, { title: 'B' }],
224224
});
225225
expect(inserts[0].data).toEqual([{ title: 'A', approval_status: 'approved' }, { title: 'B' }]);
226-
// The engine's event is the batch UNION (its listener carries no row
227-
// index); row precision is recovered by asking which row SUPPLIED the key.
228-
expect(res.outcomes[0].droppedFields).toEqual([
226+
// The engine's event is the batch UNION and its listener carries no row
227+
// index. It used to be resolved back to rows by asking which row SUPPLIED
228+
// each name; ruling C (#14147) exempts keys a `beforeInsert` hook assigned,
229+
// per row, so that is a different set. The union is reported where it is
230+
// true — on the response — and no outcome is named.
231+
expect(res.droppedFields).toEqual([
229232
{ object: 'approval_case', fields: ['approval_status'], reason: 'readonly' },
230233
]);
231-
expect(res.outcomes[1].droppedFields, 'row B supplied none of the dropped names').toBeUndefined();
234+
for (const o of res.outcomes) {
235+
expect(o, 'the union names no row').not.toHaveProperty('droppedFields');
236+
}
232237
});
233238
});
234239

235240
describe('#14147 — engine listener wiring (the firing control for every assertion above)', () => {
236241
// The faces enumerated here are the ones whose RESPONSE carries
237242
// `droppedFields`: `CreateDataResponse`, `CloneDataResponse` (since #15703),
238-
// `CreateManyDataResponse`, and the per-row results of `insertManyData` /
239-
// `batchData`. That is every create face; the case after this one pins the
243+
// `CreateManyDataResponse`, `insertManyData`'s batch-level key, and the
244+
// per-row results of `batchData`. That is every create face; the case after this one pins the
240245
// clone by name so the enumeration cannot silently lose the face that was
241246
// the exclusion until its contract gained the member.
242247
it('every create face whose response carries droppedFields passes an onFieldsDropped listener to the engine', async () => {

0 commit comments

Comments
 (0)