diff --git a/src/actions/register-handlers.ts b/src/actions/register-handlers.ts index ca967ea..e02caad 100644 --- a/src/actions/register-handlers.ts +++ b/src/actions/register-handlers.ts @@ -2,6 +2,7 @@ import { registerCatalogActionHandlers } from './catalog.handlers.js'; import { registerTaskActionHandlers } from './task.handlers.js'; +import { bindDispatchEngine, type DispatchEngine } from '../jobs/dispatch.job.js'; /** * Action handler registration. @@ -14,8 +15,16 @@ import { registerTaskActionHandlers } from './task.handlers.js'; * * An action whose handler is not registered here renders, is clickable, and * fails at call time. There is no author-time gate for it. + * + * It is also, not just incidentally, the only place `duly_dispatch` gets its + * data engine: `defineStack({ onEnable })` is the sole spot an ObjectStack + * application is handed `ctx.ql`, and `src/jobs/dispatch.job.ts` cannot reach + * one on its own (a job handler is invoked with `{ jobId, data, bundle }` — + * see that file's header, and objectstack#14094 upstream). So this function + * both registers action handlers AND binds the dispatch engine — two + * unrelated things sharing the one seam the platform gives an application. */ -export interface HandlerRegistrationContext { +export interface HandlerRegistrationContext extends DispatchEngine { registerAction: (...args: unknown[]) => void; } @@ -23,4 +32,8 @@ export function registerDulyActionHandlers(ql: HandlerRegistrationContext): void // Register handlers here, one call per feature: registerCatalogActionHandlers(ql); registerTaskActionHandlers(ql); + // Gives `duly_dispatch` its data engine (see file-header note above and + // dispatch.job.ts's own header). Until this call existed, the job was + // registered, scheduled and rendered configured — and dispatched nothing. + bindDispatchEngine(ql); } diff --git a/test/catalog-instantiate.test.ts b/test/catalog-instantiate.test.ts index 1a6e5c6..70087fe 100644 --- a/test/catalog-instantiate.test.ts +++ b/test/catalog-instantiate.test.ts @@ -529,6 +529,12 @@ describe('handler wiring', () => { registerAction: (...args: unknown[]) => { calls.push({ object: String(args[0]), action: String(args[1]), handler: args[2] }); }, + // Widened by #42 so `registerDulyActionHandlers` can also + // `bindDispatchEngine(ql)`; this suite is only about the action-handler + // registry, so these are unused no-ops rather than a real engine. + find: async () => [], + insert: async () => ({}), + update: async () => undefined, }; registerDulyActionHandlers(ql); return calls; diff --git a/test/dispatch-wiring.test.ts b/test/dispatch-wiring.test.ts new file mode 100644 index 0000000..2c36751 --- /dev/null +++ b/test/dispatch-wiring.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { AppPlugin, ObjectKernel, createStandaloneStack } from '@objectstack/runtime'; + +import stackConfig, { onEnable } from '../objectstack.config.js'; +import { DISPATCH_JOB_NAME, dulyDispatch, unbindDispatchEngine } from '../src/jobs/dispatch.job.js'; + +/** + * Issue #42 — the dispatch engine is actually wired, not just wireable. + * + * Every other dispatch assertion in this repo (`test/dispatch.test.ts`) calls + * `bindDispatchEngine(data)` itself, by hand, before touching `dulyDispatch`. + * That is deliberately right for testing the PLANNER and the idempotency + * index — but it means that suite would keep passing GREEN even if + * `registerDulyActionHandlers` never called `bindDispatchEngine` at all, i.e. + * even if this issue's fix were reverted. A test-side bind papers over + * exactly the gap #42 exists to close. + * + * So this file boots the app the way a real host does — through + * `defineStack({ onEnable })` — and never calls `bindDispatchEngine` itself. + * If the wiring in `src/actions/register-handlers.ts` is missing, this file + * fails with "Job 'duly_dispatch' has no data engine", not with a false + * green. + * + * ── Reproducing the real onEnable-merge, not `new AppPlugin(stack)` ──────── + * `objectstack.config.ts` exports `defineStack(...)` as `default` and + * `onEnable` as a SEPARATE named export sitting beside it — `defineStack` + * itself is never handed `onEnable`. Measured on `@objectstack/runtime` + * 17.2.0, `AppPlugin` only invokes `onEnable` when it is a property of the + * bundle object it was constructed with (`this.bundle`), and measured on + * `@objectstack/cli` 17.2.0 `serve.ts`, the CLI gets there by merging the + * module's named exports onto its default export before constructing + * `AppPlugin` — a comment there spells out why: "Without this AppPlugin can + * never invoke runtime hooks declared as `export const onEnable = ...` + * alongside the default `defineStack(...)` export." `test/task-actions.test.ts` + * hits the same fact from the other side, passing only the default export and + * noting `onEnable` is therefore never invoked, and registering handlers by + * hand instead. + * + * This test does what the CLI does — `{ ...stackConfig, onEnable }` — so + * `AppPlugin` finds `onEnable` on the bundle exactly as `objectstack dev` + * would, and this is the one file in the repo that boots the config the way + * a real host does. + */ + +let kernel: { getService(name: string): unknown; shutdown?(): Promise } | undefined; +let data: { + find(o: string, q?: Record, x?: Record): Promise>>; + insert(o: string, d: Record, x?: Record): Promise>; +}; + +beforeAll(async () => { + // Defensive only: vitest gives each test FILE its own module registry, so + // this module-scope binding cannot see another file's leftover state. This + // just guards against booting on top of a bind this file did not make. + unbindDispatchEngine(); + + const { plugins } = await createStandaloneStack({ + databaseDriver: 'memory', + skipSeedData: true, + // Left to its default this resolves `/dist/objectstack.json`; a local + // `pnpm build` would then make this suite report on the last BUILD rather + // than on `src/`, passing with the wiring reverted. Same guard as the + // sibling suites, for the same reason. + artifactPath: 'dist/objectstack.this-suite-must-not-load-an-artifact.json', + }); + const k = new ObjectKernel(); + for (const plugin of plugins) await k.use(plugin); + + // The merge under test: the config's `default` export plus its `onEnable` + // named export, exactly as `objectstack serve`/`objectstack dev` load it — + // NOT `new AppPlugin(stackConfig)` alone, which is the shape + // `test/task-actions.test.ts` uses precisely because it does NOT want + // `onEnable` invoked. + const bundle = { ...stackConfig, onEnable }; + await k.use(new AppPlugin(bundle, undefined, { skipSeedData: true })); + await k.bootstrap(); + + kernel = k as unknown as typeof kernel; + data = k.getService('data') as typeof data; +}, 180_000); + +afterAll(async () => { + await kernel?.shutdown?.(); + unbindDispatchEngine(); +}); + +describe('the dispatch engine is bound at boot, through the real onEnable path', () => { + it('dulyDispatch runs against the real engine with no test-side bindDispatchEngine call', async () => { + const created = await data.insert('duly_duty', { + name: 'File the emissions return', + form: 'recurring', + owner: 'user_alice', + source: 'catalog', + status: 'active', + frequency: 'monthly', + due_anchor: 'period_start', + due_offset_days: 4, + lead_days: 0, + timezone: 'UTC', + }); + const dutyId = String((Array.isArray(created) ? created[0] : created).id); + + // If `registerDulyActionHandlers` never called `bindDispatchEngine`, this + // throws "Job 'duly_dispatch' has no data engine …" — see + // `requireDispatchEngine` in dispatch.job.ts. It does not, because + // `onEnable` ran during `bootstrap()` above and bound the real `ql`. + const outcome = await dulyDispatch({ jobId: DISPATCH_JOB_NAME }); + expect(outcome.outcome).toBe('completed'); + + const tasks = await data.find('duly_task', { where: { duty: dutyId } }); + expect(tasks).toHaveLength(1); + expect(tasks[0]).toMatchObject({ + duty: dutyId, + owner: 'user_alice', + status: 'open', + }); + }); +});