Skip to content

Commit fa26308

Browse files
fix(objectql,runtime): a hook whose handler names a function gets ctx.title in-process (#24423)
Part of #24289 Clause-②: yes (widening) Item 1 of the card's release `6252025560`: a hook whose `handler` NAMES a function now gets the same `ctx.title(field?)` in-process that an inline handler and a sandboxed `body` get. Item 2 (declaring `title` on `HookContext`) is **not** built here. P3 below found two doors that run hooks in-process and still do not deliver `ctx.title`, so the declaration would run ahead of the runtime. That half goes back to the PM as `needs_decision`, and #24289 stays open for it. This PR carries no closing keyword for that reason. ## What changed - `packages/objectql/src/hook-binder.ts`: `BindHooksOptions` gains `wrapHandler(handler, hook)`. `resolveHandler` applies it once per hook, at bind, to every handler the hook runs in-process, after a string `handler` resolves: an inline function, a name from the bind's `functions`, or a name the same package registered on the engine (the new `resolveInProcessHandler` is the old resolution, moved unchanged). It is never applied to the handler `bodyRunner` builds from a `body`. The engine awaits a handler and discards its return value, and so does the adapter around the wrapped function. With no `wrapHandler`, every handler binds exactly as resolved. - `packages/spec/src/contracts/objectql-engine.ts`: `IObjectQLEngine.bindHooks` opts gain `wrapHandler?: (handler: (context: HookContext) => unknown, hook: Hook) => (context: HookContext) => unknown`, typed with the spec's own `Hook` and `HookContext` (no `any`, no bare `unknown` for a known signature; `unknown` is only the discarded return). - `packages/objectql/src/engine.ts`: `ObjectQL.bindHooks` opts carry the same member (`BindHooksOptions['wrapHandler']`). No engine default is installed. - `packages/runtime/src/app-artifact-handlers.ts`: `bindAppArtifactHandlers` passes `wrapHandler: (handler, hook) => withInProcessHookTitle(handler, ql, String(hook.name))`. The pre-bind copy of inline-handler hooks (`hookWithInProcessTitle`, PR #24356) is removed. `withInProcessHookTitle` and `buildTitleSurface` (`body-runner.ts`) are unchanged. ### Why the wrap is a binder option (G1) The alternative was to resolve names in the runtime before the bind. That would copy the binder's resolution rules (the bind's `functions`, then a function the same package registered on the engine, never another package's, with `INVALID_REFERENCE` for a miss) into a second resolver. The binder is documented as the one resolver ("Inline function handlers and string-named handlers share one resolver"). With the option, the binder resolves both kinds and applies the one wrap after resolution, so the inline handler and the named handler take the same wrap at the same point. That is also why the runtime's earlier inline-only pre-wrap is removed rather than kept beside it: keeping it would wrap an inline handler twice. ## Premises, each measured **P1 (true): a named handler ran in-process without `ctx.title`, on both doors.** The new pin `packages/verify/src/harness.named-hook-title-door.test.ts` was run against `origin/main` `ee882b845` (built through turbo) before any fix: ``` × the artifact door: a string handler resolved from the runtime module × the source door: a string handler resolved from the stack's functions TypeError: ctx.title is not a function (both) Tests 2 failed | 2 passed (4) (the 2 passing: PR #24356's inline-handler pins) ``` After the fix (head `043baeb3f`), same command: `Tests 4 passed (4)`. **P2 (true):** on `ee882b845`, `withInProcessHookTitle` wrapped only an inline `handler` in `bindAppArtifactHandlers` (`hookWithInProcessTitle`, `typeof hook.handler !== 'function'` returns the hook unchanged), before `ql.bindHooks`. A string `handler` reached the binder unwrapped. The P1 run above shows it: inline pins green, named pins red. **P3 (false): not every in-process door delivers `ctx.title` after item 1.** Doors enumerated and measured at head `043baeb3f`: | Door | Runs in-process? | `ctx.title` after this PR | Reading | |:--|:--|:--|:--| | sandboxed `body` (artifact door, `objectstack dev`) | no (QuickJS) | yes | pinned, `harness.hook-title-door.test.ts` artifact case | | inline `handler` via `bindAppArtifactHandlers` (source boot, `os serve` over a config) | yes | yes | pinned, `harness.hook-title-door.test.ts` source case and `app-artifact-handlers.hook-title.test.ts` | | named `handler`, source door (`functions`) | yes | yes | pinned, new verify pin | | named `handler`, artifact door (`runtimeModule`) | yes | yes | pinned, new verify pin | | `ObjectQLPlugin`'s metadata-service bind (`ql.bindHooks(bindable, { packageId: 'metadata-service' })`) | bodies no; a handler only by a name owned by `metadata-service` | bodies yes (engine default body runner); no wrap passed | read from code: stored rows are JSON, and no caller registers functions under `metadata-service` | | `ql.registerHook(event, fn)`, documented as the plugin escape hatch (`data-hooks.md`, method 2) | yes | **no** | measured: `typeof ctx.title === 'undefined'` | | an `ObjectQL` used directly, `ql.bindHooks([...])` with no runtime (a host or a test harness) | yes | **no** (by design: no `wrapHandler`) | measured: `typeof ctx.title === 'undefined'` for an inline and a named handler | The two measured `undefined` rows came from one scratch vitest run in `packages/objectql` against a bare `new ObjectQL()`: `{"registerHook":"undefined","bindInline":"undefined","bindNamed":"undefined"}`. The scratch file was deleted right after; it was never committed. So `HookContext` gains no `title` here. Declaring it, whether required or optional, would declare a member that the documented `registerHook` door does not deliver. The options go to the PM. ## Pins - `packages/verify/src/harness.named-hook-title-door.test.ts` (new, G2): on both `bootStack` doors, a named handler answers `ctx.title()` = `L1 · Widget` (this record's formula title, one hop through a lookup), `ctx.title('product')` = `Widget (W-1)`, and `ctx.title('spare')` = `null`. These are the same answers PR #24356's pin gets from a `body`. - `packages/objectql/src/hook-binder-wrap-handler.test.ts` (new): - the inline handler, the name from the bind's `functions` and the engine-registered name of the same package all take the wrap, once each, at bind; - a `body`'s handler never takes it; - **control:** with no `wrapHandler`, a named handler and an inline handler are called with the engine's own context object (identity), as before. ## Ablation (G2) One run under trap restore, after the fix was committed (`39cf701a1`): - **Mutation.** The `wrapHandler:` line was deleted from `packages/runtime/src/app-artifact-handlers.ts` through `scripts/ablation-replace.mjs`: anchor 1 → 0, blob `1b658c88ace0` → `376387b42a8a`. - **Rebuild.** Runtime was rebuilt. The build exited 1 in its declaration pass, because `noUnusedLocals` flags the now-unused `withInProcessHookTitle` import. That happens after the JS emit, so the marker count in `dist/index.js` went 1 → 0, and `ablation-dist-preflight.mjs runtime 'wrapHandler: (handler, hook)' --absent` exited 0. - **Ablated run.** The verify pins went `3 failed | 1 passed`: both named-handler doors and the inline source door failed with `TypeError: ctx.title is not a function`, and the artifact `body` door stayed green. The inline door going red is expected: inline handlers now take the same one wrap. - **Restore.** `git checkout HEAD -- ABS_PATH` gave blob `1b658c88ace0`, equal to the HEAD blob, and `git diff HEAD` was empty. Runtime was rebuilt (exit 0, marker count back to 1) and the pins were `4 passed (4)`. ## Local verification (head `043baeb3f`) - Build closure: `turbo run build --filter=@objectstack/verify...` (49 tasks), then the 4 touched packages, then `--filter='!@objectstack/docs'` for `check:dual-build-cjs-loads`. - Derived gates (`dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, no paths): 92 families. - 89 passed with exit 0 on the first pass. - 3 first refused on prerequisites and then passed, exit 0: - `check-engine-split-ratio --days 90` (exit 2) and `check-plugin-teardown-shape --self-test` (exit 3) on the shallow clone; both passed after `git fetch --unshallow origin main`. - `check:dual-build-cjs-loads` (exit 3, no dist); passed after the full build: "111 published require entry point(s) across 69 package(s) load". - Typecheck (each `pnpm --filter PKG typecheck`, test layer included): spec, runtime and verify passed with exit 0. objectql first exited 1: the new pin's context literal lacked `ql`, which gave TS2352 under `tsconfig.test.json`. That is fixed in `a2263206e`, and objectql then exited 0 ("test layer compiles"). - Tests: - objectql: `vitest run` 446 files, 8362 passed. - runtime: `vitest run --project local` 421 files, 6626 passed, 19 skipped. - verify: `vitest run` 43 files, 371 passed. - `eslint --no-inline-config` over the 6 changed `.ts` files: exit 0. - Final head `a2263206e` changes only that test literal. `--commands` derives the same 92 families there. The test-facing gates (`check:cross-package-test-inputs`, `check:test-source-alias`, `check:nul-bytes`, `check:tier-file-adoption`) were re-run there with exit 0, and `--ran` reconciles 92 derived, 92 run, 0 NOT-MEASURED, 0 UNRUN. ## Acceptance notes - Item 2 (`HookContext.title`) is held for a decision; the reading and the options are in the os-dev-report on #24289. - `@objectstack/verify` changes only a test file (its `files[]` ships `dist` only), so it takes no changeset entry. Dev session: `session_01UxK7vXeLCgYoxSXaHuQA1Y` (`mode:cloud`), dispatched by the `domain:spec` seat 2 (`session_019oFY6Hi35J7atvgVFnmoaK`) for claim `6257131238`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01UxK7vXeLCgYoxSXaHuQA1Y --- _Generated by [Claude Code](https://claude.ai/code/session_01UxK7vXeLCgYoxSXaHuQA1Y)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8ed3786 commit fa26308

7 files changed

Lines changed: 297 additions & 24 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': minor
4+
'@objectstack/runtime': patch
5+
---
6+
7+
fix(objectql,runtime): a hook whose `handler` names a function gets `ctx.title(field?)` in-process, as an inline handler and a `body` do
8+
9+
Clause-②: yes (widening)
10+
11+
- **What was wrong.** A hook whose `handler` is a string naming a function ran in-process without `ctx.title`: `defineStack({ functions })` plus a hook naming an entry on a source boot (`@objectstack/verify`'s `bootStack(config)`, `os serve` over a config), and a string `handler` resolved from the artifact's `runtimeModule` (what `objectstack build` ships for a callable it could not lower to a `body`). The engine's binder resolved the name after the runtime had wrapped the inline handlers, so the function was called with the bare engine context and `await ctx.title()` threw `TypeError: ctx.title is not a function`.
12+
- **What changed.** `bindHooksToEngine` (`@objectstack/objectql`) takes a `wrapHandler(handler, hook)` option, applied at bind to every handler a hook runs in-process, after a string `handler` resolves: an inline function, a name from the bind's `functions`, or a name the same package registered on the engine. A `body`'s handler (built by `bodyRunner`) never takes it. `ObjectQL.bindHooks` and the `IObjectQLEngine.bindHooks` contract (`@objectstack/spec`) carry the option. `bindAppArtifactHandlers` (`@objectstack/runtime`) passes the existing `withInProcessHookTitle` through it, in place of wrapping inline handlers before the bind, so the inline and the named handler take one wrap.
13+
- **Unchanged.** A bind that passes no `wrapHandler` binds every handler exactly as resolved, so a host or harness that calls `bindHooks` itself, and a hook registered with `ql.registerHook(...)`, are not handed `ctx.title`. `HookContext` still declares no `title`.

‎packages/objectql/src/engine.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ import { deriveViewContainerObject } from '@objectstack/metadata/view-container'
290290
// [#20331] The divergent view-container `name` refusal — the ONE judge this
291291
// registrar and `os validate` both call.
292292
import { viewContainerNameRefusal } from './view-container-name-refusal.js';
293-
import { bindHooksToEngine } from './hook-binder.js';
293+
import { bindHooksToEngine, type BindHooksOptions } from './hook-binder.js';
294294
import { declaredHookCondition } from './hook-wrappers.js';
295295
import { validateRecord, validateRecordInScope, normalizeMultiValueFields, normalizeBlankTypedValues, normalizeNumericStringValues, coerceBooleanFields, ValidationError, buildFieldError, resolveFieldLabel, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js';
296296
import type { AdmittedValueShapeViolation, AdmittedValueShapeViolationSink } from './validation/record-validator.js';
@@ -4866,6 +4866,8 @@ export class ObjectQL implements IObjectQLEngine {
48664866
strict?: boolean;
48674867
warnLegacyHandler?: boolean;
48684868
metrics?: any;
4869+
/** Wraps each in-process handler after its name resolves — see {@link BindHooksOptions.wrapHandler}. */
4870+
wrapHandler?: BindHooksOptions['wrapHandler'];
48694871
}): void {
48704872
const merged = { ...(opts ?? {}), logger: this.logger } as any;
48714873
if (!merged.bodyRunner && this._defaultBodyRunner) {
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #24289 — `bindHooksToEngine`'s `wrapHandler` option: one wrap for every
5+
* handler a hook runs in-process, applied after a string `handler` resolves.
6+
*
7+
* - an inline function, a name from the bind's `functions` and a name the
8+
* same package registered on the engine all take the wrap;
9+
* - a `body`'s handler (built by `bodyRunner`) never does;
10+
* - control: a bind with no `wrapHandler` calls the resolved function with
11+
* the engine's own context object, exactly as before.
12+
*/
13+
14+
import { describe, it, expect } from 'vitest';
15+
import { ObjectQL } from './engine.js';
16+
import { bindHooksToEngine } from './hook-binder.js';
17+
import type { Hook, HookContext } from '@objectstack/spec/data';
18+
19+
const ctx = (): HookContext =>
20+
({ object: 'account', event: 'beforeInsert', input: { data: {} }, ql: undefined }) as HookContext;
21+
22+
/** Marks each context the wrapped handler is called with, and records which hook it wrapped. */
23+
function marking(wrappedFor: string[]) {
24+
return (handler: (context: HookContext) => unknown, hook: Hook) => {
25+
wrappedFor.push(hook.name);
26+
return (context: HookContext) => handler({ ...context, wrapped: hook.name } as HookContext);
27+
};
28+
}
29+
30+
const hook = (name: string, handler: Hook['handler']): Hook => ({
31+
name,
32+
object: 'account',
33+
events: ['beforeInsert'],
34+
handler,
35+
});
36+
37+
describe('#24289: bindHooksToEngine wrapHandler', () => {
38+
it('wraps an inline handler and both kinds of named handler, once each, at bind', async () => {
39+
const engine = new ObjectQL();
40+
engine.registerFunction('registered_fn', async (c: any) => { seen.push(`registered:${c.wrapped}`); }, {
41+
packageId: 'app:wrap',
42+
});
43+
const seen: string[] = [];
44+
const wrappedFor: string[] = [];
45+
46+
const result = bindHooksToEngine(
47+
engine,
48+
[
49+
hook('inline', async (c: any) => { seen.push(`inline:${c.wrapped}`); }),
50+
hook('from_bind', 'bound_fn'),
51+
hook('from_registry', 'registered_fn'),
52+
],
53+
{
54+
packageId: 'app:wrap',
55+
functions: { bound_fn: async (c: any) => { seen.push(`bound:${c.wrapped}`); } },
56+
wrapHandler: marking(wrappedFor),
57+
},
58+
);
59+
expect(result.errors).toEqual([]);
60+
expect(wrappedFor).toEqual(['inline', 'from_bind', 'from_registry']);
61+
62+
await engine.triggerHooks('beforeInsert', ctx());
63+
await engine.triggerHooks('beforeInsert', ctx());
64+
expect(seen.sort()).toEqual([
65+
'bound:from_bind', 'bound:from_bind',
66+
'inline:inline', 'inline:inline',
67+
'registered:from_registry', 'registered:from_registry',
68+
]);
69+
expect(wrappedFor, 'the wrap is applied at bind, never per event').toHaveLength(3);
70+
});
71+
72+
it("never wraps the handler a body's runner builds", async () => {
73+
const engine = new ObjectQL();
74+
const seen: unknown[] = [];
75+
const wrappedFor: string[] = [];
76+
bindHooksToEngine(
77+
engine,
78+
[{ ...hook('with_body', undefined), body: { language: 'js', source: 'return;' } } as Hook],
79+
{
80+
bodyRunner: () => async (c: any) => { seen.push(c.wrapped); },
81+
wrapHandler: marking(wrappedFor),
82+
},
83+
);
84+
85+
await engine.triggerHooks('beforeInsert', ctx());
86+
expect(wrappedFor).toEqual([]);
87+
expect(seen).toEqual([undefined]);
88+
});
89+
90+
it("control: without wrapHandler, a named handler is called with the engine's own context", async () => {
91+
const engine = new ObjectQL();
92+
const received: unknown[] = [];
93+
const fn = async (c: unknown) => { received.push(c); };
94+
bindHooksToEngine(engine, [hook('plain_named', 'plain_fn'), hook('plain_inline', fn)], {
95+
packageId: 'app:plain',
96+
functions: { plain_fn: fn },
97+
});
98+
99+
const context = ctx();
100+
await engine.triggerHooks('beforeInsert', context);
101+
expect(received).toEqual([context, context]);
102+
expect(received.every((c) => c === context)).toBe(true);
103+
});
104+
});

‎packages/objectql/src/hook-binder.ts‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
* metadata-aware behaviour lives in this binder + the wrapper module.
2222
*/
2323

24-
import type { Hook } from '@objectstack/spec/data';
24+
import type { Hook, HookContext } from '@objectstack/spec/data';
2525
import { normalizeFlowFunctionEntry, type FlowFunctionEntry } from '@objectstack/spec/automation';
2626
import type { ObjectQL, HookHandler } from './engine.js';
2727
import { wrapDeclarativeHook, type HookDiagnosticsLogger } from './hook-wrappers.js';
@@ -58,6 +58,22 @@ export interface BindHooksOptions {
5858
*/
5959
bodyRunner?: (hook: Hook) => HookHandler | undefined;
6060

61+
/**
62+
* Optional wrap for each handler the hook runs IN-PROCESS: an inline
63+
* `handler` function, and the function a string `handler` names (from
64+
* `functions` or the package's registered functions). It is applied here,
65+
* after the name is resolved, so both kinds take the one wrap; the handler
66+
* `bodyRunner` builds from a `body` never does. Called once per hook, at bind.
67+
*
68+
* The runtime passes the wrap that hands an in-process handler the
69+
* `ctx.title(field?)` a sandboxed `body` gets (#24289). Absent, every handler
70+
* binds exactly as resolved.
71+
*/
72+
wrapHandler?: (
73+
handler: (context: HookContext) => unknown,
74+
hook: Hook,
75+
) => (context: HookContext) => unknown;
76+
6177
/**
6278
* When true, treat unresolved hooks (body present but no runner, or handler
6379
* string with no implementation) as fatal errors instead of warnings. Used
@@ -392,6 +408,21 @@ function resolveHandler(
392408
return undefined;
393409
}
394410

411+
const fn = resolveInProcessHandler(engine, hook, opts);
412+
if (!fn || typeof opts.wrapHandler !== 'function') return fn;
413+
// The engine awaits a handler and discards its return value; so does this.
414+
const wrapped = opts.wrapHandler(fn, hook);
415+
return async (context) => {
416+
await wrapped(context);
417+
};
418+
}
419+
420+
/** The in-process handler a hook with no `body` names: its inline function, or the function its string names. */
421+
function resolveInProcessHandler(
422+
engine: ObjectQL,
423+
hook: Hook,
424+
opts: BindHooksOptions,
425+
): HookHandler | undefined {
395426
const h = hook.handler;
396427
if (typeof h === 'function') return h as HookHandler;
397428
if (typeof h === 'string' && h.length > 0) {

‎packages/runtime/src/app-artifact-handlers.ts‎

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -255,9 +255,6 @@ export function bindAppArtifactHandlers(
255255
);
256256
}
257257
}
258-
// [#24289] An inline `handler` function runs in-process; it is handed the
259-
// `ctx.title(field?)` a `body` gets in the sandbox (see `hookWithInProcessTitle`).
260-
hooks = hooks.map((hook) => hookWithInProcessTitle(hook, ql));
261258
// Entries, not bare handlers: each function's declared `effect`
262259
// (#4396) rides along to the registry, where a `script` node reads
263260
// it to report what its run actually did.
@@ -277,6 +274,11 @@ export function bindAppArtifactHandlers(
277274
packageId: owner,
278275
functions,
279276
bodyRunner: hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql, logger, appId }),
277+
// [#24289] A handler that runs in-process, an inline function or
278+
// the function a string `handler` names, is handed the
279+
// `ctx.title(field?)` a `body` gets in the sandbox. The binder
280+
// applies it after resolving the name, so both take this one wrap.
281+
wrapHandler: (handler, hook) => withInProcessHookTitle(handler, ql, String(hook.name)),
280282
});
281283
out.hooks = hooks.length;
282284
out.functions = Object.keys(functions).length;
@@ -339,25 +341,6 @@ export function bindAppArtifactHandlers(
339341
return out;
340342
}
341343

342-
/**
343-
* [#24289] `hook` with its inline `handler` function handed `ctx.title(field?)`
344-
* ({@link withInProcessHookTitle}, the one accessor a `body` is handed too), or
345-
* `hook` itself when its `handler` is not a function.
346-
*
347-
* A copy, so the app's own declaration is never rewritten. Which of `body` and
348-
* `handler` runs stays the binder's call: a hook carrying both binds its `body`,
349-
* and the wrapped function is never consulted.
350-
*
351-
* A `handler` that NAMES a function (the deprecated string form, and what the
352-
* build leaves for a callable it could not lower) also runs in-process, but the
353-
* function is found by name inside the engine's binder, past this point, so
354-
* it is not handed the accessor here.
355-
*/
356-
function hookWithInProcessTitle(hook: any, ql: IObjectQLEngine): any {
357-
if (!hook || typeof hook.handler !== 'function') return hook;
358-
return { ...hook, handler: withInProcessHookTitle(hook.handler, ql, String(hook.name)) };
359-
}
360-
361344
// ─── The job half (#21489) ─────────────────────────────────────────────
362345

363346
/**

‎packages/spec/src/contracts/objectql-engine.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import type { IDataEngine } from './data-engine';
5555
import type { IDataDriver } from './data-driver';
5656
import type { FlowFunctionEffect, FlowFunctionEntry } from '../automation/flow-function.zod';
5757
import type { ServiceObject } from '../data/object.zod';
58+
import type { Hook, HookContext } from '../data/hook.zod';
5859
import type { BaseEngineOptions, EngineQueryOptions } from '../data/data-engine.zod';
5960

6061
/**
@@ -403,6 +404,20 @@ export interface IObjectQLEngine extends IDataEngine {
403404
strict?: boolean;
404405
warnLegacyHandler?: boolean;
405406
metrics?: unknown;
407+
/**
408+
* Wraps each handler the hook runs IN-PROCESS: an inline `handler`
409+
* function, and the function a string `handler` names (resolved from
410+
* `functions` or the package's registered functions). Never applied to
411+
* the handler `bodyRunner` builds from a `body`. Called once per hook,
412+
* at bind; what it returns is what the engine calls. The engine awaits
413+
* a handler and discards its return value. The runtime passes the
414+
* wrap that hands an in-process handler `ctx.title(field?)` (#24289).
415+
* Absent: every handler binds exactly as resolved.
416+
*/
417+
wrapHandler?: (
418+
handler: (context: HookContext) => unknown,
419+
hook: Hook,
420+
) => (context: HookContext) => unknown;
406421
},
407422
): void;
408423

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #24289 — a hook whose `handler` NAMES a function gets the same
5+
* `ctx.title(field?)` on both doors of `bootStack`, as an inline handler and a
6+
* `body` do (`harness.hook-title-door.test.ts`).
7+
*
8+
* The two doors name the function two ways, and both run it in-process:
9+
*
10+
* - the source door: `defineStack({ functions })` and a hook naming one entry;
11+
* - the artifact door: a string `handler` and a `runtimeModule` that supplies
12+
* the function, which is what `objectstack build` ships for a callable it
13+
* could not lower to a `body`.
14+
*
15+
* Measured before (`origin/main` ee882b845): both doors refused every insert
16+
* with `TypeError: ctx.title is not a function`. The binder resolved the name
17+
* past the runtime's wrap of an inline handler, so the function was called
18+
* with the engine's bare context.
19+
*/
20+
21+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
22+
import { tmpdir } from 'node:os';
23+
import { join } from 'node:path';
24+
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
25+
import { defineStack } from '@objectstack/spec';
26+
import { bootStack, type VerifyStack } from './harness.js';
27+
28+
const BOOT_TIMEOUT = 120_000;
29+
30+
const manifest = { id: 'com.example.named-hook-title-door', name: 'Named Hook Title Door', namespace: 'nhd', version: '1.0.0', type: 'app' };
31+
32+
const product = {
33+
name: 'nhd_product', label: 'Product', pluralLabel: 'Products', sharingModel: 'public_read_write',
34+
nameField: 'label',
35+
fields: {
36+
name: { name: 'name', type: 'text', label: 'Name', required: true },
37+
code: { name: 'code', type: 'text', label: 'Code' },
38+
label: { name: 'label', type: 'formula', label: 'Label', expression: "record.name + ' (' + record.code + ')'" },
39+
},
40+
};
41+
42+
const line = {
43+
name: 'nhd_line', label: 'Line', pluralLabel: 'Lines', sharingModel: 'public_read_write',
44+
nameField: 'title',
45+
fields: {
46+
name: { name: 'name', type: 'text', label: 'Name', required: true },
47+
product: { name: 'product', type: 'lookup', label: 'Product', reference: 'nhd_product' },
48+
spare: { name: 'spare', type: 'lookup', label: 'Spare', reference: 'nhd_product' },
49+
title: { name: 'title', type: 'formula', label: 'Title', expression: "record.name + ' · ' + record.product.name" },
50+
own_title: { name: 'own_title', type: 'text', label: 'Own title' },
51+
product_title: { name: 'product_title', type: 'text', label: 'Product title' },
52+
spare_title: { name: 'spare_title', type: 'text', label: 'Spare title' },
53+
},
54+
};
55+
56+
/** One statement set: the named function's body on both doors. */
57+
const SOURCE = [
58+
'ctx.input.own_title = String(await ctx.title());',
59+
"ctx.input.product_title = String(await ctx.title('product'));",
60+
"ctx.input.spare_title = String(await ctx.title('spare'));",
61+
].join('\n');
62+
63+
const hook = { name: 'nhd_name_line', object: 'nhd_line', events: ['beforeInsert'], handler: 'nhd_stamp_titles' };
64+
65+
type TitleCtx = { input: Record<string, unknown>; title: (field?: string) => Promise<string | null> };
66+
67+
/** The configuration: the hook names an entry of the stack's own `functions`. */
68+
const app = (): Record<string, unknown> =>
69+
defineStack({
70+
manifest,
71+
objects: [product, line],
72+
hooks: [hook],
73+
functions: {
74+
nhd_stamp_titles: async (ctx: TitleCtx) => {
75+
ctx.input.own_title = String(await ctx.title());
76+
ctx.input.product_title = String(await ctx.title('product'));
77+
ctx.input.spare_title = String(await ctx.title('spare'));
78+
},
79+
},
80+
} as never) as unknown as Record<string, unknown>;
81+
82+
/** The artifact: the same hook, its function shipped in the runtime module beside it. */
83+
const artifact = (): Record<string, unknown> => ({
84+
manifest,
85+
objects: [product, line],
86+
hooks: [hook],
87+
runtimeModule: './runtime.mjs',
88+
});
89+
const RUNTIME_MODULE = `export const functions = {\n nhd_stamp_titles: async (ctx) => {\n${SOURCE}\n },\n};\n`;
90+
91+
/** What a `body` answers on a served runtime, the answer both doors owe. */
92+
const EXPECTED = { own: 'L1 · Widget', product: 'Widget (W-1)', spare: 'null' };
93+
94+
let hostRoot: string;
95+
beforeAll(() => {
96+
hostRoot = mkdtempSync(join(tmpdir(), 'verify-named-hook-title-door-'));
97+
mkdirSync(join(hostRoot, 'dist'));
98+
writeFileSync(join(hostRoot, 'dist', 'objectstack.json'), JSON.stringify(artifact()));
99+
writeFileSync(join(hostRoot, 'dist', 'runtime.mjs'), RUNTIME_MODULE);
100+
});
101+
afterAll(() => {
102+
if (hostRoot) rmSync(hostRoot, { recursive: true, force: true });
103+
});
104+
105+
const live: VerifyStack[] = [];
106+
afterEach(async () => {
107+
for (const stack of live.splice(0)) await stack.stop();
108+
});
109+
110+
const titlesOn = async (stack: VerifyStack): Promise<Record<string, unknown>> => {
111+
live.push(stack);
112+
const widget = await stack.hooks.run('nhd_product', 'insert', { name: 'Widget', code: 'W-1' }, { system: true });
113+
const row = await stack.hooks.run('nhd_line', 'insert', { name: 'L1', product: widget.id }, { system: true });
114+
return { own: row.own_title, product: row.product_title, spare: row.spare_title };
115+
};
116+
117+
describe('#24289: a hook whose handler names a function gets ctx.title on both bootStack doors', () => {
118+
it('the artifact door: a string handler resolved from the runtime module', async () => {
119+
expect(await titlesOn(await bootStack(app(), { hostRoot, artifact: 'dist/objectstack.json' }))).toEqual(EXPECTED);
120+
}, BOOT_TIMEOUT);
121+
122+
it("the source door: a string handler resolved from the stack's functions", async () => {
123+
expect(await titlesOn(await bootStack(app(), { hostRoot }))).toEqual(EXPECTED);
124+
}, BOOT_TIMEOUT);
125+
});

0 commit comments

Comments
 (0)