From c1a32655c843d663483e281c1c17a3090a95f23c Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Mon, 10 Aug 2026 06:22:46 +0000 Subject: [PATCH] =?UTF-8?q?test(examples):=20pin=20the=20#7225=20measureme?= =?UTF-8?q?nt=20=E2=80=94=20one=20persisted-row=20hook=20probe=20per=20exa?= =?UTF-8?q?mple=20app=20(#7258)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #7225 read three shipped example hooks against `HookContextSchema.input`'s contract table, concluded all three silently no-op, and prescribed re-spelling them to `ctx.input.data`. Measurement answered the other way: all three work, and the prescribed fix would have turned the showcase's public web-to-lead insert into a hard refusal (`onError: 'abort'` + `ctx.input.data` undefined inside a sandboxed body). A full dispatch was spent on the false alarm because nothing pinned the behaviour. These two files are that pin, landing the probes the #7225 dev measured green: - `examples/app-crm/test/opportunity-stage-hook.test.ts` — real ObjectQL over a real SqlDriver (better-sqlite3), the app's real `crm_opportunity` object and real `OpportunityStageHook`, asserting the PERSISTED row: closed_won -> 100, closed_lost -> 0, update into closed_won -> 100, non-closed control untouched, plus a flat-`ctx.input` shape witness and an unbound-hook fixture. - `examples/app-showcase/test/hook-body-persisted-writes.test.ts` — same stack plus the real QuickJSScriptRunner behind `hookBodyRunnerFactory`, i.e. the AppPlugin production wiring: persisted inquiry status=new / source=web, trimmed task title, and the in-sandbox shape probe that a body sees the flat record with `ctx.input.data` undefined (the #7254 documentation witness). Assertions land on rows read back out of the database, because "the handler ran and had no effect" — the defect #7225 believed in — is invisible to anything that watches the handler instead of the row. Test-only: no `src/` change in either app. app-crm gains two workspace devDeps (`@objectstack/objectql`, `@objectstack/driver-sql`) to host its harness. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015fkdTyGmMD5s8ZtEifvuGy --- examples/app-crm/package.json | 2 + .../test/opportunity-stage-hook.test.ts | 276 ++++++++++++++++++ .../test/hook-body-persisted-writes.test.ts | 264 +++++++++++++++++ pnpm-lock.yaml | 6 + 4 files changed, 548 insertions(+) create mode 100644 examples/app-crm/test/opportunity-stage-hook.test.ts create mode 100644 examples/app-showcase/test/hook-body-persisted-writes.test.ts diff --git a/examples/app-crm/package.json b/examples/app-crm/package.json index bb3870cd9e..76c13735d9 100644 --- a/examples/app-crm/package.json +++ b/examples/app-crm/package.json @@ -24,6 +24,8 @@ }, "devDependencies": { "@objectstack/cli": "workspace:*", + "@objectstack/driver-sql": "workspace:*", + "@objectstack/objectql": "workspace:*", "typescript": "^6.0.3", "vitest": "^4.1.10" } diff --git a/examples/app-crm/test/opportunity-stage-hook.test.ts b/examples/app-crm/test/opportunity-stage-hook.test.ts new file mode 100644 index 0000000000..7970cf69ea --- /dev/null +++ b/examples/app-crm/test/opportunity-stage-hook.test.ts @@ -0,0 +1,276 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7258] `OpportunityStageHook` pins the probability — on the PERSISTED row. + * + * This file exists because a careful reader got it wrong. #7225 read the three + * shipped example hooks against `HookContextSchema.input`'s contract table + * (`{ data, options }` — the record lives at `ctx.input.data`), concluded that + * this hook's `input.stage` is always `undefined` and that it silently does + * nothing, and prescribed re-spelling every one of them to `ctx.input.data`. + * Measurement answered the other way: all three work, and the prescribed fix + * would have turned the showcase's public web-to-lead insert into a hard + * refusal. One full dispatch was spent on the false alarm. + * + * THE MISSING LINK, and why the contract table did not lie: `bindHooks` wraps + * every DECLARATIVE hook in `wrapDeclarativeHook`, which calls `installFlatInput` + * (`packages/objectql/src/hook-wrappers.ts:446`, helper at `:502`). That swaps + * `ctx.input` for a Proxy presenting a FLAT RECORD VIEW over the envelope — + * reads of a non-wrapper key resolve against `data`, and writes always land in + * `data`, "so the engine's downstream `input.data` read picks up mutations made + * by user code as `input.field = value`". The contract table describes the + * RAW-ENGINE surface; an authored hook only ever meets the declarative one. + * (The table's silence about that is filed separately as #7254.) + * + * WHY THE ASSERTIONS ARE ON PERSISTED ROWS. The defect #7225 believed in was + * "the handler runs and has no effect" — a shape that a handler-side spy reads + * as healthy. Only the row that came back out of the database can tell + * "mutated the envelope nobody reads" from "mutated the record". So the harness + * is the real `ObjectQL` engine over a real `SqlDriver` (better-sqlite3), the + * app's REAL `crm_opportunity` object and the app's REAL hook, bound the way + * `AppPlugin` binds it from `defineStack({ hooks })` + * (`packages/runtime/src/app-plugin.ts`, `ql.bindHooks(hooks, { packageId })`). + * No double anywhere in the chain. + * + * WHAT THIS FILE DOES AND DOES NOT CATCH — measured, both directions, rather + * than assumed. Unregistering the hook turns four of the cases below red; + * misspelling its `closed_won` predicate turns exactly the two closed_won cases + * red and leaves closed_lost green. But re-spelling the handler to + * `ctx.input.data` — the change #7225 prescribed — keeps all seven GREEN, and + * that is correct rather than a hole: on the code-handler path `data` is a + * wrapper key the proxy passes straight through, so both spellings really do + * work here. The spelling is only fatal inside a sandboxed `body`, where + * `ctx.input.data` is `undefined`; the pin that bites on it is therefore the + * showcase's (`examples/app-showcase/test/hook-body-persisted-writes.test.ts`), + * not this one. Read the two files as one pair. + * + * Test-only pin: nothing in `src/` changes. If this file ever goes red, the + * behaviour it describes regressed — the hook is not to be "fixed" toward the + * raw-engine spelling on the strength of the contract table alone. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; + +import { Account, Opportunity, OpportunityLineItem } from '../src/objects/index.js'; +import { allHooks } from '../src/hooks/index.js'; +import { OpportunityStageHook } from '../src/hooks/opportunity.hook.js'; + +/** The app id `AppPlugin` derives its `packageId` from. */ +const PACKAGE_ID = 'app:com.example.crm'; + +/** Engines opened by a test, destroyed when that test ends. */ +const openEngines: ObjectQL[] = []; +afterEach(async () => { + while (openEngines.length) { + try { await openEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +/** + * A real kernel-free engine carrying the app's real CRM objects. + * + * `hooks` is a parameter so the reverse check below can withhold exactly one + * thing — the binding — from an otherwise identical engine. + */ +async function bootCrm(hooks: unknown[] = allHooks): Promise { + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await driver.connect(); + + const engine = new ObjectQL(); + openEngines.push(engine); + engine.registerDriver(driver as never, true); + await engine.init(); + + // The app's real objects, not a reduction of them: `crm_opportunity` carries + // a required `account` lookup, a `line_total` summary over the line items, a + // state-machine rule on `stage` and a formula reading `probability`. Any of + // those could plausibly be what actually moves the column, so all of them are + // in the picture. + for (const def of [Account, Opportunity, OpportunityLineItem]) { + engine.registry.registerObject(def as never, PACKAGE_ID, 'crm'); + } + await engine.syncSchemas(); + + // Exactly the AppPlugin call, minus the sandbox body runner this app has no + // use for (its single hook is an inline code handler). + engine.bindHooks(hooks as never[], { packageId: PACKAGE_ID }); + return engine; +} + +const ctx = { context: { userId: 'u_crm', isSystem: true } }; + +describe('#7258 — app-crm `OpportunityStageHook` moves the persisted probability', () => { + /** An account to hang the opportunities off — `account` is a required lookup. */ + async function seedAccount(engine: ObjectQL): Promise { + const account: any = await engine.insert('crm_account', { name: 'Acme', industry: 'technology' }, ctx as never); + return String(account.id); + } + + const readBack = async (engine: ObjectQL, id: string) => + (await engine.find('crm_opportunity', { where: { id } }, ctx as never))[0] as any; + + it('INSERT closed_won: the stored row has probability 100, not the authored 50', async () => { + const engine = await bootCrm(); + const accountId = await seedAccount(engine); + + const created: any = await engine.insert( + 'crm_opportunity', + { name: 'Won deal', account: accountId, stage: 'closed_won', probability: 50 }, + ctx as never, + ); + + // The row as the DATABASE holds it. A hook that mutated only the envelope + // would leave the authored 50 here. + const stored = await readBack(engine, String(created.id)); + expect(stored.stage).toBe('closed_won'); + expect(stored.probability).toBe(100); + }, 30000); + + it('INSERT closed_lost: the stored row has probability 0 — the falsy end of the pin', async () => { + // `0` matters on its own: it is the value a `??`/`||`-shaped repair would + // silently drop, so pinning only the 100 case would leave half the hook + // unwitnessed. + const engine = await bootCrm(); + const accountId = await seedAccount(engine); + + const created: any = await engine.insert( + 'crm_opportunity', + { name: 'Lost deal', account: accountId, stage: 'closed_lost', probability: 90 }, + ctx as never, + ); + + const stored = await readBack(engine, String(created.id)); + expect(stored.stage).toBe('closed_lost'); + expect(stored.probability).toBe(0); + }, 30000); + + it('UPDATE into closed_won: the hook fires on beforeUpdate too', async () => { + // `events` lists both, and the update path binds `input` differently from + // insert (the envelope carries `id` as well), so this is a distinct seam + // rather than a repetition of the insert case. + const engine = await bootCrm(); + const accountId = await seedAccount(engine); + + const created: any = await engine.insert( + 'crm_opportunity', + { name: 'Advancing deal', account: accountId, stage: 'proposal', probability: 40 }, + ctx as never, + ); + const id = String(created.id); + expect((await readBack(engine, id)).probability).toBe(40); + + await engine.update('crm_opportunity', { id, stage: 'closed_won' }, ctx as never); + + const stored = await readBack(engine, id); + expect(stored.stage).toBe('closed_won'); + expect(stored.probability).toBe(100); + }, 30000); + + it('CONTROL: a non-closed stage is left exactly as authored', async () => { + // The half that makes the three cases above mean something. A hook that + // stamped unconditionally — or an engine that recomputed the column on + // every write — would also satisfy them. + const engine = await bootCrm(); + const accountId = await seedAccount(engine); + + const created: any = await engine.insert( + 'crm_opportunity', + { name: 'Open deal', account: accountId, stage: 'proposal', probability: 40 }, + ctx as never, + ); + + const stored = await readBack(engine, String(created.id)); + expect(stored.stage).toBe('proposal'); + expect(stored.probability).toBe(40); + }, 30000); + + it('THE #7225 QUESTION: the handler sees the record FLAT on `ctx.input`', async () => { + // The documented-surface witness for the code-handler half (the showcase + // file carries the sandboxed-body twin). #7225's whole case was that + // `input.stage` is `undefined` here; it is bound, and `input.data` — the + // spelling the contract table teaches — is a passthrough on this path + // rather than the only one that works. + const engine = await bootCrm(); + const accountId = await seedAccount(engine); + + const seen: Array> = []; + engine.bindHooks( + [{ + name: 'probe_flat_input', + object: 'crm_opportunity', + events: ['beforeInsert'], + // Lower than the real hook's 100 is irrelevant to what it observes: it + // reads the caller's payload, which no ordering changes. + priority: 10, + handler: async (hookCtx: any) => { + const input = hookCtx.input as Record; + seen.push({ + keys: Object.keys(input), + stage: input.stage, + typeofData: typeof (input as { data?: unknown }).data, + }); + }, + }] as never[], + { packageId: 'probe' }, + ); + + await engine.insert( + 'crm_opportunity', + { name: 'Probe deal', account: accountId, stage: 'closed_won', probability: 50 }, + ctx as never, + ); + + expect(seen).toHaveLength(1); + // The flat record's own fields are what `Object.keys` enumerates — the + // proxy's `ownKeys` trap, which is also why a sandboxed body receives the + // record rather than the envelope. + expect(seen[0].keys).toContain('stage'); + expect(seen[0].keys).toContain('probability'); + expect(seen[0].stage).toBe('closed_won'); + }, 30000); + + it('REVERSE: the same writes with the hook unbound leave the authored values', async () => { + // The pins above must be able to FAIL. This engine is identical except that + // `bindHooks` is handed nothing — the #4984 phantom check, run as a + // fixture rather than as a promise: if the assertions here also read 100/0, + // then something other than the hook is moving the column and every + // expectation above is vacuous. + const engine = await bootCrm([]); + const accountId = await seedAccount(engine); + + const won: any = await engine.insert( + 'crm_opportunity', + { name: 'Won deal, hook unbound', account: accountId, stage: 'closed_won', probability: 50 }, + ctx as never, + ); + const lost: any = await engine.insert( + 'crm_opportunity', + { name: 'Lost deal, hook unbound', account: accountId, stage: 'closed_lost', probability: 90 }, + ctx as never, + ); + + expect((await readBack(engine, String(won.id))).probability).toBe(50); + expect((await readBack(engine, String(lost.id))).probability).toBe(90); + }, 30000); + + it('the hook is registered on the app bundle — an unregistered hook never runs', async () => { + // The behavioural cases bind the hook themselves (this file assembles its + // own engine). This one pins the wiring the RUNTIME reads: `AppPlugin` + // walks `defineStack({ hooks })` via `collectBundleHooks` and nothing else, + // so a hook absent from that array is dead metadata however correct its + // file is. That is exactly what #7036 found in app-todo. + const stack = (await import('../objectstack.config.js')).default as { + hooks?: Array<{ name?: string; object?: string; events?: string[] }>; + }; + const registered = (stack.hooks ?? []).find((h) => h.name === OpportunityStageHook.name); + expect(registered, '`opportunity_stage_probability` must be in defineStack({ hooks })').toBeDefined(); + expect(registered!.object).toBe('crm_opportunity'); + expect(registered!.events).toEqual(expect.arrayContaining(['beforeInsert', 'beforeUpdate'])); + }); +}); diff --git a/examples/app-showcase/test/hook-body-persisted-writes.test.ts b/examples/app-showcase/test/hook-body-persisted-writes.test.ts new file mode 100644 index 0000000000..ec427dd55b --- /dev/null +++ b/examples/app-showcase/test/hook-body-persisted-writes.test.ts @@ -0,0 +1,264 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7258] The showcase's sandboxed `body` hooks write the PERSISTED row — and + * a body sees the record FLAT, not the engine's envelope. + * + * #7225 read these two hooks against `HookContextSchema.input`'s contract table + * (`insert` → `{ data, options }`, so the record lives at `ctx.input.data`), + * concluded that `StampInquiryDefaultsHook` and `NormalizeTaskTitleHook` + * silently do nothing, and prescribed re-spelling both to `ctx.input.data`. + * Measurement answered the other way twice over: + * + * 1. both hooks work today, on the real production wiring; and + * 2. the prescribed re-spelling is a LIVE REGRESSION on this half. Inside a + * sandboxed body `ctx.input.data` is `undefined`, and both hooks carry + * `onError: 'abort'` — so the re-spelled `StampInquiryDefaultsHook` threw + * `TypeError` and persisted ZERO rows. The public web-to-lead insert + * (ADR-0056 Option A, anonymous visitors) would have gone from working to + * refusing every submission. + * + * WHY A BODY SEES THE RECORD. `bindHooks` wraps every declarative hook in + * `wrapDeclarativeHook` → `installFlatInput` (`packages/objectql/src/ + * hook-wrappers.ts:446`, helper `:502`), a Proxy presenting a flat record view + * over the envelope. `buildSandboxContext` then snapshots it with + * `unwrapProxyToPlain(engineCtx.input)`, which runs through that proxy's + * `ownKeys` trap — and the trap enumerates the record's fields only. So the + * body receives the RECORD, and the envelope keys are not there to be read. + * The contract table describes the raw-engine surface and is accurate about it; + * that it says nothing about the declarative one is filed as #7254, and the + * shape probe at the bottom of this file is that card's witness. + * + * THE HARNESS IS THE PRODUCTION ONE. Real `ObjectQL`, real `SqlDriver` + * (better-sqlite3), real `QuickJSScriptRunner` behind `hookBodyRunnerFactory`, + * the app's REAL objects and REAL hooks — the same call `AppPlugin` makes from + * `defineStack({ hooks })` (`packages/runtime/src/app-plugin.ts`: + * `ql.bindHooks(hooks, { packageId, bodyRunner: hookBodyRunnerFactory(new + * QuickJSScriptRunner(), …) })`). Assertions land on rows read back out of the + * database, because "the handler ran and had no effect" — the defect #7225 + * believed in — is invisible to anything that watches the handler instead of + * the row. + * + * Test-only pin: nothing in `src/` changes. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { QuickJSScriptRunner, hookBodyRunnerFactory } from '@objectstack/runtime'; + +import { Account, Project, Task, Inquiry } from '../src/data/objects/index.js'; +import { allHooks } from '../src/data/hooks/index.js'; + +/** The app id `AppPlugin` derives its `packageId` from. */ +const APP_ID = 'com.objectstack.showcase'; +const PACKAGE_ID = `app:${APP_ID}`; + +const openEngines: ObjectQL[] = []; +afterEach(async () => { + while (openEngines.length) { + try { await openEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +/** + * The showcase's real objects on a real engine, with the real body runner. + * + * `hooks` is a parameter so the reverse check can withhold exactly one thing — + * the binding — from an otherwise identical engine. + */ +async function bootShowcase(hooks: unknown[] = allHooks): Promise { + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await driver.connect(); + + const engine = new ObjectQL(); + openEngines.push(engine); + engine.registerDriver(driver as never, true); + await engine.init(); + + // `showcase_task.project` is a REQUIRED master-detail and + // `showcase_project.account` a required lookup, so the whole chain is real + // rather than a trimmed stand-in for the object under test. + for (const def of [Account, Project, Task, Inquiry]) { + engine.registry.registerObject(def as never, PACKAGE_ID, 'showcase'); + } + await engine.syncSchemas(); + + engine.bindHooks(hooks as never[], { + packageId: PACKAGE_ID, + bodyRunner: hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: APP_ID }), + }); + return engine; +} + +const ctx = { context: { userId: 'u_showcase', isSystem: true } }; + +const readBack = async (engine: ObjectQL, object: string, id: string) => + (await engine.find(object, { where: { id } }, ctx as never))[0] as any; + +describe('#7258 — app-showcase sandboxed `body` hooks reach the persisted row', () => { + it('StampInquiryDefaultsHook: an inquiry with neither field set persists status=new / source=web', async () => { + // The public web-to-lead submission, exactly as an anonymous visitor sends + // it: the three whitelisted fields and nothing else. `status` / `source` + // are server-controlled, so if the hook is inert they persist as NULL and + // an inquiry can arrive without provenance. + const engine = await bootShowcase(); + + const created: any = await engine.insert( + 'showcase_inquiry', + { name: 'Ada Lovelace', email: 'ada@example.com', message: 'Please send a demo.' }, + ctx as never, + ); + + const stored = await readBack(engine, 'showcase_inquiry', String(created.id)); + expect(stored.status).toBe('new'); + expect(stored.source).toBe('web'); + // ...and the visitor's own fields survived the hook untouched. + expect(stored.name).toBe('Ada Lovelace'); + expect(stored.email).toBe('ada@example.com'); + }, 30000); + + it('StampInquiryDefaultsHook: a value the caller DID supply is left alone', async () => { + // The hook is `if (!ctx.input.status)`, not an unconditional stamp. Pinning + // only the empty case would stay green if the guard were dropped — which + // would let a crafted submission be overwritten rather than defaulted, a + // different behaviour from the one documented. + const engine = await bootShowcase(); + + const created: any = await engine.insert( + 'showcase_inquiry', + { + name: 'Grace Hopper', email: 'grace@example.com', message: 'Triaged already.', + status: 'contacted', source: 'import', + }, + ctx as never, + ); + + const stored = await readBack(engine, 'showcase_inquiry', String(created.id)); + expect(stored.status).toBe('contacted'); + expect(stored.source).toBe('import'); + }, 30000); + + it('NormalizeTaskTitleHook: the stored title is trimmed on insert AND on update', async () => { + const engine = await bootShowcase(); + const account: any = await engine.insert( + 'showcase_account', { name: 'Initech', status: 'active' }, ctx as never, + ); + const project: any = await engine.insert( + 'showcase_project', + { name: 'Platform', account: String(account.id), status: 'planned' }, + ctx as never, + ); + + const created: any = await engine.insert( + 'showcase_task', + { title: ' spaced out ', project: String(project.id), status: 'backlog' }, + ctx as never, + ); + expect((await readBack(engine, 'showcase_task', String(created.id))).title).toBe('spaced out'); + + // `events` lists `beforeUpdate` too, and the update envelope carries `id` + // alongside `data` — a distinct binding, not a repeat of the insert. + await engine.update( + 'showcase_task', + { id: String(created.id), title: ' renamed ' }, + ctx as never, + ); + expect((await readBack(engine, 'showcase_task', String(created.id))).title).toBe('renamed'); + }, 30000); + + it('THE #7254 WITNESS: inside a body, `ctx.input` IS the record and `ctx.input.data` is undefined', async () => { + // The load-bearing line of this file, and the one the documentation card + // quotes. It is asserted from INSIDE the sandbox — the observation is + // written to a real column and read back out of the database, so it cannot + // be satisfied by anything short of the body actually running and actually + // seeing that shape. + // + // Priority 10 puts this ahead of `StampInquiryDefaultsHook` (50 — entries + // sort ascending), so the keys it reports are the caller's payload rather + // than the payload plus that hook's stamps. + const engine = await bootShowcase([ + ...allHooks, + { + name: 'showcase_probe_body_input_shape', + object: 'showcase_inquiry', + events: ['beforeInsert'], + priority: 10, + body: { + language: 'js', + source: + "ctx.input.company = 'KEYS[' + Object.keys(ctx.input).sort().join(',') + " + + "'] hasData=' + typeof ctx.input.data;", + }, + }, + ]); + + const created: any = await engine.insert( + 'showcase_inquiry', + { name: 'Ada Lovelace', email: 'ada@example.com', message: 'Please send a demo.' }, + ctx as never, + ); + + const stored = await readBack(engine, 'showcase_inquiry', String(created.id)); + // The record's own fields are what a body enumerates... + expect(stored.company).toBe('KEYS[email,message,name] hasData=undefined'); + // ...and the write the probe made through that flat view landed in the row, + // which is the second half of `installFlatInput`'s contract. + expect(stored.status).toBe('new'); + }, 30000); + + it('REVERSE: with the hooks unbound the same writes persist raw — the pins are not vacuous', async () => { + // The #4984 phantom check as a fixture rather than a promise. If these rows + // came back stamped and trimmed anyway, something other than the hooks + // would be doing it and every expectation above would be about nothing. + const engine = await bootShowcase([]); + + const inquiry: any = await engine.insert( + 'showcase_inquiry', + { name: 'Ada Lovelace', email: 'ada@example.com', message: 'Please send a demo.' }, + ctx as never, + ); + const storedInquiry = await readBack(engine, 'showcase_inquiry', String(inquiry.id)); + expect(storedInquiry.status == null).toBe(true); + expect(storedInquiry.source == null).toBe(true); + + const account: any = await engine.insert( + 'showcase_account', { name: 'Initech', status: 'active' }, ctx as never, + ); + const project: any = await engine.insert( + 'showcase_project', + { name: 'Platform', account: String(account.id), status: 'planned' }, + ctx as never, + ); + const task: any = await engine.insert( + 'showcase_task', + { title: ' spaced out ', project: String(project.id), status: 'backlog' }, + ctx as never, + ); + expect((await readBack(engine, 'showcase_task', String(task.id))).title).toBe(' spaced out '); + }, 30000); + + it('both hooks are registered on the app bundle — an unregistered hook never runs', async () => { + // The wiring the RUNTIME reads: `AppPlugin`'s `collectBundleHooks` walks + // `defineStack({ hooks })` and nothing else, so a hook missing from that + // array is dead metadata however correct its file is (#7036, app-todo). + const stack = (await import('../objectstack.config.js')).default as { + hooks?: Array<{ name?: string; object?: string; events?: string[] }>; + }; + const byName = new Map((stack.hooks ?? []).map((h) => [h.name, h])); + + const stamp = byName.get('showcase_stamp_inquiry_defaults'); + expect(stamp, 'the web-to-lead defaults hook must be in defineStack({ hooks })').toBeDefined(); + expect(stamp!.object).toBe('showcase_inquiry'); + expect(stamp!.events).toEqual(expect.arrayContaining(['beforeInsert'])); + + const trim = byName.get('showcase_normalize_task_title'); + expect(trim, 'the title-normalising hook must be in defineStack({ hooks })').toBeDefined(); + expect(trim!.object).toBe('showcase_task'); + expect(trim!.events).toEqual(expect.arrayContaining(['beforeInsert', 'beforeUpdate'])); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e88375c1c..dd77bb96c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -159,6 +159,12 @@ importers: '@objectstack/cli': specifier: workspace:* version: link:../../packages/cli + '@objectstack/driver-sql': + specifier: workspace:* + version: link:../../packages/drivers/driver-sql + '@objectstack/objectql': + specifier: workspace:* + version: link:../../packages/objectql typescript: specifier: ^6.0.3 version: 6.0.3