Skip to content

Commit ec74646

Browse files
os-zhuangclaude
andauthored
fix(metadata-protocol): gate a batch row's errors[].message on a declared client refusal (#8569)
* wip(#8502): withhold undeclared driver text from batch row errors[].message * fix(metadata-protocol): gate batch row errors[].message on a declared client refusal (#8502) * test(objectql): re-spell the batch-atomic driver-text pin for the #8502 withhold * test: type the engine query options and drop a dead helper (#8502) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2342ee4 commit ec74646

11 files changed

Lines changed: 1011 additions & 22 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
Withhold undeclared driver text from a bulk write's per-row `errors[].message` (#8502)
6+
7+
`toRowApiError` interpolated whatever it caught into a batch row's message, so a
8+
driver fault under `deleteManyData` answered
9+
`{ code: "INTERNAL_ERROR", message: "SQLITE_ERROR: no such table: leave_request" }`
10+
on response DATA riding a 200 — where no HTTP boundary's 5xx withhold can reach
11+
it. Driven against a real driver the leaked text is worse than the tidy example:
12+
a delete's raw message carries the failing statement's `WHERE` clause and its
13+
bound record id, and a create's carries the whole `INSERT` with its values. The
14+
causal row's message is also copied onto every `NOT_ATTEMPTED` / `ROLLED_BACK`
15+
sibling, so one leaked sentence was repeated across the batch.
16+
17+
A caught sentence now reaches a caller only when its producer declared a
18+
client-facing refusal, asked through `resolveThrownHttpError` — the same
19+
resolver the HTTP doors answer with — so all three declarations this sink
20+
actually receives are honoured: a 4xx `status`, a 4xx `statusCode`, and the
21+
`VALIDATION_FAILED` shape that carries neither. Per-field authoring feedback
22+
from the engine's validator, `RECORD_NOT_FOUND`, `VALIDATION_FAILED` and
23+
`plugin-approvals`' `RECORD_LOCKED` are unchanged, byte for byte. Anything
24+
undeclared — a driver fault, or a hook that throws a bare `Error` — gets a
25+
stable sentence naming the operation, and the original goes to the server log.
26+
27+
The `code` limb is untouched (#8441 already gates it on catalog membership), and
28+
no `httpStatus` is minted where the wire did not carry one.
29+
30+
**Behaviour change for hook authors**: a hook that refuses by throwing an
31+
undeclared `Error` no longer has its sentence echoed on the row. Declare the
32+
refusal — a 4xx `status` or `statusCode`, or `validationFailure(message, fields)`
33+
from `@objectstack/types` — and the message is served verbatim, as it now is on
34+
the single-record path.

packages/metadata-protocol/src/protocol.batch-atomic.test.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,15 @@ describe('batchData atomic — rollback is real and the response admits it (ADR-
104104
// "Attempted, undone" vs "never ran" is a CODE, not a message-prefix
105105
// regex (#4793) — the message keeps the human-readable cause.
106106
expect(res.results[0].errors?.[0]?.code).toBe('ROLLED_BACK');
107-
expect(res.results[0].errors?.[0]?.message).toContain('insert exploded'); // carries the cause
108-
expect(res.results[1].errors?.[0]?.message).toBe('insert exploded'); // the causal row, verbatim
107+
// [#8502] `insert exploded` is a BARE `Error` — it declares no client
108+
// refusal, so its sentence is withheld and the row says the stable
109+
// operation-named line instead. The claim under test is unchanged and
110+
// is about PROPAGATION: whatever the causal row says, the rolled-back
111+
// row quotes it, so a caller reading row 0 learns why row 1 stopped
112+
// the batch. Asserted against the causal row's own message rather than
113+
// a literal, so the two cannot drift apart.
114+
expect(res.results[1].errors?.[0]?.message).toBe('The create of this record failed. The reason is in the server log.');
115+
expect(res.results[0].errors?.[0]?.message).toContain(res.results[1].errors?.[0]?.message); // carries the cause
109116
expect(res.results[2].errors?.[0]?.code).toBe('NOT_ATTEMPTED');
110117
// Rows correlate to the request array by `index` (#4793).
111118
expect(res.results.map((r: any) => r.index)).toEqual([0, 1, 2]);
@@ -162,7 +169,8 @@ describe('batchData atomic — rollback is real and the response admits it (ADR-
162169
expect(res.succeeded).toBe(0);
163170
expect(res.results[0].errors?.[0]?.code).toBe('ROLLED_BACK');
164171
expect(res.results[0].id).toBe('rec-1'); // ids survive so a caller can reconcile
165-
expect(res.results[1].errors?.[0]?.message).toBe('update exploded');
172+
// [#8502] withheld: a bare `Error` declares no client refusal.
173+
expect(res.results[1].errors?.[0]?.message).toBe('The update of this record failed. The reason is in the server log.');
166174
});
167175
});
168176

