Skip to content

Commit a5bc9c9

Browse files
committed
fix(objectql): keep the cascade refusal out of the data door's unknown-object heuristic; changeset
Claude-Session: https://claude.ai/code/session_01ADHkmQQoWsHzMSsiaM89mr Co-authored-by: Claude <noreply@anthropic.com>
1 parent a6a871e commit a5bc9c9

3 files changed

Lines changed: 29 additions & 8 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql)!: a nested write's stale-roll-up refusal (`ERR_SUMMARY_RECOMPUTE`) never answers for a delete or a transaction that did not land
6+
7+
Clause-②: yes (narrowing) — a delete whose cascade a dependent's failing roll-up stopped, and a transaction that refusal rolled back, answered the success of a write that never landed; both now refuse.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling or stored shape is added, removed, renamed or re-shaped, and no exported declaration is added, removed or narrowed. What narrows is one engine answer: a stale-roll-up refusal that used to leave a delete or a transaction which did not land now leaves it as a code-less error, and the remedy is the summary field's `summaryOperations` or a retry, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
11+
12+
**BREAKING** for one answer only. `ERR_SUMMARY_RECOMPUTE` means "this write is stored, only a parent roll-up recompute failed", and the data doors (REST, MCP) and the action, sandbox and flow writers answer it as the write's own success. Two engine units carried a nested write's refusal past the point where it stopped being true:
13+
14+
- **A cascade delete.** `delete()` removes a record's `master_detail` dependents through the public write path before its own row. When a dependent's delete raised the refusal (its roll-up into another parent could not be recomputed), the cascade stopped before the record was removed, and under the `'atomic'` plan the transaction rolled the dependent back too. The refusal still reached the caller, so `DELETE /api/v1/data/:object/:id` answered `200 { success: true }` for a record that is still stored. On a runtime that cannot roll back, the same delete removed the first dependent and left the record and the rest of its dependents.
15+
- **A transaction that rolls back.** A `transaction()` (on the engine or on a scoped context, `ctx.api`) whose callback let the refusal escape rolled back every write in it, the refusing one included, and the refusal still told the caller it was stored.
16+
17+
**Now.** Both units answer an error with no `code` (an internal failure, a `500` at the data doors), whose message says what did not complete and names the stale summary; the original refusal is kept as `cause`. Nothing else changes: a dependent's own delete, a write outside any unit, a transaction that catches the refusal and commits, and a joined transaction whose owner decides all still read `ERR_SUMMARY_RECOMPUTE` carrying the written result.
18+
19+
**The remedy.** Fix the summary field's `summaryOperations` (the engine's warning names why the recompute was refused), then retry the delete or the transaction.

‎packages/objectql/src/summary-errors.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,9 @@ function unlandedWriteRefusal(e: unknown, lead: string): unknown {
119119
export function cascadeStoppedRefusal(e: unknown, object: string): unknown {
120120
return unlandedWriteRefusal(
121121
e,
122-
`The delete of '${object}' did not complete: its cascade stopped before the record was removed. ` +
122+
// Double quotes: a quoted `'object'` beside a "not" reads as an unknown
123+
// object to the data door's message heuristic, which answers it 404.
124+
`The delete of "${object}" did not complete: its cascade stopped before the record was removed. ` +
123125
'Under a transaction that rolls back nothing was written; otherwise the dependents written before ' +
124126
'that point stay written.',
125127
);

‎packages/rest/src/data-delete-cascade-summary-rollback.test.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@
1717
*
1818
* ## What is pinned
1919
*
20-
* - `DELETE` of the account answers a 5xx refusal whose code is not the
21-
* stale-roll-up one, and the account and its lines are all still stored;
20+
* - `DELETE` of the account answers `500 INTERNAL_ERROR` (an unrecomputable
21+
* roll-up is an internal failure), and the account and its lines are all
22+
* still stored;
2223
* - CONTROL: `DELETE` of a line itself (its own write landed, only its
2324
* recompute failed) still answers `200 { success: true }` and the line is
2425
* gone.
@@ -136,12 +137,11 @@ const rowIds = async (object: string) =>
136137
((await engine.find(object, { fields: ['id'] } as any)) as Array<{ id: string }>).map((r) => r.id).sort();
137138

138139
describe('a DELETE whose cascade a dependent\'s failing roll-up stops answers a refusal', () => {
139-
it('DELETE of the account answers a 5xx refusal that is not the stale-roll-up code, and nothing is deleted', async () => {
140+
it('DELETE of the account answers 500 INTERNAL_ERROR, and nothing is deleted', async () => {
140141
const res = await call('DELETE', '/:object/:id', { params: { object: ACCOUNT, id: 'a1' } });
141-
expect(res._status, JSON.stringify(res._json)).toBeGreaterThanOrEqual(500);
142-
expect(res._json?.success, JSON.stringify(res._json)).not.toBe(true);
143-
expect(res._json?.error?.code, JSON.stringify(res._json)).toBeDefined();
144-
expect(res._json.error.code).not.toBe('ERR_SUMMARY_RECOMPUTE');
142+
expect(res._status, JSON.stringify(res._json)).toBe(500);
143+
expect(res._json?.code, JSON.stringify(res._json)).toBe('INTERNAL_ERROR');
144+
expect(res._json.success).toBeUndefined();
145145
expect(await rowIds(ACCOUNT)).toEqual(['a1']);
146146
expect(await rowIds(LINE)).toEqual(['l1', 'l2', 'l3']);
147147
});

0 commit comments

Comments
 (0)