From 7ec6d1eb2111202d3f4add28028a111dc20edd36 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 04:46:34 +0000 Subject: [PATCH] test(runtime): pin ActionEngineFacade.delete's partial-failure shape against a datastore-side rejection `ActionEngineFacade.delete`'s member doc declares that a failure part-way through the array form leaves the ids before it deleted, the ids after it untouched, and delivers the rejection that stopped it. The existing `action-engine-facade-nullish-id.test.ts` reaches the sequential await loop, but every rejection it pins is the dispatch predicate's malformed-argument refusal. The causes the sentence exists for are the opposite kind: a well-formed by-id delete the datastore refuses. Adds a pin driving `buildActionEngineFacade` against an engine double whose `delete` accepts dispatch (it calls the producer's own `assertEngineDeleteDispatch`) and then refuses a well-formed mid-array id for a datastore reason. The double records every ATTEMPT as well as every deletion, so "the ids after it are untouched" is read off the calls the arm actually made. The rejection is pinned by object identity, plus the code/status envelope it carries. No behaviour change: tests and the regenerated pinned ledger only. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c --- ...gine-facade-delete-partial-failure.test.ts | 180 ++++++++++++++++++ scripts/engine-double-contract.pinned.json | 5 + 2 files changed, 185 insertions(+) create mode 100644 packages/runtime/src/action-engine-facade-delete-partial-failure.test.ts diff --git a/packages/runtime/src/action-engine-facade-delete-partial-failure.test.ts b/packages/runtime/src/action-engine-facade-delete-partial-failure.test.ts new file mode 100644 index 0000000000..0542492416 --- /dev/null +++ b/packages/runtime/src/action-engine-facade-delete-partial-failure.test.ts @@ -0,0 +1,180 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#17619] `ActionEngineFacade.delete`'s PARTIAL-FAILURE shape, pinned against + * the rejection cause the contract sentence is actually about. + * + * ## The sentence under pin + * + * `ActionEngineFacade.delete`'s member doc (`packages/spec/src/ui/ + * action-params.zod.ts`, declared by #15117) says of the array form: + * + * > There is no transaction around the set: a failure part-way through leaves + * > the ids before it deleted and the ids after it untouched, and the rejection + * > a caller sees is the one that stopped it. + * + * Three separable promises — earlier ids DELETED, later ids NEVER CALLED, and + * the stopping rejection delivered AS-IS — and #17619 filed the observation + * that nothing held the arm to them. + * + * ## What the sibling file already covers, and the narrow gap this closes + * + * `action-engine-facade-nullish-id.test.ts` (#17620) drives + * `['case_1', null, 'case_3']` and does reach the sequential await loop: the + * facade carries NO pre-guard — #17620 REMOVED the `if (id != null)` that used + * to open the loop — so that rejection is already `ql.delete`'s own, raised by + * the engine's dispatch predicate on a malformed `where.id`. + * + * What is left unpinned is the rejection's CAUSE, and the cause is what a + * refactor can discriminate on. Every rejection pinned there is + * `ENGINE_DELETE_REJECT_MESSAGE` — a MALFORMED-ARGUMENT refusal, raised + * before the driver is asked to do anything. The causes the contract sentence + * exists for are the opposite kind: a WELL-FORMED by-id delete that the + * datastore refuses — a permission denial, a row lock, a driver error. An arm + * that caught rejections, re-threw the dispatch refusal and swallowed the rest + * would keep every case in that file green while silently continuing past a + * permission denial to delete rows the caller was told were untouched: the one + * violation of this contract that is invisible to the caller. + * + * So this file pins the same three promises against a WELL-FORMED id that the + * engine double refuses for a datastore reason, and it pins the "never called" + * half with an instrument the sibling does not have: the double records every + * ATTEMPT, not only the deletions that succeeded, so "the ids after it are + * untouched" is read off the calls the arm actually made rather than inferred + * from an empty result. + * + * ⚠️ The as-is promise is pinned by OBJECT IDENTITY (`toBe`), not by message + * equality. A wrapper that copied the message across would satisfy a message + * comparison while destroying the `code`/`status` envelope an action handler + * catches on — so the envelope is asserted too, on the object that came out. + * + * @see packages/runtime/src/action-execution.ts — `buildActionEngineFacade`. + * @see packages/runtime/src/action-engine-facade-nullish-id.test.ts — the + * malformed-id half of the same loop, whose cases this file does not touch. + */ + +import { describe, it, expect } from 'vitest'; +import { assertEngineDeleteDispatch } from '@objectstack/metadata-core'; +import { buildActionEngineFacade } from './action-execution.js'; + +const deps: any = { resolveService: () => undefined, getObjectQL: async () => undefined }; + +/** + * A datastore-side refusal of a WELL-FORMED by-id delete — the population the + * contract sentence is about (permission denial, row lock, driver error), and + * the one thing `ENGINE_DELETE_REJECT_MESSAGE` is NOT. Carries the + * ADR-0112 envelope so the as-is assertion has something to be about beyond the + * message text. + */ +class DatastoreRefusal extends Error { + readonly code = 'PERMISSION_DENIED'; + readonly status = 403; + constructor(id: string) { + super(`row ${id} is not deletable by this caller`); + this.name = 'DatastoreRefusal'; + } +} + +interface DeleteAttempt { + object: string; + id: unknown; + context: unknown; +} + +interface RecordingEngine { + attempted: DeleteAttempt[]; + deleted: DeleteAttempt[]; + insert(object: string, data: Record): Promise<{ id: unknown }>; + find(object: string, options?: Record): Promise>>; + count(object: string, options?: Record): Promise; + delete(object: string, options?: Record): Promise<{ ok: boolean }>; +} + +/** + * An engine double bound to the REAL engine's dispatch contract — the call to + * {@link assertEngineDeleteDispatch} is the producer's own predicate, never a + * mirrored `if` (`scripts/check-engine-double-contract.mjs`). Every id below is + * a truthy scalar, so that predicate ACCEPTS all of them: the refusals this + * double raises come from `refusals`, one layer past dispatch, which is the + * whole point of the file. + * + * `attempted` records every delete that reached the double; `deleted` records + * only the ones it let through. The pair is what separates "the id after the + * failure was not deleted" from "the id after the failure was never asked for". + */ +function makeEngine(refusals: Record = {}): RecordingEngine { + const attempted: DeleteAttempt[] = []; + const deleted: DeleteAttempt[] = []; + return { + attempted, + deleted, + async insert(_object: string, data: Record) { + return { id: data?.id ?? 'rec_new' }; + }, + async find(_object: string, _options?: Record) { + return []; + }, + async count(_object: string, _options?: Record) { + return 0; + }, + async delete(object: string, options?: Record) { + assertEngineDeleteDispatch(options); + const where = (options as { where?: Record } | undefined)?.where; + const id = where?.id; + const context = (options as { context?: unknown } | undefined)?.context; + attempted.push({ object, id, context }); + const refusal = typeof id === 'string' ? refusals[id] : undefined; + if (refusal) throw refusal; + deleted.push({ object, id, context }); + return { ok: true }; + }, + }; +} + +/** Drive the arm and hand back whatever it rejected with, or `undefined`. */ +async function rejection(run: Promise): Promise { + return run.then( + () => undefined, + (e: unknown) => e, + ); +} + +const ids = (calls: DeleteAttempt[]): unknown[] => calls.map((c) => c.id); + +describe('#17619 — a mid-array ql.delete rejection: the declared partial-failure shape', () => { + it('stops at the refused id — earlier ids deleted, later ids NEVER CALLED, rejection as-is', async () => { + const refusal = new DatastoreRefusal('case_2'); + const ql = makeEngine({ case_2: refusal }); + const engine = buildActionEngineFacade(deps, ql, { userId: 'u1', tenantId: 'org_acme' }); + + const err = await rejection(engine.delete('crm_case', ['case_1', 'case_2', 'case_3'])); + + // ③ "the rejection a caller sees is the one that stopped it" — the SAME + // object the producer threw: not wrapped, not re-thrown as a copy, + // not swallowed. Identity first, then the envelope it carries. + expect(err).toBe(refusal); + expect((err as DatastoreRefusal).code).toBe('PERMISSION_DENIED'); + expect((err as DatastoreRefusal).status).toBe(403); + + // ① "leaves the ids before it deleted" + expect(ids(ql.deleted)).toEqual(['case_1']); + + // ② "and the ids after it untouched" — read off the ATTEMPTS, so an arm + // that carried on and was merely refused again could not pass it. + expect(ids(ql.attempted)).toEqual(['case_1', 'case_2']); + }); + + it('delivers the FIRST rejection — a later refusal is never reached, let alone reported', async () => { + const first = new DatastoreRefusal('case_2'); + const second = new DatastoreRefusal('case_3'); + const ql = makeEngine({ case_2: first, case_3: second }); + const engine = buildActionEngineFacade(deps, ql, { userId: 'u1' }); + + const err = await rejection(engine.delete('crm_case', ['case_1', 'case_2', 'case_3'])); + + expect(err).toBe(first); + expect(err).not.toBe(second); + expect(ids(ql.attempted)).toEqual(['case_1', 'case_2']); + expect(ids(ql.deleted)).toEqual(['case_1']); + }); +}); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 9db49c9bd5..42f64f365d 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3386,6 +3386,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/runtime/src/action-engine-facade-delete-partial-failure.test.ts", + "verb": "delete", + "pinned": 1 + }, { "file": "packages/runtime/src/action-engine-facade-nullish-id.test.ts", "verb": "delete",