From 7b3b83094a363e7bdf55753826be9b2912d38a79 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:54:11 +0000 Subject: [PATCH 1/6] fix(objectql)!: a nested write's stale-roll-up refusal never answers for a delete or transaction that did not land A cascade dependent's ERR_SUMMARY_RECOMPUTE stopped the parent delete's walk (and, under the atomic plan, rolled the dependent back), yet reached the caller unchanged, so every surface that answers that refusal as the write's success reported a delete that never happened. The cascade walk and both owned transaction() rollbacks now convert it into a code-less refusal carrying the original as cause. Claude-Session: https://claude.ai/code/session_01ADHkmQQoWsHzMSsiaM89mr Co-authored-by: Claude --- .../engine-cascade-summary-rollback.test.ts | 203 ++++++++++++++++++ packages/objectql/src/engine.ts | 26 ++- packages/objectql/src/summary-errors.ts | 57 +++++ ...ta-delete-cascade-summary-rollback.test.ts | 155 +++++++++++++ 4 files changed, 433 insertions(+), 8 deletions(-) create mode 100644 packages/objectql/src/engine-cascade-summary-rollback.test.ts create mode 100644 packages/rest/src/data-delete-cascade-summary-rollback.test.ts diff --git a/packages/objectql/src/engine-cascade-summary-rollback.test.ts b/packages/objectql/src/engine-cascade-summary-rollback.test.ts new file mode 100644 index 00000000000..acd59493eae --- /dev/null +++ b/packages/objectql/src/engine-cascade-summary-rollback.test.ts @@ -0,0 +1,203 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A cascade dependent's stale-roll-up refusal never answers for a delete that + * did not land. + * + * `SummaryRecomputeError` (`ERR_SUMMARY_RECOMPUTE`) means "this write is + * stored, only a parent's roll-up recompute failed", and every answering + * surface (the data doors, the action / sandbox / flow writers) turns it into + * the write's own success. A by-id delete walks its `master_detail` + * dependents first, each through the public `delete()`. When one dependent's + * delete raises that refusal (its roll-up into ANOTHER parent cannot be + * recomputed), the walk stops before the parent's own row is removed, so the + * parent delete did not land — under the `'atomic'` plan the transaction then + * rolls the dependent back too. The refusal used to leave the parent's + * `delete()` unchanged, and the caller was told the delete succeeded. + * + * Pinned here: the parent delete refuses with an error that is NOT the + * stale-roll-up refusal, on the atomic plan (nothing stored changes) and on a + * runtime that cannot roll back (the parent is still stored); a refusal that + * escapes a caller's own `transaction()` that rolls back is converted the same + * way. Controls: a delete of the dependent itself — its own write landed, only + * its recompute failed — still answers `ERR_SUMMARY_RECOMPUTE` carrying its + * result, and so does a caller's transaction that catches it and commits. + * + * The driver rolls back for real (a snapshot per transaction), and refuses + * every write to `bucket` while `refuseBucket` is set: that is the roll-up + * recompute's parent update, refused without a transient message, so no retry + * runs. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectQL } from './engine.js'; + +type Row = Record; + +function matches(row: Row, where: any): boolean { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]) => { + if (k === '$and') return (v as any[]).every((w) => matches(row, w)); + if (k === '$or') return (v as any[]).some((w) => matches(row, w)); + if (k.startsWith('$')) return true; + if (v && typeof v === 'object' && !Array.isArray(v)) { + const ops = v as Record; + if ('$in' in ops) return (ops.$in as unknown[]).map(String).includes(String(row[k])); + if ('$eq' in ops) return String(row[k]) === String(ops.$eq); + if ('$ne' in ops) return String(row[k]) !== String(ops.$ne); + return true; + } + return row[k] === v || (row[k] != null && v != null && String(row[k]) === String(v)); + }); +} + +function makeDriver(transactional: boolean) { + let stores = new Map>(); + const storeFor = (o: string) => stores.get(o) ?? (stores.set(o, new Map()), stores.get(o)!); + const begun: unknown[] = []; + const rolledBack: unknown[] = []; + const snapshots = new Map(); + const state = { refuseBucket: false }; + let n = 0; + const driver: any = { + name: 'primary', version: '0.0.0', supports: {}, begun, rolledBack, state, + rowsOf: (o: string) => Array.from(storeFor(o).values()), + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async syncSchema() {}, + async find(o: string, ast: any) { + const rows = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(o: string, ast: any) { return (await this.find(o, ast))[0] ?? null; }, + async create(o: string, data: Row) { + const row = { ...data, id: (data.id as string) ?? `${o}_${++n}` }; + storeFor(o).set(String(row.id), row); + return row; + }, + async update(o: string, id: string, data: Row) { + if (state.refuseBucket && o === 'bucket') throw new Error('bucket store refused the write'); + const row = { ...storeFor(o).get(String(id)), ...data, id }; + storeFor(o).set(String(id), row); + return row; + }, + async delete(o: string, id: string) { return storeFor(o).delete(String(id)); }, + async count(o: string, ast: any) { return (await this.find(o, ast)).length; }, + async bulkCreate(o: string, rows: Row[]) { return Promise.all(rows.map((r) => this.create(o, r))); }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + }; + if (transactional) { + driver.beginTransaction = async () => { + const handle = { trx: begun.length + 1 }; + snapshots.set(handle, new Map(Array.from(stores, ([o, s]) => [o, new Map(Array.from(s, ([k, v]) => [k, { ...v }]))]))); + begun.push(handle); + return handle; + }; + driver.commit = async (h: unknown) => { snapshots.delete(h); }; + driver.rollback = async (h: unknown) => { stores = snapshots.get(h) ?? stores; rolledBack.push(h); }; + } + return driver; +} + +async function makeEngine(transactional: boolean) { + const log = () => {}; + const engine = new ObjectQL({ logger: { debug: log, info: log, warn: log, error: log } } as any); + const driver = makeDriver(transactional); + engine.registerDriver(driver, true); + await engine.init(); + const text = (name: string) => ({ name, type: 'text' }); + engine.registry.registerObject({ name: 'acct', label: 'acct', fields: { id: { ...text('id'), primaryKey: true }, name: text('name') } } as any, '__test__'); + engine.registry.registerObject({ + name: 'bucket', label: 'bucket', + fields: { + id: { ...text('id'), primaryKey: true }, name: text('name'), + line_count: { name: 'line_count', type: 'summary', summaryOperations: { object: 'line', function: 'count' } }, + }, + } as any, '__test__'); + engine.registry.registerObject({ + name: 'line', label: 'line', + fields: { + id: { ...text('id'), primaryKey: true }, name: text('name'), + acct: { name: 'acct', type: 'master_detail', reference: 'acct', deleteBehavior: 'cascade' }, + bucket: { name: 'bucket', type: 'lookup', reference: 'bucket' }, + }, + } as any, '__test__'); + await engine.insert('acct', { id: 'a1', name: 'A' }); + await engine.insert('bucket', { id: 'b1', name: 'B' }); + await engine.insert('line', { id: 'l1', name: 'L1', acct: 'a1', bucket: 'b1' }); + await engine.insert('line', { id: 'l2', name: 'L2', acct: 'a1', bucket: 'b1' }); + driver.begun.length = 0; + driver.rolledBack.length = 0; + driver.state.refuseBucket = true; + return { engine, driver }; +} + +const ids = (rows: Row[]) => rows.map((r) => r.id).sort(); + +describe('a cascade dependent\'s ERR_SUMMARY_RECOMPUTE never answers for a parent delete that did not land', () => { + it('atomic plan: the parent delete refuses with another error, and nothing stored changes', async () => { + const { engine, driver } = await makeEngine(true); + const err: any = await engine.delete('acct', { where: { id: 'a1' } } as any).then(() => null, (e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBeUndefined(); + expect(err.cause?.code).toBe('ERR_SUMMARY_RECOMPUTE'); + expect(err.written).toBeUndefined(); + expect(driver.rolledBack).toHaveLength(1); + expect(ids(driver.rowsOf('acct'))).toEqual(['a1']); + expect(ids(driver.rowsOf('line'))).toEqual(['l1', 'l2']); + }); + + it('a runtime that cannot roll back: the parent is still stored, and the caller is refused', async () => { + const { engine, driver } = await makeEngine(false); + const err: any = await engine.delete('acct', { where: { id: 'a1' } } as any).then(() => null, (e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBeUndefined(); + expect(err.cause?.code).toBe('ERR_SUMMARY_RECOMPUTE'); + expect(ids(driver.rowsOf('acct'))).toEqual(['a1']); + // The dependent the walk reached first is gone: nothing can roll it back. + expect(ids(driver.rowsOf('line'))).toEqual(['l2']); + }); + + it('a caller\'s own transaction that the refusal rolls back: refused with another error, nothing stored', async () => { + const { engine, driver } = await makeEngine(true); + const err: any = await engine + .transaction(async (trx: any) => engine.delete('line', { where: { id: 'l1' }, context: trx } as any)) + .then(() => null, (e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBeUndefined(); + expect(err.cause?.code).toBe('ERR_SUMMARY_RECOMPUTE'); + expect(driver.rolledBack).toHaveLength(1); + expect(ids(driver.rowsOf('line'))).toEqual(['l1', 'l2']); + }); + + it('the same through a scoped context\'s transaction (ctx.api)', async () => { + const { engine, driver } = await makeEngine(true); + const scoped = (engine as any).createContext({ isSystem: true }); + const err: any = await scoped + .transaction(async (trx: any) => trx.object('line').deleteById('l1')) + .then(() => null, (e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBeUndefined(); + expect(err.cause?.code).toBe('ERR_SUMMARY_RECOMPUTE'); + expect(driver.rolledBack).toHaveLength(1); + expect(ids(driver.rowsOf('line'))).toEqual(['l1', 'l2']); + }); + + it('CONTROL the dependent\'s own delete landed: ERR_SUMMARY_RECOMPUTE, carrying its result', async () => { + const { engine, driver } = await makeEngine(true); + const err: any = await engine.delete('line', { where: { id: 'l1' } } as any).then(() => null, (e: unknown) => e); + expect(err?.code).toBe('ERR_SUMMARY_RECOMPUTE'); + expect(err.written).toBe(true); + expect(ids(driver.rowsOf('line'))).toEqual(['l2']); + }); + + it('CONTROL a caller\'s transaction that catches the refusal commits, and the refusal stays true', async () => { + const { engine, driver } = await makeEngine(true); + let caught: any; + await engine.transaction(async (trx: any) => { + await engine.delete('line', { where: { id: 'l1' }, context: trx } as any).catch((e: unknown) => { caught = e; }); + }); + expect(caught?.code).toBe('ERR_SUMMARY_RECOMPUTE'); + expect(driver.rolledBack).toHaveLength(0); + expect(ids(driver.rowsOf('line'))).toEqual(['l2']); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 899a02084e6..2313d6cc106 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -201,7 +201,7 @@ import { } from '@objectstack/core'; import { WriteEpoch, isWriteEpochOperation } from './write-epoch.js'; import { bridgeAuthzInvalidation } from './authz-invalidation-bridge.js'; -import { SummaryRecomputeError, type SummaryRecomputeFailure } from './summary-errors.js'; +import { SummaryRecomputeError, cascadeStoppedRefusal, rolledBackRefusal, type SummaryRecomputeFailure } from './summary-errors.js'; import { TransactionUnsupportedError } from './transaction-errors.js'; import { aggregateSummaryValue, @@ -20917,8 +20917,13 @@ export class ObjectQL implements IObjectQLEngine { // wrapping its cascade. `buildDriverOptions` only fills keys that // are still `undefined`, so re-running it adds the handle and // changes nothing else. + // + // A dependent's stale-roll-up refusal stops the walk before this + // row is removed, so it is not this delete's answer: + // `cascadeStoppedRefusal` says what it is instead. const runByIdDelete = async (writeContext?: ExecutionContext) => { - await this.cascadeDeleteRelations(object, [hookContext.input.id as string | number], writeContext); + await this.cascadeDeleteRelations(object, [hookContext.input.id as string | number], writeContext) + .catch((e: unknown) => { throw cascadeStoppedRefusal(e, object); }); return await driver.delete( object, hookContext.input.id as string, @@ -21010,8 +21015,11 @@ export class ObjectQL implements IObjectQLEngine { const matchedIds = matched.map((row) => row?.id).filter((v): v is string | number => v != null); if (summaryKeyColumns.length > 0) summaryPriorRows = withoutDeletedSelfParents(object, matched, matchedIds, this.getSummaryDescriptors(object)); deletedSecrets = deletedSecretSlots(deleteSchema, matched); - // [#27004] One walk, rooted at the whole matched set. - await this.cascadeDeleteRelations(object, matchedIds, writeContext); + // [#27004] One walk, rooted at the whole matched set. A + // dependent's stale-roll-up refusal is not this delete's + // answer, as in the by-id branch. + await this.cascadeDeleteRelations(object, matchedIds, writeContext) + .catch((e: unknown) => { throw cascadeStoppedRefusal(e, object); }); // The id set rides an `$in`; batched so one statement never // outgrows a driver's bind-parameter limit. const batchSize = ObjectQL.REFERENCE_PROBE_BATCH; @@ -21864,8 +21872,9 @@ export class ObjectQL implements IObjectQLEngine { // [#21274] A boundary outside the middleware seam: a commit failure, or // a callback that reached the driver through the transaction handle. // An error that already crossed an engine boundary inside the callback - // comes back as the same reference. - throw redactPropagatedDriverFault(err); + // comes back as the same reference. A stale-roll-up refusal ("stored") + // is no longer true once this rollback has run: `rolledBackRefusal`. + throw redactPropagatedDriverFault(rolledBackRefusal(err)); } } @@ -22855,8 +22864,9 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi { } catch (error) { if (driver.rollback) await driver.rollback(trx); else if (driver.rollbackTransaction) await driver.rollbackTransaction(trx); - // [#21274] The engine surface's twin (`ObjectQL.transaction`). - throw redactPropagatedDriverFault(error); + // [#21274] The engine surface's twin (`ObjectQL.transaction`), the + // stale-roll-up conversion included. + throw redactPropagatedDriverFault(rolledBackRefusal(error)); } } diff --git a/packages/objectql/src/summary-errors.ts b/packages/objectql/src/summary-errors.ts index 2d4ef78cfdd..b5206b8cb1b 100644 --- a/packages/objectql/src/summary-errors.ts +++ b/packages/objectql/src/summary-errors.ts @@ -72,3 +72,60 @@ export class SummaryRecomputeError extends Error { this.name = 'SummaryRecomputeError'; } } + +/** + * The refusal a unit answers when a write NESTED in it raised + * {@link SummaryRecomputeError} and the unit itself then did not land. + * + * That refusal says "this write is stored, only a roll-up recompute failed", + * and every answering surface turns it into the write's own success. It is + * true for the write that raised it, and only while that write stands. Two + * engine units carry a nested write's refusal past the point where it stops + * being true: + * + * - a delete's cascade walk deletes (or clears) each dependent through the + * public write path, before the parent's own row is removed. A dependent's + * refusal stops the walk, so the parent's row is never removed — and under + * the `'atomic'` plan the transaction then rolls the dependent back too; + * - a `transaction()` whose callback lets the refusal escape rolls back every + * write in it, the refusing one included. + * + * Either way the caller's own write did not land, so it is told so: an error + * with no `code` (an internal failure, which is what an unrecomputable roll-up + * is), the original refusal kept as `cause` for the operator. Every other error + * passes through unchanged, and so does the refusal anywhere it is still true: + * the dependent's own delete, a write outside any unit, a transaction that + * catches it and commits, a joined transaction whose owner decides. + * + * Matched by `code`, as every reader of the refusal matches it. + */ +function unlandedWriteRefusal(e: unknown, lead: string): unknown { + if (!(e instanceof Error) || (e as { code?: unknown }).code !== SUMMARY_RECOMPUTE_CODE) return e; + const failures = (e as { failures?: unknown }).failures; + const named = Array.isArray(failures) + ? [...new Set((failures as SummaryRecomputeFailure[]).map((f) => `${f?.parentObject}.${f?.field}`))] + : []; + const summaries = named.length > 0 ? `the roll-up summary ${named.join(', ')}` : 'a parent roll-up summary'; + const refusal = new Error( + `${lead} A write inside it was stored, then ${summaries} could not be recomputed after it, and that ` + + "refusal stopped it. Fix the summary field's `summaryOperations` (the engine's warning names why the " + + 'recompute was refused), then retry.', + ); + (refusal as Error & { cause?: unknown }).cause = e; + return refusal; +} + +/** {@link unlandedWriteRefusal} for a delete whose cascade walk the refusal stopped. */ +export function cascadeStoppedRefusal(e: unknown, object: string): unknown { + return unlandedWriteRefusal( + e, + `The delete of '${object}' did not complete: its cascade stopped before the record was removed. ` + + 'Under a transaction that rolls back nothing was written; otherwise the dependents written before ' + + 'that point stay written.', + ); +} + +/** {@link unlandedWriteRefusal} for a transaction the refusal rolled back. */ +export function rolledBackRefusal(e: unknown): unknown { + return unlandedWriteRefusal(e, 'The transaction rolled back, so nothing written inside it was stored.'); +} diff --git a/packages/rest/src/data-delete-cascade-summary-rollback.test.ts b/packages/rest/src/data-delete-cascade-summary-rollback.test.ts new file mode 100644 index 00000000000..9ab8107ccb0 --- /dev/null +++ b/packages/rest/src/data-delete-cascade-summary-rollback.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A DELETE whose `master_detail` cascade is stopped by a dependent's failing + * roll-up answers a refusal, never the success of a delete that rolled back. + * + * ## The defect, measured on `origin/main` before this file was written + * + * The data door answers a write's success over the engine's stale-roll-up + * refusal (`ERR_SUMMARY_RECOMPUTE`, "the triggering records WERE written"). + * Deleting an account cascades to its lines under the `'atomic'` plan; each + * line's delete recomputes a roll-up on ANOTHER parent (`bucket.memo_sum`, a + * sum over a text field, which every recompute refuses). The first line's + * refusal stopped the cascade and the transaction rolled back, so neither the + * account nor any line was deleted, yet the refusal reached the door unchanged + * and `DELETE` answered `200 { success: true }`. + * + * ## What is pinned + * + * - `DELETE` of the account answers a 5xx refusal whose code is not the + * stale-roll-up one, and the account and its lines are all still stored; + * - CONTROL: `DELETE` of a line itself (its own write landed, only its + * recompute failed) still answers `200 { success: true }` and the line is + * gone. + * + * Harness: the composed REST harness of `data-write-summary-recompute-refused.test.ts` + * (`RestServer` → `ObjectStackProtocolImplementation` → `ObjectQL` → a real + * `SqlDriver` on better-sqlite3, which rolls back for real). The roll-up is + * registered on the engine directly, as a stored object published before the + * publish gate refused it would be. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; + +const ACCOUNT = 'qcr_account'; +const BUCKET = 'qcr_bucket'; +const LINE = 'qcr_line'; + +const ACCOUNT_OBJECT = { name: ACCOUNT, label: 'Account', fields: { name: { name: 'name', type: 'text' as const } } }; + +const BUCKET_OBJECT = { + name: BUCKET, + label: 'Bucket', + fields: { + name: { name: 'name', type: 'text' as const }, + // A sum over a text field: every recompute of it is refused. + memo_sum: { name: 'memo_sum', type: 'summary' as const, summaryOperations: { object: LINE, function: 'sum', field: 'memo' } }, + }, +}; + +const LINE_OBJECT = { + name: LINE, + label: 'Line', + fields: { + name: { name: 'name', type: 'text' as const }, + memo: { name: 'memo', type: 'text' as const }, + account: { name: 'account', type: 'master_detail' as const, reference: ACCOUNT, required: true, deleteBehavior: 'cascade' }, + bucket: { name: 'bucket', type: 'lookup' as const, reference: BUCKET }, + }, +}; + +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 = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +const dir = mkdtempSync(join(tmpdir(), 'os-cascade-rollup-')); +let engine: ObjectQL; +let call: (method: string, path: string, req: Record) => Promise; +let warn: ReturnType; +let errorLog: ReturnType; + +beforeAll(async () => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}); + engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: join(dir, 'cascade.sqlite') }, useNullAsDefault: true }) as any, + true, + ); + await engine.init(); + engine.registry.registerObject(ACCOUNT_OBJECT as any); + engine.registry.registerObject(BUCKET_OBJECT as any); + engine.registry.registerObject(LINE_OBJECT as any); + await engine.syncSchemas(); + await installDefaultOrganization(engine); + await engine.insert(ACCOUNT, { id: 'a1', name: 'A' } as any); + await engine.insert(BUCKET, { id: 'b1', name: 'B' } as any); + for (const id of ['l1', 'l2', 'l3']) { + await engine.insert(LINE, { id, name: id, account: 'a1', bucket: 'b1' } as any).catch((e: any) => { + // The seed itself meets the failing roll-up; its rows are written. + if (e?.code !== 'ERR_SUMMARY_RECOMPUTE') throw e; + }); + } + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const routes = rest.getRoutes(); + call = async (method, path, req) => { + const route = routes.find((r: any) => r.method === method && r.path === `/api/v1/data${path}`); + expect(route, `${method} /api/v1/data${path}`).toBeDefined(); + const res = makeRes(); + await route!.handler({ query: {}, headers: {}, ...req } as any, res); + return res; + }; +}); + +afterAll(async () => { + warn.mockRestore(); + errorLog.mockRestore(); + try { await engine.destroy(); } catch { /* noop */ } + rmSync(dir, { recursive: true, force: true }); +}); + +const rowIds = async (object: string) => + ((await engine.find(object, { fields: ['id'] } as any)) as Array<{ id: string }>).map((r) => r.id).sort(); + +describe('a DELETE whose cascade a dependent\'s failing roll-up stops answers a refusal', () => { + it('DELETE of the account answers a 5xx refusal that is not the stale-roll-up code, and nothing is deleted', async () => { + const res = await call('DELETE', '/:object/:id', { params: { object: ACCOUNT, id: 'a1' } }); + expect(res._status, JSON.stringify(res._json)).toBeGreaterThanOrEqual(500); + expect(res._json?.success, JSON.stringify(res._json)).not.toBe(true); + expect(res._json?.error?.code, JSON.stringify(res._json)).toBeDefined(); + expect(res._json.error.code).not.toBe('ERR_SUMMARY_RECOMPUTE'); + expect(await rowIds(ACCOUNT)).toEqual(['a1']); + expect(await rowIds(LINE)).toEqual(['l1', 'l2', 'l3']); + }); + + it('CONTROL DELETE of a line itself landed: 200 success, the line is gone', async () => { + const res = await call('DELETE', '/:object/:id', { params: { object: LINE, id: 'l3' } }); + expect(res._status ?? 200, JSON.stringify(res._json)).toBe(200); + expect(res._json).toEqual({ object: LINE, id: 'l3', success: true }); + expect(await rowIds(LINE)).toEqual(['l1', 'l2']); + }); +}); From a5bc9c90cc6a26214f5f9c07907c2aa319c695d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:00:23 +0000 Subject: [PATCH 2/6] 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 --- ...291-cascade-summary-refusal-rolled-back.md | 19 +++++++++++++++++++ packages/objectql/src/summary-errors.ts | 4 +++- ...ta-delete-cascade-summary-rollback.test.ts | 14 +++++++------- 3 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 .changeset/29291-cascade-summary-refusal-rolled-back.md diff --git a/.changeset/29291-cascade-summary-refusal-rolled-back.md b/.changeset/29291-cascade-summary-refusal-rolled-back.md new file mode 100644 index 00000000000..5184f678d90 --- /dev/null +++ b/.changeset/29291-cascade-summary-refusal-rolled-back.md @@ -0,0 +1,19 @@ +--- +'@objectstack/objectql': patch +--- + +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 + +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. +Metadata-protocol: none + + + +**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: + +- **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. +- **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. + +**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. + +**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. diff --git a/packages/objectql/src/summary-errors.ts b/packages/objectql/src/summary-errors.ts index b5206b8cb1b..6634bd5480a 100644 --- a/packages/objectql/src/summary-errors.ts +++ b/packages/objectql/src/summary-errors.ts @@ -119,7 +119,9 @@ function unlandedWriteRefusal(e: unknown, lead: string): unknown { export function cascadeStoppedRefusal(e: unknown, object: string): unknown { return unlandedWriteRefusal( e, - `The delete of '${object}' did not complete: its cascade stopped before the record was removed. ` + + // Double quotes: a quoted `'object'` beside a "not" reads as an unknown + // object to the data door's message heuristic, which answers it 404. + `The delete of "${object}" did not complete: its cascade stopped before the record was removed. ` + 'Under a transaction that rolls back nothing was written; otherwise the dependents written before ' + 'that point stay written.', ); diff --git a/packages/rest/src/data-delete-cascade-summary-rollback.test.ts b/packages/rest/src/data-delete-cascade-summary-rollback.test.ts index 9ab8107ccb0..16c7d804b4b 100644 --- a/packages/rest/src/data-delete-cascade-summary-rollback.test.ts +++ b/packages/rest/src/data-delete-cascade-summary-rollback.test.ts @@ -17,8 +17,9 @@ * * ## What is pinned * - * - `DELETE` of the account answers a 5xx refusal whose code is not the - * stale-roll-up one, and the account and its lines are all still stored; + * - `DELETE` of the account answers `500 INTERNAL_ERROR` (an unrecomputable + * roll-up is an internal failure), and the account and its lines are all + * still stored; * - CONTROL: `DELETE` of a line itself (its own write landed, only its * recompute failed) still answers `200 { success: true }` and the line is * gone. @@ -136,12 +137,11 @@ const rowIds = async (object: string) => ((await engine.find(object, { fields: ['id'] } as any)) as Array<{ id: string }>).map((r) => r.id).sort(); describe('a DELETE whose cascade a dependent\'s failing roll-up stops answers a refusal', () => { - it('DELETE of the account answers a 5xx refusal that is not the stale-roll-up code, and nothing is deleted', async () => { + it('DELETE of the account answers 500 INTERNAL_ERROR, and nothing is deleted', async () => { const res = await call('DELETE', '/:object/:id', { params: { object: ACCOUNT, id: 'a1' } }); - expect(res._status, JSON.stringify(res._json)).toBeGreaterThanOrEqual(500); - expect(res._json?.success, JSON.stringify(res._json)).not.toBe(true); - expect(res._json?.error?.code, JSON.stringify(res._json)).toBeDefined(); - expect(res._json.error.code).not.toBe('ERR_SUMMARY_RECOMPUTE'); + expect(res._status, JSON.stringify(res._json)).toBe(500); + expect(res._json?.code, JSON.stringify(res._json)).toBe('INTERNAL_ERROR'); + expect(res._json.success).toBeUndefined(); expect(await rowIds(ACCOUNT)).toEqual(['a1']); expect(await rowIds(LINE)).toEqual(['l1', 'l2', 'l3']); }); From f1eec6fdc1950cf1c68583aa36fa0ab3ae53eafd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:01:27 +0000 Subject: [PATCH 3/6] test(objectql): pin the predicate-delete cascade's refusal Claude-Session: https://claude.ai/code/session_01ADHkmQQoWsHzMSsiaM89mr Co-authored-by: Claude --- .../engine-cascade-summary-rollback.test.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/objectql/src/engine-cascade-summary-rollback.test.ts b/packages/objectql/src/engine-cascade-summary-rollback.test.ts index acd59493eae..933ed924dc2 100644 --- a/packages/objectql/src/engine-cascade-summary-rollback.test.ts +++ b/packages/objectql/src/engine-cascade-summary-rollback.test.ts @@ -17,9 +17,10 @@ * * Pinned here: the parent delete refuses with an error that is NOT the * stale-roll-up refusal, on the atomic plan (nothing stored changes) and on a - * runtime that cannot roll back (the parent is still stored); a refusal that - * escapes a caller's own `transaction()` that rolls back is converted the same - * way. Controls: a delete of the dependent itself — its own write landed, only + * runtime that cannot roll back (the parent is still stored), by id and by + * predicate; a refusal that + * escapes a caller's own `transaction()` (engine or scoped context) that rolls + * back is converted the same way. Controls: a delete of the dependent itself — its own write landed, only * its recompute failed — still answers `ERR_SUMMARY_RECOMPUTE` carrying its * result, and so does a caller's transaction that catches it and commits. * @@ -81,6 +82,11 @@ function makeDriver(transactional: boolean) { return row; }, async delete(o: string, id: string) { return storeFor(o).delete(String(id)); }, + async deleteMany(o: string, ast: any) { + const doomed = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + for (const r of doomed) storeFor(o).delete(String(r.id)); + return doomed.length; + }, async count(o: string, ast: any) { return (await this.find(o, ast)).length; }, async bulkCreate(o: string, rows: Row[]) { return Promise.all(rows.map((r) => this.create(o, r))); }, async bulkUpdate() { return []; }, async bulkDelete() {}, @@ -157,6 +163,15 @@ describe('a cascade dependent\'s ERR_SUMMARY_RECOMPUTE never answers for a paren expect(ids(driver.rowsOf('line'))).toEqual(['l2']); }); + it('a predicate delete (multi) on a runtime that cannot roll back: refused the same way, the parent still stored', async () => { + const { engine, driver } = await makeEngine(false); + const err: any = await engine.delete('acct', { where: { name: 'A' }, multi: true } as any).then(() => null, (e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBeUndefined(); + expect(err.cause?.code).toBe('ERR_SUMMARY_RECOMPUTE'); + expect(ids(driver.rowsOf('acct'))).toEqual(['a1']); + }); + it('a caller\'s own transaction that the refusal rolls back: refused with another error, nothing stored', async () => { const { engine, driver } = await makeEngine(true); const err: any = await engine From 4dd590503a27d304854ffc85aadc8369da294f98 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:03:30 +0000 Subject: [PATCH 4/6] test: type the new pins' engine options and drop the REST pin's console spies Claude-Session: https://claude.ai/code/session_01ADHkmQQoWsHzMSsiaM89mr Co-authored-by: Claude --- .../src/engine-cascade-summary-rollback.test.ts | 12 ++++++------ .../src/data-delete-cascade-summary-rollback.test.ts | 10 ++-------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/packages/objectql/src/engine-cascade-summary-rollback.test.ts b/packages/objectql/src/engine-cascade-summary-rollback.test.ts index 933ed924dc2..47b6b981f47 100644 --- a/packages/objectql/src/engine-cascade-summary-rollback.test.ts +++ b/packages/objectql/src/engine-cascade-summary-rollback.test.ts @@ -142,7 +142,7 @@ const ids = (rows: Row[]) => rows.map((r) => r.id).sort(); describe('a cascade dependent\'s ERR_SUMMARY_RECOMPUTE never answers for a parent delete that did not land', () => { it('atomic plan: the parent delete refuses with another error, and nothing stored changes', async () => { const { engine, driver } = await makeEngine(true); - const err: any = await engine.delete('acct', { where: { id: 'a1' } } as any).then(() => null, (e: unknown) => e); + const err: any = await engine.delete('acct', { where: { id: 'a1' } }).then(() => null, (e: unknown) => e); expect(err).toBeInstanceOf(Error); expect(err.code).toBeUndefined(); expect(err.cause?.code).toBe('ERR_SUMMARY_RECOMPUTE'); @@ -154,7 +154,7 @@ describe('a cascade dependent\'s ERR_SUMMARY_RECOMPUTE never answers for a paren it('a runtime that cannot roll back: the parent is still stored, and the caller is refused', async () => { const { engine, driver } = await makeEngine(false); - const err: any = await engine.delete('acct', { where: { id: 'a1' } } as any).then(() => null, (e: unknown) => e); + const err: any = await engine.delete('acct', { where: { id: 'a1' } }).then(() => null, (e: unknown) => e); expect(err).toBeInstanceOf(Error); expect(err.code).toBeUndefined(); expect(err.cause?.code).toBe('ERR_SUMMARY_RECOMPUTE'); @@ -165,7 +165,7 @@ describe('a cascade dependent\'s ERR_SUMMARY_RECOMPUTE never answers for a paren it('a predicate delete (multi) on a runtime that cannot roll back: refused the same way, the parent still stored', async () => { const { engine, driver } = await makeEngine(false); - const err: any = await engine.delete('acct', { where: { name: 'A' }, multi: true } as any).then(() => null, (e: unknown) => e); + const err: any = await engine.delete('acct', { where: { name: 'A' }, multi: true }).then(() => null, (e: unknown) => e); expect(err).toBeInstanceOf(Error); expect(err.code).toBeUndefined(); expect(err.cause?.code).toBe('ERR_SUMMARY_RECOMPUTE'); @@ -175,7 +175,7 @@ describe('a cascade dependent\'s ERR_SUMMARY_RECOMPUTE never answers for a paren it('a caller\'s own transaction that the refusal rolls back: refused with another error, nothing stored', async () => { const { engine, driver } = await makeEngine(true); const err: any = await engine - .transaction(async (trx: any) => engine.delete('line', { where: { id: 'l1' }, context: trx } as any)) + .transaction(async (trx: any) => engine.delete('line', { where: { id: 'l1' }, context: trx })) .then(() => null, (e: unknown) => e); expect(err).toBeInstanceOf(Error); expect(err.code).toBeUndefined(); @@ -199,7 +199,7 @@ describe('a cascade dependent\'s ERR_SUMMARY_RECOMPUTE never answers for a paren it('CONTROL the dependent\'s own delete landed: ERR_SUMMARY_RECOMPUTE, carrying its result', async () => { const { engine, driver } = await makeEngine(true); - const err: any = await engine.delete('line', { where: { id: 'l1' } } as any).then(() => null, (e: unknown) => e); + const err: any = await engine.delete('line', { where: { id: 'l1' } }).then(() => null, (e: unknown) => e); expect(err?.code).toBe('ERR_SUMMARY_RECOMPUTE'); expect(err.written).toBe(true); expect(ids(driver.rowsOf('line'))).toEqual(['l2']); @@ -209,7 +209,7 @@ describe('a cascade dependent\'s ERR_SUMMARY_RECOMPUTE never answers for a paren const { engine, driver } = await makeEngine(true); let caught: any; await engine.transaction(async (trx: any) => { - await engine.delete('line', { where: { id: 'l1' }, context: trx } as any).catch((e: unknown) => { caught = e; }); + await engine.delete('line', { where: { id: 'l1' }, context: trx }).catch((e: unknown) => { caught = e; }); }); expect(caught?.code).toBe('ERR_SUMMARY_RECOMPUTE'); expect(driver.rolledBack).toHaveLength(0); diff --git a/packages/rest/src/data-delete-cascade-summary-rollback.test.ts b/packages/rest/src/data-delete-cascade-summary-rollback.test.ts index 16c7d804b4b..e15c6277c20 100644 --- a/packages/rest/src/data-delete-cascade-summary-rollback.test.ts +++ b/packages/rest/src/data-delete-cascade-summary-rollback.test.ts @@ -31,7 +31,7 @@ * publish gate refused it would be. */ -import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -86,12 +86,8 @@ function makeRes() { const dir = mkdtempSync(join(tmpdir(), 'os-cascade-rollup-')); let engine: ObjectQL; let call: (method: string, path: string, req: Record) => Promise; -let warn: ReturnType; -let errorLog: ReturnType; beforeAll(async () => { - warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}); engine = new ObjectQL(); engine.registerDriver( new SqlDriver({ client: 'better-sqlite3', connection: { filename: join(dir, 'cascade.sqlite') }, useNullAsDefault: true }) as any, @@ -127,14 +123,12 @@ beforeAll(async () => { }); afterAll(async () => { - warn.mockRestore(); - errorLog.mockRestore(); try { await engine.destroy(); } catch { /* noop */ } rmSync(dir, { recursive: true, force: true }); }); const rowIds = async (object: string) => - ((await engine.find(object, { fields: ['id'] } as any)) as Array<{ id: string }>).map((r) => r.id).sort(); + ((await engine.find(object, { fields: ['id'] })) as Array<{ id: string }>).map((r) => r.id).sort(); describe('a DELETE whose cascade a dependent\'s failing roll-up stops answers a refusal', () => { it('DELETE of the account answers 500 INTERNAL_ERROR, and nothing is deleted', async () => { From 3ff9597acbe440e008e25228b440ef3167b45cdc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:04:45 +0000 Subject: [PATCH 5/6] =?UTF-8?q?chore(changeset):=20copy=20the=20claim's=20?= =?UTF-8?q?Clause-=E2=91=A1=20line=20verbatim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01ADHkmQQoWsHzMSsiaM89mr Co-authored-by: Claude --- .changeset/29291-cascade-summary-refusal-rolled-back.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/29291-cascade-summary-refusal-rolled-back.md b/.changeset/29291-cascade-summary-refusal-rolled-back.md index 5184f678d90..538a371a2d8 100644 --- a/.changeset/29291-cascade-summary-refusal-rolled-back.md +++ b/.changeset/29291-cascade-summary-refusal-rolled-back.md @@ -4,7 +4,7 @@ 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 -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. +Clause-②: yes (narrowing) — a delete that answered success after rolling back now refuses (`!` in the title, full dogfood, seat contract review) Metadata-protocol: none From 2af3815d9e444653480ccf23f5b29028c57499fc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:40:27 +0000 Subject: [PATCH 6/6] =?UTF-8?q?test(runtime):=20pin=20an=20action's=20ctx.?= =?UTF-8?q?engine.delete=20over=20a=20rolled-back=20cascade=20as=20a=20ref?= =?UTF-8?q?usal;=20Clause-=E2=91=A1=20reads=20no=20(narrowing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An action body's ctx.engine.delete (PR #29283 answers a landed write's stale-roll-up refusal as success) whose master_detail cascade a dependent's failing roll-up stopped now answers the action door's 400 refusal, the body does not run on, and nothing is deleted. Ablating the engine conversion answers 200 {success:true,data:{deleted:'a1'}} for a delete that rolled back. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01ADHkmQQoWsHzMSsiaM89mr --- ...291-cascade-summary-refusal-rolled-back.md | 4 +- ...on-delete-cascade-summary-rollback.test.ts | 153 ++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 packages/runtime/src/action-delete-cascade-summary-rollback.test.ts diff --git a/.changeset/29291-cascade-summary-refusal-rolled-back.md b/.changeset/29291-cascade-summary-refusal-rolled-back.md index 538a371a2d8..7f015946ef7 100644 --- a/.changeset/29291-cascade-summary-refusal-rolled-back.md +++ b/.changeset/29291-cascade-summary-refusal-rolled-back.md @@ -4,9 +4,11 @@ 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 -Clause-②: yes (narrowing) — a delete that answered success after rolling back now refuses (`!` in the title, full dogfood, seat contract review) +Clause-②: no (narrowing) Metadata-protocol: none +Narrowing note: a delete or a transaction that answered success after rolling back now refuses (`!` in the title, full dogfood, seat contract review); claim amendment 6577647622 on #29291. + **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: diff --git a/packages/runtime/src/action-delete-cascade-summary-rollback.test.ts b/packages/runtime/src/action-delete-cascade-summary-rollback.test.ts new file mode 100644 index 00000000000..e42ea892b16 --- /dev/null +++ b/packages/runtime/src/action-delete-cascade-summary-rollback.test.ts @@ -0,0 +1,153 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * An action body's `ctx.engine.delete` whose `master_detail` cascade is + * stopped by a dependent's failing roll-up is refused, never answered as the + * success of a delete that rolled back. + * + * `ctx.engine` answers a write's own success over the engine's stale-roll-up + * refusal (`ERR_SUMMARY_RECOMPUTE`, "the triggering records WERE written"), so + * a body runs on after a write that landed. Deleting an account cascades to its + * lines under the `'atomic'` plan. Each line's delete recomputes a roll-up on + * ANOTHER parent (`qca_bucket.memo_sum`, a sum over a text field, which every + * recompute refuses), so the first line's refusal stops the cascade and the + * transaction rolls everything back. The engine now answers that delete with a + * refusal that is not the stale-roll-up one, so the body does not run on and + * the action door answers it as it answers any other throw out of a body: a + * `400` refusal carrying the engine's message, `success: false`. + * + * Pinned through the real stack: `HttpDispatcher` → the action door → + * `ctx.engine` → `ObjectQL` → a real `SqlDriver` on better-sqlite3, which rolls + * back for real (the harness of `dispatcher-unique-violation-wire-code.test.ts`). + * + * - the account's delete: the action answers `400`, the body's next line never + * ran, and the account and its lines are all still stored; + * - CONTROL a line's own delete landed: the action answers its body's own + * result and the line is gone. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { HttpDispatcher } from './http-dispatcher.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; + +const PREFIX = '/api/v1'; +const TEST_PACKAGE_ID = 'objectstack-test'; +const ACCOUNT = 'qca_account'; +const BUCKET = 'qca_bucket'; +const LINE = 'qca_line'; + +const ACCOUNT_OBJECT = { + name: ACCOUNT, + label: 'Account', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + name: { name: 'name', type: 'text' as const, label: 'Name' }, + }, + actions: [ + { name: 'delete_account', label: 'Delete account', type: 'script', target: 'delete_account' }, + { name: 'delete_line', label: 'Delete line', type: 'script', target: 'delete_line' }, + ], +}; + +const BUCKET_OBJECT = { + name: BUCKET, + label: 'Bucket', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + name: { name: 'name', type: 'text' as const, label: 'Name' }, + // A sum over a text field: every recompute of it is refused. + memo_sum: { name: 'memo_sum', type: 'summary' as const, label: 'Memo sum', summaryOperations: { object: LINE, function: 'sum', field: 'memo' } }, + }, +}; + +const LINE_OBJECT = { + name: LINE, + label: 'Line', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + name: { name: 'name', type: 'text' as const, label: 'Name' }, + memo: { name: 'memo', type: 'text' as const, label: 'Memo' }, + account: { name: 'account', type: 'master_detail' as const, reference: ACCOUNT, deleteBehavior: 'cascade', label: 'Account' }, + bucket: { name: 'bucket', type: 'lookup' as const, reference: BUCKET, label: 'Bucket' }, + }, +}; + +let engine: ObjectQL; +let dispatcher: HttpDispatcher; +const bodyRanOn: string[] = []; + +afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } +}); + +beforeAll(async () => { + engine = new ObjectQL(); + engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true); + await engine.init(); + for (const o of [ACCOUNT_OBJECT, BUCKET_OBJECT, LINE_OBJECT]) engine.registry.registerObject(o as any, TEST_PACKAGE_ID); + await engine.syncSchemas(); + await installDefaultOrganization(engine); + await engine.insert(ACCOUNT, { id: 'a1', name: 'A' }); + await engine.insert(BUCKET, { id: 'b1', name: 'B' }); + for (const id of ['l1', 'l2', 'l3']) { + await engine.insert(LINE, { id, name: id, account: 'a1', bucket: 'b1' }).catch((e: any) => { + // The seed itself meets the failing roll-up; its rows are written. + if (e?.code !== 'ERR_SUMMARY_RECOMPUTE') throw e; + }); + } + const ql = engine as any; + ql.registerAction(ACCOUNT, 'delete_account', async (ctx: any) => { + await ctx.engine.delete(ACCOUNT, 'a1'); + bodyRanOn.push('delete_account'); + return { deleted: 'a1' }; + }); + ql.registerAction(ACCOUNT, 'delete_line', async (ctx: any) => { + await ctx.engine.delete(LINE, 'l3'); + bodyRanOn.push('delete_line'); + return { deleted: 'l3' }; + }); + const protocol = new ObjectStackProtocolImplementation(engine as any); + const services: Record = { objectql: engine, data: engine, protocol }; + const get = (n: string) => services[n] ?? null; + const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) }; + dispatcher = new HttpDispatcher(kernel); + (dispatcher as any).timedResolveExecutionContext = async () => ({ userId: 'u_member', isSystem: false }); +}, 60_000); + +/** `POST /api/v1/actions/` as `@objectstack/hono`'s catch-all hands it to `dispatch()`. */ +async function postAction(rest: string) { + const path = `/actions/${rest}`; + const request = new Request(`http://localhost${PREFIX}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }); + const res: any = await dispatcher.dispatch('POST', path, {}, {}, { request } as any, PREFIX); + return res.response as { status: number; body: any }; +} + +const rowIds = async (object: string) => + ((await engine.find(object, { fields: ['id'] })) as Array<{ id: string }>).map((r) => r.id).sort(); + +describe('an action\'s ctx.engine.delete whose cascade a dependent\'s failing roll-up stops is refused', () => { + it('the account\'s delete: the action answers a 400 refusal, the body stopped there, and nothing is deleted', async () => { + const response = await postAction(`${ACCOUNT}/delete_account`); + expect(response.status, JSON.stringify(response.body)).toBe(400); + expect(response.body?.success, JSON.stringify(response.body)).toBe(false); + expect(response.body?.error?.message).toContain('The delete of "qca_account" did not complete'); + expect(bodyRanOn).not.toContain('delete_account'); + expect(await rowIds(ACCOUNT)).toEqual(['a1']); + expect(await rowIds(LINE)).toEqual(['l1', 'l2', 'l3']); + }); + + it('CONTROL a line\'s own delete landed: the action answers its body\'s result, the line is gone', async () => { + const response = await postAction(`${ACCOUNT}/delete_line`); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(JSON.stringify(response.body)).toContain('"deleted":"l3"'); + expect(bodyRanOn).toContain('delete_line'); + expect(await rowIds(LINE)).toEqual(['l1', 'l2']); + }); +});