@@ -236,7 +244,13 @@ describe('batchData atomic — precedence and opt-in (ADR-0119 D4)', () => {
236244

237245
expect(t.rollbacks).toHaveLength(1);
238246
expect(t.insert).not.toHaveBeenCalled(); // no blind fallback
239-
expect(res.results[0].errors?.[0]?.message).toBe('update exploded'); // the real cause survives
247+
// [#8502] The cause is withheld from the RESPONSE (bare `Error`), so
248+
// "the real cause survives" is now carried by the two structural
249+
// assertions above — the update was attempted and no fallback insert
250+
// ran — plus the row naming the UPSERT it was doing. What must never
251+
// appear is the fallback insert's duplicate-key text.
252+
expect(res.results[0].errors?.[0]?.message).toBe('The upsert of this record failed. The reason is in the server log.');
253+
expect(res.results[0].errors?.[0]?.message).not.toContain('duplicate key');
240254
});
241255
});
242256

packages/metadata-protocol/src/protocol.batch-row-conformance.test.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,11 @@ describe('batchData rows conform to BatchOperationResultSchema (#4793)', () => {
129129
expectConformantResponse(res, 3);
130130
expect(res.results[0].data).toMatchObject({ title: 'A' });
131131
// An unclassified engine throw is a 500 in row form.
132-
expect(res.results[1].errors[0]).toMatchObject({ code: 'INTERNAL_ERROR', message: 'insert exploded' });
132+
// [#8502] `code` is unchanged; the message is the withheld stable line.
133+
expect(res.results[1].errors[0]).toMatchObject({
134+
code: 'INTERNAL_ERROR',
135+
message: 'The create of this record failed. The reason is in the server log.',
136+
});
133137
expect(res.results[1].data).toBeUndefined();
134138
});
135139

@@ -185,8 +189,9 @@ describe('batchData rows conform to BatchOperationResultSchema (#4793)', () => {
185189

186190
expectConformantResponse(res, 3);
187191
expect(res.results[0].errors[0].code).toBe('ROLLED_BACK');
188-
expect(res.results[0].errors[0].message).toContain('insert exploded'); // human-readable cause
189-
expect(res.results[1].errors[0].message).toBe('insert exploded'); // causal row keeps its own error
192+
// [#8502] Same propagation claim, against the causal row's own text.
193+
expect(res.results[1].errors[0].message).toBe('The create of this record failed. The reason is in the server log.');
194+
expect(res.results[0].errors[0].message).toContain(res.results[1].errors[0].message); // human-readable cause
190195
expect(res.results[2].errors[0].code).toBe('NOT_ATTEMPTED');
191196
// No reverted write may carry a record payload.
192197
for (const row of res.results) expect(row.data).toBeUndefined();
@@ -210,7 +215,7 @@ describe('updateManyData rows conform to BatchOperationResultSchema (#4793)', ()
210215

211216
expectConformantResponse(res, 3);
212217
expect(res.results[0].data).toMatchObject({ id: 'a', title: 'a-new' });
213-
expect(res.results[1].errors[0].message).toBe('update exploded');
218+
expect(res.results[1].errors[0].message).toBe('The update of this record failed. The reason is in the server log.'); // [#8502]
214219
});
215220

216221
it('atomic rollback — all three row classes, as codes', async () => {

0 commit comments

Comments
 (0)