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..7f015946ef7 --- /dev/null +++ b/.changeset/29291-cascade-summary-refusal-rolled-back.md @@ -0,0 +1,21 @@ +--- +'@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-②: 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: + +- **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/engine-cascade-summary-rollback.test.ts b/packages/objectql/src/engine-cascade-summary-rollback.test.ts new file mode 100644 index 00000000000..47b6b981f47 --- /dev/null +++ b/packages/objectql/src/engine-cascade-summary-rollback.test.ts @@ -0,0 +1,218 @@ +// 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), 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. + * + * 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 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() {}, + }; + 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' } }).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' } }).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 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 }).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 + .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(); + 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' } }).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 }).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..6634bd5480a 100644 --- a/packages/objectql/src/summary-errors.ts +++ b/packages/objectql/src/summary-errors.ts @@ -72,3 +72,62 @@ 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, + // 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.', + ); +} + +/** {@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..e15c6277c20 --- /dev/null +++ b/packages/rest/src/data-delete-cascade-summary-rollback.test.ts @@ -0,0 +1,149 @@ +// 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 `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. + * + * 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 } 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; + +beforeAll(async () => { + 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 () => { + try { await engine.destroy(); } catch { /* noop */ } + rmSync(dir, { recursive: true, force: true }); +}); + +const rowIds = async (object: string) => + ((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 () => { + const res = await call('DELETE', '/:object/:id', { params: { object: ACCOUNT, id: 'a1' } }); + 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']); + }); + + 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']); + }); +}); 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']); + }); +});