Skip to content

Commit 39cf701

Browse files
committed
fix(objectql,runtime): a hook whose handler names a function gets ctx.title in-process
bindHooksToEngine gains a wrapHandler option, applied to every handler a hook runs in-process after a string handler resolves; the IObjectQLEngine.bindHooks and ObjectQL.bindHooks opts carry it. bindAppArtifactHandlers passes withInProcessHookTitle through it instead of pre-wrapping inline handlers, so the inline and the named handler take one wrap. Claude-Session: https://claude.ai/code/session_01UxK7vXeLCgYoxSXaHuQA1Y Co-authored-by: Claude <noreply@anthropic.com>
1 parent ee882b8 commit 39cf701

6 files changed

Lines changed: 284 additions & 24 deletions

File tree

‎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: {} } }) 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)