diff --git a/.changeset/22301-verify-observe-writes-door.md b/.changeset/22301-verify-observe-writes-door.md new file mode 100644 index 00000000000..9c53c699970 --- /dev/null +++ b/.changeset/22301-verify-observe-writes-door.md @@ -0,0 +1,18 @@ +--- +'@objectstack/verify': minor +--- + +feat(verify): the handle gains a door that observes the writes the engine receives, a hook's refused write included + +Clause-②: yes (widening) + +An app's tests can see what a hook handed the engine through the in-process handle, so a suite no longer wraps the booted engine's write methods in a spy to find a refused write, to wait for a fire-and-forget hook, or to stage a refusal. + +- **`stack.observeWrites(fn)`** runs `fn` and resolves with every insert, update and delete the booted engine received meanwhile, in the order each reached it. Each entry (`ObservedWrite`) carries `object`, `operation`, the payload (`data`), the update or delete selection (`where`), `multi`, the execution context the write carried (`context`), and the engine's answer (`outcome`): `pending`, then `resolved` with its result, or `refused` with the error the engine threw, unchanged, and that error's own `code`. +- **What it is.** One global middleware on the engine's own chain (`registerMiddleware`), added on the first call. It passes every operation on unchanged and decides nothing. So it sits after the write gates the boot registered. Every write those gates let through is recorded, from any door (`hooks.run`, `seed`, a flow, a route, a hook's `ctx.api`), and so is every refusal the engine raises past them: a declared validation rule (`VALIDATION_FAILED`), a hook's throw, a unique conflict (`DUPLICATE_RECORD`). A write a gate refuses, the permission check among them, never reaches it and leaves no entry. Its refusal still reaches whoever made the write. +- **An outcome is the engine's answer to that call, not a statement about stored rows.** A write an `after*` hook refuses was refused after the driver wrote, and its row can stay. Read rows back with `stack.rows`. +- **The window is time, not cause.** A write the stack makes for its own reasons while `fn` runs is recorded too, so filter by `object`. +- **An `async: true` hook.** Its write reaches the engine after the write that fired it has returned. `fn` is handed the live observation (`WriteObservation`), and `observation.settled(match)` resolves with the first write `match` accepts once the engine has answered it. It rejects after `timeoutMs` (default 2000), naming every write observed, or when the observation closes first. A hook that writes nothing leaves nothing to observe. +- **Staging a refusal.** Arrange one the engine makes itself: seed the row that already holds a unique value the hook will write, or set up the state a declared validation rule refuses. The handle has no switch that refuses a write in the engine's place. +- A `fn` that is not a function, a `match` that is not a function, and a `timeoutMs` that is not a positive number are refused with `code: 'INVALID_REQUEST'`, `status: 400`. A rejection of `fn` is rethrown unchanged once the observation has closed. No error code is added. +- New exported types: `ObservedWrite`, `ObservedWriteOutcome`, `WriteObservation`. diff --git a/packages/qa/dogfood/test/rls-runner.test.ts b/packages/qa/dogfood/test/rls-runner.test.ts index 1f2aff3774e..8628a117f19 100644 --- a/packages/qa/dogfood/test/rls-runner.test.ts +++ b/packages/qa/dogfood/test/rls-runner.test.ts @@ -136,6 +136,7 @@ function fakeStack(opts: FakeOpts): VerifyStack { automation: undefined as never, seed: undefined as never, rows: undefined as never, + observeWrites: undefined as never, metadata: undefined as never, tenancy: undefined as never, }; diff --git a/packages/verify/README.md b/packages/verify/README.md index d33ece85f8c..bb04e58a531 100644 --- a/packages/verify/README.md +++ b/packages/verify/README.md @@ -76,9 +76,9 @@ await stack.stop(); Every `VerifyStack` carries it; nothing extra to boot. Each method is a thin facade over a door the kernel wired at boot — the ObjectQL engine's own write, -dry-run and read calls, the runtime's `/automation` and `/actions` routes -driven in-process, the automation engine's condition evaluator, the -`SchemaRegistry`, the `tenancy` service — with **zero +dry-run and read calls and its middleware chain, the runtime's `/automation` +and `/actions` routes driven in-process, the automation engine's condition +evaluator, the `SchemaRegistry`, the `tenancy` service — with **zero re-implemented semantics**: the handle assembles no execution context, orders no hooks, evaluates no permission. What the engine does is what you assert on. @@ -137,6 +137,16 @@ expect(await stack.automation.evaluateCondition( const [acc] = await stack.seed('crm_account', [{ name: 'Globex' }]); const mine = await stack.rows('crm_opportunity', { crm_account: acc.id }, { as: rep }); +// What a hook handed the engine: every write the engine receives while the +// function runs, and what it answered. A hook's write the engine refused +// leaves no row; here it is, refused, with the engine's own error. +const writes = await stack.observeWrites(async (observation) => { + await stack.hooks.run('crm_lead', 'insert', { last_name: 'Ada', company: 'Globex' }, { as: rep }); + // An `async: true` hook writes after the triggering write has returned. + await observation.settled((w) => w.object === 'crm_task'); +}); +const refused = writes.filter((w) => w.outcome.status === 'refused'); + // What the boot actually holds. stack.metadata.object('crm_opportunity')?.fields; // system columns injected stack.metadata.items('permission'); // the registry's singular names @@ -164,6 +174,22 @@ await stack.stop(); touched, with `INVALID_REQUEST` / `400` (a caller named twice, or a `hooks.updateWhere` the engine would write by id). Assert on `code` (and `status` / `statusCode`), never on a message alone. +- `observeWrites(fn)` records each insert, update and delete the engine + receives while `fn` runs (`object`, `operation`, `data`, `where`, `multi`, + `context`) and what the engine answered (`pending`, then `resolved` with its + result or `refused` with its error and that error's own `code`). It is one + middleware on the engine's own chain that passes everything on unchanged, + so it sits after the write gates the boot registered: a write a gate refuses + (the permission check among them) never reaches it and leaves no entry, + while every refusal past the gates (a validation rule, a hook's throw, a + unique conflict) is recorded. An outcome is the engine's answer to that call, + not a statement about stored rows: read those back with `rows`. The window is + time, not cause, so filter by `object`. `observation.settled(match)` waits + for a write that lands later, such as an `async: true` hook's (default + timeout 2000 ms). To stage a refusal of a hook's write, arrange one the + engine makes itself: seed the row that already holds a unique value, or set + up the state a declared validation rule refuses. The handle has no switch + that refuses a write in the engine's place. - Many files, one boot: `bootStackOnce(config, opts?)` memoises `bootStack` per `(config, opts)` object identity for the life of the process. Share it from one module, under vitest `isolate: false`, and never `stop()` a stack other @@ -221,7 +247,7 @@ run" must never read like "nothing to find". - `bootStack(config, opts?)` → `VerifyStack` (`api` / `raw` / `signIn` / `signUp` / `apiAs` / `stop`, plus the handle: `hooks.run` / `hooks.updateWhere` / `validate` / `flows.run` / `flows.resume` / `actions.run` / - `automation.evaluateCondition` / `seed` / `rows` / `metadata` / `tenancy` / `contextFor`). + `automation.evaluateCondition` / `seed` / `rows` / `observeWrites` / `metadata` / `tenancy` / `contextFor`). - `bootStackOnce(config, opts?)` → the same, memoised per `(config, opts)` identity for the process. - `deriveCrudCases(config)` → the auto-derived round-trip cases (write one, read one, assert) for every object. - `runCrudVerification(stack, token, config)` → `VerifyReport`; `formatReport(report)` for a log summary. diff --git a/packages/verify/src/handle.observe-writes.test.ts b/packages/verify/src/handle.observe-writes.test.ts new file mode 100644 index 00000000000..f21a5fdc216 --- /dev/null +++ b/packages/verify/src/handle.observe-writes.test.ts @@ -0,0 +1,447 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22301 item 6 — observe what a hook handed the engine. + * + * `observeWrites(fn)` records the writes the booted engine receives while `fn` + * runs and what the engine answered each one. It is one middleware on the + * engine's own chain that passes every operation straight on, so each claim + * below is read off the engine's own answer: the validation rule's refusal, + * the unique conflict, a hook's swallowed failure, a fire-and-forget hook's + * late write. Nothing here is decided by the handle. + * + * The three needs the card names, and what holds each: + * + * 1. A refused write leaves no row. The observation shows it, refused, with + * the engine's own error. + * 2. An `async: true` hook's completion is invisible to the write that fired + * it. `settled(match)` waits for the write that hook makes. + * 3. A refusal cannot be staged without a spy. It is staged through the app's + * own rules: seeded state the unique field refuses, a value the declared + * validation rule refuses. + * + * And the boundary the observer's position fixes, pinned so the documentation + * cannot drift from it: a write a gate refuses (here, the permission check) + * never reaches the observer. + * + * The fixture is neutral. Writing an `ow_order` fires afterInsert hooks that + * write elsewhere through `ctx.api`, the hook's own door to the engine, and + * swallow what the engine answers into `hookCaught`, so the triggering write + * succeeds either way. That is the shape whose refusal used to be invisible. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { PermissionSetSchema } from '@objectstack/spec/security'; + +import { bootStack, type VerifyStack } from './harness.js'; +import type { EngineRow, ObservedWrite, WriteObservation } from './handle.js'; + +// Booting the full in-process stack runs well past vitest's 5s default. +const BOOT_TIMEOUT = 120_000; + +const ORDER = 'ow_order'; +const TASK = 'ow_task'; +const VAULT = 'ow_vault'; + +/** What each hook's own `ctx.api` call answered: the error's code, or `ok`. */ +const hookCaught = new Map(); + +/** + * The latch the fire-and-forget hook waits on before it writes, so "the write + * has not landed when the triggering write returns" is a fact of the test, + * not of the machine's speed. Released by the test. + */ +let releaseAsync: () => void = () => undefined; +let asyncGate: Promise = Promise.resolve(); +const armAsyncGate = () => { + asyncGate = new Promise((resolve) => { + releaseAsync = resolve; + }); +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type HookCtx = { input?: { data?: Record }; api: any }; + +/** Write through the hook's `ctx.api` and record what the engine answered, swallowing a refusal. */ +async function hookWrite(ctx: HookCtx, object: string, row: EngineRow): Promise { + const name = String(row.name); + try { + await ctx.api.object(object).insert(row); + hookCaught.set(name, 'ok'); + } catch (e) { + hookCaught.set(name, String((e as { code?: unknown })?.code)); + } +} + +const Order = ObjectSchema.create({ + name: ORDER, + sharingModel: 'public_read_write', + label: 'Order', + pluralLabel: 'Orders', + fields: { + name: Field.text({ label: 'Name', required: true }), + /** Which hook acts on this order: `task`, `vault` or `async`. */ + mode: Field.text({ label: 'Mode' }), + task_title: Field.text({ label: 'Task title' }), + task_code: Field.text({ label: 'Task code' }), + }, +}); + +const Task = ObjectSchema.create({ + name: TASK, + sharingModel: 'public_read_write', + label: 'Task', + pluralLabel: 'Tasks', + fields: { + name: Field.text({ label: 'Name', required: true }), + title: Field.text({ label: 'Title' }), + // One holder per installation: seeding a holder stages the conflict. + code: Field.text({ label: 'Code', unique: 'global' }), + }, + validations: [ + { + name: 'title_not_blocked', + type: 'script', + severity: 'error', + message: 'This title is blocked', + condition: "record.title == 'blocked'", + }, + ], +}); + +/** Granted to nobody but the platform admin. */ +const Vault = ObjectSchema.create({ + name: VAULT, + sharingModel: 'public_read_write', + label: 'Vault', + pluralLabel: 'Vaults', + fields: { name: Field.text({ label: 'Name', required: true }) }, +}); + +const memberSet = PermissionSetSchema.parse({ + name: 'ow_member_default', + label: 'Observe-writes fixture member (default)', + isDefault: true, + objects: { + [ORDER]: { allowRead: true, allowCreate: true, allowEdit: true }, + [TASK]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, + }, +}); + +const fixtureStack = defineStack({ + manifest: { + id: 'com.objectstack.verify.observe-writes', + namespace: 'ow', + version: '0.0.0', + type: 'app', + name: 'Verify Observe-Writes Fixture', + description: 'An order whose afterInsert hooks write a task, a vault row, or a task later.', + }, + objects: [Order, Task, Vault], + hooks: [ + { + name: 'ow_order_task', + object: ORDER, + events: ['afterInsert'], + handler: async (ctx: HookCtx) => { + const order = ctx.input?.data ?? {}; + if (order.mode !== 'task') return; + await hookWrite(ctx, TASK, { + name: order.name, + title: order.task_title, + ...(order.task_code ? { code: order.task_code } : {}), + }); + }, + }, + { + name: 'ow_order_vault', + object: ORDER, + events: ['afterInsert'], + handler: async (ctx: HookCtx) => { + const order = ctx.input?.data ?? {}; + if (order.mode !== 'vault') return; + await hookWrite(ctx, VAULT, { name: order.name }); + }, + }, + { + name: 'ow_order_async_task', + object: ORDER, + events: ['afterInsert'], + // Fire-and-forget: the triggering write does not wait for it. + async: true, + handler: async (ctx: HookCtx) => { + const order = ctx.input?.data ?? {}; + if (order.mode !== 'async') return; + await asyncGate; + await hookWrite(ctx, TASK, { name: `${order.name}-async`, title: order.task_title }); + }, + }, + ], + permissions: [memberSet], +} as never); + +let stack: VerifyStack; +let admin: string; +let member: string; +let memberId: string; + +beforeAll(async () => { + stack = await bootStack(fixtureStack); + admin = await stack.signIn(); + // The first user is the seeded dev admin, so this sign-up is a plain member. + member = await stack.signUp('ow-member@verify.test'); + memberId = String((await stack.contextFor(member)).userId); +}, BOOT_TIMEOUT); + +afterAll(async () => { + releaseAsync(); + await stack?.stop().catch(() => undefined); +}); + +/** Unique per call, so no assertion sees another test's rows. */ +const uniq = (prefix: string): string => `${prefix}-${Math.random().toString(36).slice(2, 8)}`; + +/** The rejection `p` settles with, or a failure when it resolves. */ +async function refusalOf(p: Promise): Promise { + try { + await p; + } catch (e) { + return e; + } + throw new Error('expected a refusal, and the call resolved'); +} + +/** The ADR-0112 pair: `code`, and `status` (or the engine's `statusCode`). */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const envelope = (e: unknown) => ({ code: (e as any)?.code, status: (e as any)?.statusCode ?? (e as any)?.status }); + +/** The observed writes to the fixture's objects: the window also holds the stack's own writes. */ +const fixtureWrites = (writes: readonly ObservedWrite[]): ObservedWrite[] => + writes.filter((w) => w.object === ORDER || w.object === TASK || w.object === VAULT); + +/** A write's first (or only) payload row. */ +const rowOf = (w: ObservedWrite): EngineRow => (Array.isArray(w.data) ? w.data[0] : w.data) ?? {}; + +/** `object` + `status` (+ `code` when refused), the shape most assertions read. */ +const summary = (w: ObservedWrite) => ({ + object: w.object, + operation: w.operation, + status: w.outcome.status, + ...(w.outcome.status === 'refused' ? { code: w.outcome.code } : {}), +}); + +describe('item 6 — a refused write leaves no row, and the observation shows it', () => { + it("a hook's write the declared validation rule refuses is recorded refused, with the engine's own error", async () => { + const name = uniq('order'); + let order: EngineRow | undefined; + const writes = await stack.observeWrites(async () => { + order = await stack.hooks.run(ORDER, 'insert', { name, mode: 'task', task_title: 'blocked' }, { as: member }); + }); + + // The hook swallowed the refusal, so the triggering write succeeded and + // the task left no row: before this door, nothing in the test said why. + expect(typeof order?.id).toBe('string'); + expect(hookCaught.get(name)).toBe('VALIDATION_FAILED'); + expect(await stack.rows(TASK, { name }), 'the refused write left no row').toEqual([]); + + const seen = fixtureWrites(writes); + expect(seen.map(summary)).toEqual([ + { object: ORDER, operation: 'insert', status: 'resolved' }, + { object: TASK, operation: 'insert', status: 'refused', code: 'VALIDATION_FAILED' }, + ]); + const task = seen[1]; + expect(rowOf(task)).toMatchObject({ name, title: 'blocked' }); + expect(task.context?.userId, "the hook's write carried the triggering user").toBe(memberId); + expect(task.outcome.status === 'refused' && task.outcome.error).toMatchObject({ + code: 'VALIDATION_FAILED', + fields: [{ field: '_record', code: 'rule_violation' }], + }); + + // Control that DISCRIMINATES: the same hook with a title the rule accepts + // is recorded resolved, and its row is there. + const okName = uniq('order'); + const okWrites = await stack.observeWrites(async () => { + await stack.hooks.run(ORDER, 'insert', { name: okName, mode: 'task', task_title: 'fine' }, { as: member }); + }); + expect(fixtureWrites(okWrites).map(summary)).toEqual([ + { object: ORDER, operation: 'insert', status: 'resolved' }, + { object: TASK, operation: 'insert', status: 'resolved' }, + ]); + expect((await stack.rows(TASK, { name: okName })).map((r) => r.title)).toEqual(['fine']); + }); + + it('the observer changes nothing: the caller gets the very error recorded, and the same call unobserved answers the same', async () => { + const name = uniq('task'); + let caught: unknown; + const writes = await stack.observeWrites(async () => { + caught = await refusalOf(stack.hooks.run(TASK, 'insert', { name, title: 'blocked' }, { as: member })); + }); + const [task] = fixtureWrites(writes); + expect(task.outcome.status).toBe('refused'); + expect(task.outcome.status === 'refused' && task.outcome.error, 'the same error object, not a copy').toBe(caught); + + const unobserved = await refusalOf(stack.hooks.run(TASK, 'insert', { name, title: 'blocked' }, { as: member })); + expect(envelope(unobserved)).toEqual(envelope(caught)); + expect(await stack.rows(TASK, { name })).toEqual([]); + }); +}); + +describe('item 6 — an async: true hook', () => { + it('its write lands after the triggering write returned, and settled() waits for it', async () => { + armAsyncGate(); + const name = uniq('order'); + const asyncName = `${name}-async`; + const writes = await stack.observeWrites(async (observation) => { + await stack.hooks.run(ORDER, 'insert', { name, mode: 'async', task_title: 'later' }, { as: member }); + // The triggering write has returned, and the hook has not written yet: + // this is the completion the write itself cannot show. + expect(observation.writes.some((w) => w.object === TASK)).toBe(false); + expect(await stack.rows(TASK, { name: asyncName })).toEqual([]); + + releaseAsync(); + const task = await observation.settled((w) => w.object === TASK && rowOf(w).name === asyncName); + expect(summary(task)).toEqual({ object: TASK, operation: 'insert', status: 'resolved' }); + }); + expect(fixtureWrites(writes).map((w) => w.object)).toEqual([ORDER, TASK]); + expect((await stack.rows(TASK, { name: asyncName })).map((r) => r.title)).toEqual(['later']); + }); + + it("settled() also answers a fire-and-forget write the engine refuses, which leaves no row", async () => { + armAsyncGate(); + const name = uniq('order'); + const asyncName = `${name}-async`; + await stack.observeWrites(async (observation) => { + await stack.hooks.run(ORDER, 'insert', { name, mode: 'async', task_title: 'blocked' }, { as: member }); + releaseAsync(); + const task = await observation.settled((w) => w.object === TASK && rowOf(w).name === asyncName); + expect(summary(task)).toEqual({ object: TASK, operation: 'insert', status: 'refused', code: 'VALIDATION_FAILED' }); + }); + expect(await stack.rows(TASK, { name: asyncName })).toEqual([]); + }); +}); + +describe("item 6 — a refusal staged through the app's own rules, never a switch", () => { + it('seeded state: the row that already holds the unique value makes the engine refuse the hook\'s write', async () => { + const code = uniq('CODE'); + await stack.seed(TASK, [{ name: uniq('holder'), code }]); + + const name = uniq('order'); + const writes = await stack.observeWrites(async () => { + await stack.hooks.run(ORDER, 'insert', { name, mode: 'task', task_title: 'dup', task_code: code }, { as: member }); + }); + expect(hookCaught.get(name)).toBe('DUPLICATE_RECORD'); + expect(fixtureWrites(writes).map(summary)).toEqual([ + { object: ORDER, operation: 'insert', status: 'resolved' }, + { object: TASK, operation: 'insert', status: 'refused', code: 'DUPLICATE_RECORD' }, + ]); + expect(await stack.rows(TASK, { code }), 'only the seeded holder').toHaveLength(1); + + // Control: a code no row holds is written. + const freshName = uniq('order'); + await stack.hooks.run(ORDER, 'insert', { name: freshName, mode: 'task', task_title: 'dup', task_code: uniq('CODE') }, { as: member }); + expect(hookCaught.get(freshName)).toBe('ok'); + }); +}); + +describe('item 6 — the boundary: a write a gate refuses never reaches the observer', () => { + it("a hook's write the permission check refuses leaves no entry; the same write the gate admits is recorded", async () => { + const name = uniq('order'); + const writes = await stack.observeWrites(async () => { + await stack.hooks.run(ORDER, 'insert', { name, mode: 'vault' }, { as: member }); + }); + // The refusal reached the hook that made the write... + expect(hookCaught.get(name)).toBe('PERMISSION_DENIED'); + expect(await stack.rows(VAULT, { name })).toEqual([]); + // ...and never reached the observer, which sits after that gate. + expect(fixtureWrites(writes).map(summary)).toEqual([{ object: ORDER, operation: 'insert', status: 'resolved' }]); + + // Control: the admin is granted the vault, so the same hook's write passes + // the gate and is recorded. + const adminName = uniq('order'); + const adminWrites = await stack.observeWrites(async () => { + await stack.hooks.run(ORDER, 'insert', { name: adminName, mode: 'vault' }, { as: admin }); + }); + expect(hookCaught.get(adminName)).toBe('ok'); + expect(fixtureWrites(adminWrites).map(summary)).toEqual([ + { object: ORDER, operation: 'insert', status: 'resolved' }, + { object: VAULT, operation: 'insert', status: 'resolved' }, + ]); + }); +}); + +describe('item 6 — what an entry carries, and the window', () => { + it('every door is observed: a seed, a predicate update and a delete, each with its own selection and caller', async () => { + const name = uniq('task'); + let seededId = ''; + const writes = await stack.observeWrites(async () => { + const [seeded] = await stack.seed(TASK, [{ name, title: 'one' }]); + seededId = String(seeded.id); + await stack.hooks.updateWhere(TASK, { name }, { title: 'two' }, { as: member }); + await stack.hooks.run(TASK, 'delete', { id: seededId }, { system: true }); + }); + const seen = fixtureWrites(writes); + expect(seen.map(summary)).toEqual([ + { object: TASK, operation: 'insert', status: 'resolved' }, + { object: TASK, operation: 'update', status: 'resolved' }, + { object: TASK, operation: 'delete', status: 'resolved' }, + ]); + const [insert, update, del] = seen; + expect(insert.context?.isSystem, 'seed writes as the system').toBe(true); + expect(insert.where).toBeUndefined(); + expect(update).toMatchObject({ where: { name }, multi: true, data: { title: 'two' } }); + expect(update.context?.userId).toBe(memberId); + expect(update.outcome.status === 'resolved' && update.outcome.result, "the engine's affected-row count").toBe(1); + expect(del).toMatchObject({ where: { id: seededId }, multi: false, data: undefined }); + expect(del.context?.isSystem).toBe(true); + }); + + it('the window closes with fn: a later write is not recorded, and settled() after the close rejects', async () => { + let live: readonly ObservedWrite[] = []; + let late: WriteObservation | undefined; + const writes = await stack.observeWrites(async (observation) => { + live = observation.writes; + late = observation; + await stack.hooks.run(TASK, 'insert', { name: uniq('task'), title: 'inside' }, { as: member }); + }); + const countAtClose = live.length; + expect(fixtureWrites(writes)).toHaveLength(1); + + await stack.hooks.run(TASK, 'insert', { name: uniq('task'), title: 'outside' }, { as: member }); + expect(live, 'nothing is added once the observation closed').toHaveLength(countAtClose); + expect(fixtureWrites(writes)).toHaveLength(1); + if (!late) throw new Error('fn was never handed its observation'); + await expect(late.settled(() => true)).rejects.toBeInstanceOf(Error); + }); + + it('settled() rejects on its timeout, naming the writes it saw, and fn is free to end', async () => { + const name = uniq('task'); + await stack.observeWrites(async (observation) => { + await stack.hooks.run(TASK, 'insert', { name, title: 'only' }, { as: member }); + const waited = await refusalOf(observation.settled((w) => w.object === 'ow_nothing', { timeoutMs: 50 })); + expect(waited).toBeInstanceOf(Error); + expect((waited as Error).message).toContain(`insert ${TASK}`); + }); + }); + + it('the observer is global: it keys no middleware to an object', async () => { + await stack.observeWrites(() => undefined); + const ql = (await stack.kernel.getServiceAsync('objectql')) as { hasObjectMiddleware(name: string): boolean }; + expect(ql.hasObjectMiddleware(TASK)).toBe(false); + }); +}); + +describe('item 6 — a malformed call is refused before anything is observed', () => { + it('observeWrites without a function, and settled without a match or with a bad timeout', async () => { + expect(envelope(await refusalOf(stack.observeWrites(undefined as never)))).toEqual({ code: 'INVALID_REQUEST', status: 400 }); + await stack.observeWrites(async (observation) => { + expect(envelope(await refusalOf(observation.settled(undefined as never)))).toEqual({ code: 'INVALID_REQUEST', status: 400 }); + expect(envelope(await refusalOf(observation.settled(() => true, { timeoutMs: 0 })))).toEqual({ + code: 'INVALID_REQUEST', + status: 400, + }); + }); + }); +}); diff --git a/packages/verify/src/handle.ts b/packages/verify/src/handle.ts index a6e175d1c0d..62a14c3b71e 100644 --- a/packages/verify/src/handle.ts +++ b/packages/verify/src/handle.ts @@ -58,6 +58,15 @@ // `@objectstack/core`'s `resolveAuthzContext`), the exact resolver every // dispatcher request goes through. The handle never assembles an // `ExecutionContext` by hand. +// observeWrites → the engine's own middleware chain +// (`registerMiddleware`, a member of the `IObjectQLEngine` contract). +// [#22301] One global middleware, registered on the first call and kept +// for the life of the stack, that passes every operation straight on and, +// while an observation is open, records each insert / update / delete it +// sees and what the engine answered it. It changes no payload, no answer +// and no error, so it re-derives nothing: what it can see is fixed by +// where it sits, after the write gates the boot registered. +// `handle.observe-writes.test.ts` pins it, and that boundary. // metadata → the engine's `SchemaRegistry`. // tenancy → the `tenancy` service AuthPlugin // registered at boot. @@ -73,7 +82,7 @@ import { SEED_WRITE_EXECUTION_CONTEXT, type ExecutionContext } from '@objectstac import type { ValidateDataResponse } from '@objectstack/spec/api'; import type { AutomationResult } from '@objectstack/spec/contracts'; import type { ServiceObject } from '@objectstack/spec/data'; -import { resolveEngineUpdateDispatch, type ObjectQL } from '@objectstack/objectql'; +import { resolveEngineUpdateDispatch, type ObjectQL, type OperationContext } from '@objectstack/objectql'; import type { TenancyService } from '@objectstack/plugin-auth'; import type { AutomationEngine } from '@objectstack/service-automation'; @@ -157,6 +166,59 @@ export interface FlowRunRef { runId?: string; } +/** + * [#22301] What the engine answered one observed write: `pending` until it + * answers, then `resolved` with the result as it left the engine's executor + * (before the outer write gates' post-phase, such as field-level masking), or + * `refused` with the error the engine threw, unchanged, and that error's own + * `code` (`undefined` when it carries none: the handle names no code itself). + */ +export type ObservedWriteOutcome = + | { status: 'pending' } + | { status: 'resolved'; result: unknown } + | { status: 'refused'; code: string | undefined; error: unknown }; + +/** [#22301] One write the booted engine received while an observation was open. */ +export interface ObservedWrite { + /** The object written, as the engine resolved its name. */ + readonly object: string; + readonly operation: 'insert' | 'update' | 'delete'; + /** + * The payload (one row, or the rows of a batch insert), copied when the + * write reached the observer: what the write gates handed on, their own + * fills (an `owner_id`) included, the field defaults and the hooks' changes + * not yet. `undefined` for a delete. + */ + readonly data: EngineRow | EngineRow[] | undefined; + /** The selection an update or a delete was handed (`where`); `undefined` for an insert. */ + readonly where: EngineRow | undefined; + /** `true` for a predicate write (`multi: true`). */ + readonly multi: boolean; + /** The execution context the write carried (`isSystem`, `userId`, …). */ + readonly context: ExecutionContext | undefined; + /** What the engine answered, set in place when it does. */ + readonly outcome: ObservedWriteOutcome; +} + +/** [#22301] The live observation `observeWrites` hands the function it runs. */ +export interface WriteObservation { + /** Every write observed so far, in the order each reached the engine. Grows while the observation is open. */ + readonly writes: readonly ObservedWrite[]; + /** + * Resolve with the first observed write `match` accepts, once the engine + * has answered it (`resolved` or `refused`): a write already observed, or + * one that reaches the engine later while the observation is open. This is + * how a test waits for an `async: true` hook's write, which reaches the + * engine after the write that fired it has returned. + * + * Rejects when no write `match` accepts has been answered within + * `timeoutMs` (default 2000), naming every write observed, and when the + * observation closes first. A `match` that throws rejects with its error; + * the engine's write never sees it. + */ + settled(match: (write: ObservedWrite) => boolean, opts?: { timeoutMs?: number }): Promise; +} + export interface VerifyHandle { /** * Resolve the execution context the platform resolves for `token` — user, @@ -343,6 +405,58 @@ export interface VerifyHandle { */ rows(object: string, where?: EngineRow, opts?: Partial): Promise; + /** + * [#22301] Observe the writes the booted engine receives while `fn` runs, + * and what the engine answered each one. That includes the writes a hook + * hands the engine through `ctx.api`, a refused one included, which leaves + * no row for `rows` to find. Resolves, once `fn` has settled, with every + * write observed ({@link ObservedWrite}), in the order each reached the + * engine. `fn` is handed the live observation ({@link WriteObservation}), + * so it can also wait for a write that lands after the call that fired it + * has returned. + * + * The observer is one middleware on the engine's own chain + * (`registerMiddleware`, a global registration), added on the first call + * and kept for the life of the stack. It passes every operation straight + * on: it changes no payload, no answer and no error. So what it sees is + * fixed by where it sits, after the write gates the boot registered: + * + * - **Seen:** every insert, update and delete those gates let through, from + * any door (`hooks.run`, `seed`, a flow, a route, a hook's `ctx.api`), and + * every refusal the engine raises past them, such as a declared + * validation rule (`VALIDATION_FAILED`), a hook's throw, or a unique + * conflict (`DUPLICATE_RECORD`). + * - **Not seen:** a write a gate refuses (the permission check among them) + * never reaches the observer and leaves no entry. Its refusal still + * reaches whoever made the write: a hook that does not swallow it fails + * the write that fired it. Nor is a write the engine refuses before its + * chain (to an external datasource, or across drivers inside a + * transaction). + * + * An outcome is the engine's answer to that one call, not a statement + * about stored rows: a write an `after*` hook refuses was refused after the + * driver wrote, and its row can stay. Read rows back with `rows`. + * + * The window is time, not cause: a write the stack makes for its own + * reasons while `fn` runs (a sign-in's key rotation, another test's write + * on a shared stack) is observed too. Filter by `object`. + * + * An `async: true` hook's write reaches the engine after the write that + * fired it has returned: wait for it inside `fn` with + * `observation.settled(match)`. A hook that writes nothing leaves nothing + * to observe, because the engine keeps no record of a fire-and-forget run. + * + * To stage a refusal of a hook's write, arrange one the engine makes + * itself: seed the row that already holds a unique value the hook will + * write, or set up the state a declared validation rule refuses. The handle + * has no switch that refuses a write in the engine's place. + * + * A `fn` that is not a function is refused before anything is observed + * (`INVALID_REQUEST` / `400`). A rejection of `fn` is rethrown unchanged + * once the observation has closed. + */ + observeWrites(fn: (observation: WriteObservation) => unknown): Promise; + /** The booted `SchemaRegistry`, read through its own accessors. */ metadata: { /** One registered object (system columns injected), or `undefined`. */ @@ -389,6 +503,33 @@ function callShapeRefusal(message: string): Error & { code: string; status: numb return Object.assign(new Error(message), { code: 'INVALID_REQUEST', status: 400, statusCode: 400 }); } +/** [#22301] How long `WriteObservation.settled` waits when the caller names no `timeoutMs`. */ +const SETTLED_DEFAULT_TIMEOUT_MS = 2_000; + +/** [#22301] The three operations `observeWrites` records; every other operation passes unrecorded. */ +function isObservedOperation(operation: OperationContext['operation']): operation is ObservedWrite['operation'] { + return operation === 'insert' || operation === 'update' || operation === 'delete'; +} + +/** [#22301] A row copied on entry, so a later change to the engine's payload cannot rewrite the record. */ +function copyRow(row: unknown): unknown { + return row !== null && typeof row === 'object' && !Array.isArray(row) ? { ...(row as EngineRow) } : row; +} + +/** [#22301] The error's own `code`, when it carries a string one; the handle names none itself. */ +function ownCode(error: unknown): string | undefined { + const code = (error as { code?: unknown } | null | undefined)?.code; + return typeof code === 'string' ? code : undefined; +} + +/** [#22301] `insert sys_x (refused VALIDATION_FAILED)`: one observed write, for a message. */ +function describeWrite(write: ObservedWrite): string { + const { outcome } = write; + const answered = + outcome.status === 'refused' ? `refused${outcome.code ? ` ${outcome.code}` : ''}` : outcome.status; + return `${write.operation} ${write.object} (${answered})`; +} + /** `true` when `opts` names the system principal; refuses a call that names two callers. */ function namesSystem(opts: AsUser | AsSystem, door: string): boolean { const system = (opts as Partial | undefined)?.system === true; @@ -483,6 +624,60 @@ export async function createHandle(kernel: ObjectKernel, origin: string): Promis return id; }; + // [#22301] `observeWrites`. One record per write is shared by every open + // observation, and each observation is told when one is added or answered. + type MutableWrite = { -readonly [K in keyof ObservedWrite]: ObservedWrite[K] }; + interface ObservationSink { + add(write: ObservedWrite): void; + answered(): void; + } + const openObservations = new Set(); + let observer: Promise | undefined; + + // The one observer middleware, registered on the first observation and + // kept: the engine has no way to take a middleware back, and with no + // observation open it is `return next()`. Global (no `object`), so it is + // keyed to no object and `hasObjectMiddleware` answers as before. + const installObserver = (): Promise => { + observer ??= engine().then( + (ql) => { + ql.registerMiddleware(async (opCtx: OperationContext, next: () => Promise) => { + const operation = opCtx.operation; + if (openObservations.size === 0 || !isObservedOperation(operation)) return next(); + const data = opCtx.data; + const write: MutableWrite = { + object: opCtx.object, + operation, + data: (Array.isArray(data) ? data.map(copyRow) : copyRow(data)) as ObservedWrite['data'], + where: opCtx.options?.where === undefined ? undefined : (copyRow(opCtx.options.where) as EngineRow), + multi: opCtx.options?.multi === true, + context: opCtx.context, + outcome: { status: 'pending' }, + }; + // The observations open when the write arrived are the ones told + // about its answer, even one that closes before the engine answers. + const told = [...openObservations]; + for (const sink of told) sink.add(write); + try { + await next(); + write.outcome = { status: 'resolved', result: opCtx.result }; + } catch (error) { + write.outcome = { status: 'refused', code: ownCode(error), error }; + throw error; + } finally { + for (const sink of told) sink.answered(); + } + }); + }, + (error: unknown) => { + // Not memoised: a later call asks the kernel again. + observer = undefined; + throw error; + }, + ); + return observer; + }; + return { contextFor, @@ -605,6 +800,97 @@ export async function createHandle(kernel: ObjectKernel, origin: string): Promis return Array.isArray(found) ? found : []; }, + async observeWrites(fn) { + if (typeof fn !== 'function') { + throw callShapeRefusal('verify: observeWrites(fn) takes the function to run while the observation is open'); + } + await installObserver(); + + const writes: ObservedWrite[] = []; + let open = true; + // Each pending `settled` call: re-checked whenever a write is added or + // answered, and failed when the observation closes first. + const waiters = new Set<{ check(): void; fail(error: Error): void }>(); + const recheck = () => { + for (const waiter of [...waiters]) waiter.check(); + }; + const sink: ObservationSink = { + add(write) { + writes.push(write); + recheck(); + }, + answered: recheck, + }; + + const observation: WriteObservation = { + writes, + settled(match, opts) { + const timeoutMs = opts?.timeoutMs ?? SETTLED_DEFAULT_TIMEOUT_MS; + if (typeof match !== 'function') { + return Promise.reject(callShapeRefusal('verify: settled(match) takes a function that picks the write to wait for')); + } + if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) { + return Promise.reject(callShapeRefusal('verify: settled(match, { timeoutMs }) takes a positive number of milliseconds')); + } + if (!open) { + return Promise.reject(new Error('verify: settled() was called after its observeWrites observation closed')); + } + return new Promise((resolve, reject) => { + let timer: ReturnType | undefined; + const waiter = { + check() { + // `match` is the caller's code, run when the engine adds or + // answers a write: a throw from it rejects this wait and never + // reaches the engine's write. + let hit: ObservedWrite | undefined; + try { + hit = writes.find((w) => match(w)); + } catch (error) { + waiter.fail(error as Error); + return; + } + if (hit && hit.outcome.status !== 'pending') { + done(); + resolve(hit); + } + }, + fail(error: Error) { + done(); + reject(error); + }, + }; + const done = () => { + if (timer !== undefined) clearTimeout(timer); + waiters.delete(waiter); + }; + waiters.add(waiter); + timer = setTimeout(() => { + const seen = writes.map(describeWrite); + waiter.fail( + new Error( + `verify: settled() found no answered write its match accepts within ${timeoutMs}ms; ` + + (seen.length > 0 ? `observed: ${seen.join(', ')}` : 'no write was observed'), + ), + ); + }, timeoutMs); + waiter.check(); + }); + }, + }; + + openObservations.add(sink); + try { + await fn(observation); + } finally { + open = false; + openObservations.delete(sink); + for (const waiter of [...waiters]) { + waiter.fail(new Error('verify: the observeWrites observation closed before settled() found its write')); + } + } + return [...writes]; + }, + metadata: { object: (name) => registry().getObject(name), objects: () => registry().getAllObjects(), diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index f1c3fd1fbeb..a53e9a3fadc 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -146,9 +146,9 @@ function harnessCryptoProvider(): LocalCryptoProvider { /** * A booted stack: the HTTP surface (`api` / `raw` / `signIn` / `signUp` / * `apiAs`) plus the in-process handle (`hooks` / `validate` / `flows` / - * `actions` / `automation` / `seed` / `rows` / `metadata` / `tenancy` / - * `contextFor`) on the same kernel — see `./handle.ts` for what each method - * is a facade over. + * `actions` / `automation` / `seed` / `rows` / `observeWrites` / `metadata` / + * `tenancy` / `contextFor`) on the same kernel — see `./handle.ts` for what + * each method is a facade over. */ export interface VerifyStack extends VerifyHandle { /** The booted kernel — for direct service calls when bypassing HTTP is intentional. */ diff --git a/packages/verify/src/index.ts b/packages/verify/src/index.ts index 246f066f868..f4e41088f18 100644 --- a/packages/verify/src/index.ts +++ b/packages/verify/src/index.ts @@ -13,7 +13,18 @@ export type { VerifyStack, BootOptions } from './harness.js'; // The in-process handle on the booted stack (hotcrm#1579 step 5a): every // `VerifyStack` carries it; these are its types and its one predicate. export { isVerifyRefusal } from './handle.js'; -export type { VerifyHandle, VerifyRefusal, AsUser, AsSystem, FlowRun, FlowRunRef, EngineRow } from './handle.js'; +export type { + VerifyHandle, + VerifyRefusal, + AsUser, + AsSystem, + FlowRun, + FlowRunRef, + EngineRow, + ObservedWrite, + ObservedWriteOutcome, + WriteObservation, +} from './handle.js'; export { deriveCrudCases, fillRelationalRefs } from './derive.js'; export type { CrudCase, DerivedAssert, AssertKind, RelationalRef } from './derive.js';