Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/actions/register-handlers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand All@@ -14,13 +15,25 @@ 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;
}

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);
}
6 changes: 6 additions & 0 deletions test/catalog-instantiate.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand Down
120 changes: 120 additions & 0 deletions test/dispatch-wiring.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<void> } | undefined;
let data: {
find(o: string, q?: Record<string, unknown>, x?: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
insert(o: string, d: Record<string, unknown>, x?: Record<string, unknown>): Promise<Record<string, unknown>>;
};

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 `<cwd>/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',
});
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/actions/register-handlers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand All@@ -14,13 +15,25 @@ 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;
}

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);
}
6 changes: 6 additions & 0 deletions test/catalog-instantiate.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand Down
120 changes: 120 additions & 0 deletions test/dispatch-wiring.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<void> } | undefined;
let data: {
find(o: string, q?: Record<string, unknown>, x?: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
insert(o: string, d: Record<string, unknown>, x?: Record<string, unknown>): Promise<Record<string, unknown>>;
};

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 `<cwd>/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',
});
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/actions/register-handlers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand All@@ -14,13 +15,25 @@ 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;
}

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);
}
6 changes: 6 additions & 0 deletions test/catalog-instantiate.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand Down
120 changes: 120 additions & 0 deletions test/dispatch-wiring.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<void> } | undefined;
let data: {
find(o: string, q?: Record<string, unknown>, x?: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
insert(o: string, d: Record<string, unknown>, x?: Record<string, unknown>): Promise<Record<string, unknown>>;
};

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 `<cwd>/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',
});
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/actions/register-handlers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand All@@ -14,13 +15,25 @@ 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;
}

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);
}
6 changes: 6 additions & 0 deletions test/catalog-instantiate.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand Down
120 changes: 120 additions & 0 deletions test/dispatch-wiring.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<void> } | undefined;
let data: {
find(o: string, q?: Record<string, unknown>, x?: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
insert(o: string, d: Record<string, unknown>, x?: Record<string, unknown>): Promise<Record<string, unknown>>;
};

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 `<cwd>/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',
});
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/actions/register-handlers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand All@@ -14,13 +15,25 @@ 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;
}

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);
}
6 changes: 6 additions & 0 deletions test/catalog-instantiate.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand Down
120 changes: 120 additions & 0 deletions test/dispatch-wiring.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<void> } | undefined;
let data: {
find(o: string, q?: Record<string, unknown>, x?: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
insert(o: string, d: Record<string, unknown>, x?: Record<string, unknown>): Promise<Record<string, unknown>>;
};

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 `<cwd>/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',
});
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/actions/register-handlers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand All@@ -14,13 +15,25 @@ 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;
}

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);
}
6 changes: 6 additions & 0 deletions test/catalog-instantiate.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand Down
120 changes: 120 additions & 0 deletions test/dispatch-wiring.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<void> } | undefined;
let data: {
find(o: string, q?: Record<string, unknown>, x?: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
insert(o: string, d: Record<string, unknown>, x?: Record<string, unknown>): Promise<Record<string, unknown>>;
};

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 `<cwd>/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',
});
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/actions/register-handlers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand All@@ -14,13 +15,25 @@ 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;
}

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);
}
6 changes: 6 additions & 0 deletions test/catalog-instantiate.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand Down
120 changes: 120 additions & 0 deletions test/dispatch-wiring.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<void> } | undefined;
let data: {
find(o: string, q?: Record<string, unknown>, x?: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
insert(o: string, d: Record<string, unknown>, x?: Record<string, unknown>): Promise<Record<string, unknown>>;
};

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 `<cwd>/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',
});
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/actions/register-handlers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand All@@ -14,13 +15,25 @@ 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;
}

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);
}
6 changes: 6 additions & 0 deletions test/catalog-instantiate.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand Down
120 changes: 120 additions & 0 deletions test/dispatch-wiring.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<void> } | undefined;
let data: {
find(o: string, q?: Record<string, unknown>, x?: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
insert(o: string, d: Record<string, unknown>, x?: Record<string, unknown>): Promise<Record<string, unknown>>;
};

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 `<cwd>/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',
});
});
});
Loading