Skip to content

Commit 69be10e

Browse files
committed
test(runtime): pin the in-process ctx.title at the binder; changeset
Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
1 parent e4e883d commit 69be10e

3 files changed

Lines changed: 125 additions & 6 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): a hook's inline `handler` function is handed the same `ctx.title(field?)` a hook `body` gets, so a source boot (`@objectstack/verify`'s `bootStack(config)`, `os serve` over an `objectstack.config.ts`) answers it as `objectstack dev` does (#24289)
6+
7+
Clause-②: no
8+
9+
A source boot runs a hook's inline `handler` in-process, and the accessor existed only in the sandbox a `body` runs in. So a hook calling `await ctx.title()` or `await ctx.title('<lookup>')` answered on `objectstack dev` and an artifact boot, and under `bootStack(config)` refused every write with `TypeError: ctx.title is not a function`. `bindAppArtifactHandlers` now hands each inline handler function a view of the engine's hook context that answers `title` through the same accessor the sandbox installs: the same two forms, the same served formula title and related-record read on the context's own `ctx.api`, and `null`, never the record's id, when there is no title. Every other read and every write goes to the engine's context unchanged, and that context gains no key. A `handler` that names a function (the deprecated string form) is not covered by this change.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #24289 — `bindAppArtifactHandlers` hands a hook's inline `handler` function
5+
* the `ctx.title(field?)` a hook `body` gets, through the one accessor
6+
* (`buildTitleSurface`), on a REAL `ObjectQL` engine dispatching through its own
7+
* `triggerHooks`.
8+
*
9+
* The cross-door answer (a source boot against the artifact's body, on a booted
10+
* stack) is pinned in `@objectstack/verify`'s `harness.hook-title-door.test.ts`.
11+
* This file pins what only the binder can show:
12+
*
13+
* - both forms answer, and an absent title is `null`, as the VM delivers it;
14+
* - every write the handler makes lands on the engine's own context, while
15+
* that context never gains a `title` key;
16+
* - the app's declaration keeps its own function.
17+
*
18+
* ⚠️ `@objectstack/objectql` resolves to its `dist/` here, and so do the
19+
* record-title imports inside `body-runner.ts`: build it first.
20+
*/
21+
22+
import { describe, it, expect } from 'vitest';
23+
import { ObjectQL } from '@objectstack/objectql';
24+
import { bindAppArtifactHandlers } from './app-artifact-handlers.js';
25+
26+
const APP_ID = 'com.example.hook-titles';
27+
const quiet = { debug() {}, info() {}, warn() {}, error() {} };
28+
29+
const PRODUCT = { name: 'ttl_product', nameField: 'name', fields: { name: { type: 'text' } } };
30+
const LINE = {
31+
name: 'ttl_line',
32+
nameField: 'label',
33+
fields: {
34+
name: { type: 'text' },
35+
product: { type: 'lookup', reference: 'ttl_product' },
36+
spare: { type: 'lookup', reference: 'ttl_product' },
37+
label: { type: 'formula', expression: "'#' + record.name" },
38+
},
39+
};
40+
41+
function engine(): ObjectQL {
42+
const ql = new ObjectQL({ logger: quiet } as any);
43+
for (const o of [PRODUCT, LINE]) ql.registry.registerObject(o as any, 'test');
44+
return ql;
45+
}
46+
47+
/** The engine's own hook context shape: the payload wrapped under `input.data`, and the scoped `api`. */
48+
function hookContext(event: string, reads: string[]) {
49+
return {
50+
object: 'ttl_line',
51+
event,
52+
input: { data: { name: 'n1', product: 'p1' } },
53+
session: { isSystem: true },
54+
api: {
55+
object: (name: string) => ({
56+
findOne: async (q: { where: { id: string } }) => {
57+
reads.push(`${name}:${q.where.id}`);
58+
return { id: q.where.id, name: 'Widget' };
59+
},
60+
}),
61+
},
62+
} as Record<string, any>;
63+
}
64+
65+
describe('#24289: bindAppArtifactHandlers hands an inline hook handler ctx.title', () => {
66+
it('answers both forms through the one accessor; an absent title is null, never an id', async () => {
67+
const ql = engine();
68+
const seen: unknown[] = [];
69+
bindAppArtifactHandlers(ql as any, {
70+
manifest: { id: APP_ID, version: '0.1.0', type: 'app' },
71+
hooks: [{
72+
name: 'ttl_name_line',
73+
object: 'ttl_line',
74+
events: ['beforeInsert'],
75+
handler: async (ctx: any) => {
76+
seen.push(await ctx.title(), await ctx.title('product'), await ctx.title('spare'), await ctx.title(null));
77+
},
78+
}],
79+
}, { appId: APP_ID, logger: quiet });
80+
81+
const reads: string[] = [];
82+
await ql.triggerHooks('beforeInsert', hookContext('beforeInsert', reads) as any);
83+
84+
// This record's formula title over its own columns; the related record's
85+
// title off the one read the hook context's own `api` served.
86+
expect(seen).toEqual(['#n1', 'Widget', null, '#n1']);
87+
expect(reads).toEqual(['ttl_product:p1']);
88+
});
89+
90+
it("lands every write on the engine's context, which never gains a title key", async () => {
91+
const ql = engine();
92+
const handler = async (ctx: any) => {
93+
ctx.input.stamped = String(await ctx.title());
94+
ctx.result = { replaced: true };
95+
};
96+
const hook = { name: 'ttl_stamp_line', object: 'ttl_line', events: ['beforeInsert'], handler };
97+
bindAppArtifactHandlers(ql as any, { manifest: { id: APP_ID, version: '0.1.0', type: 'app' }, hooks: [hook] }, {
98+
appId: APP_ID,
99+
logger: quiet,
100+
});
101+
102+
const ctx = hookContext('beforeInsert', []);
103+
await ql.triggerHooks('beforeInsert', ctx as any);
104+
105+
expect(ctx.input.data.stamped).toBe('#n1');
106+
expect(ctx.result).toEqual({ replaced: true });
107+
expect('title' in ctx).toBe(false);
108+
expect(hook.handler, "the app's own declaration is not rewritten").toBe(handler);
109+
});
110+
});

‎packages/runtime/src/sandbox/body-runner.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -336,21 +336,21 @@ function buildTitleSurface(
336336
* removes another handler's accessor, and nothing serialising the context
337337
* finds a new key on it.
338338
*/
339-
export function withInProcessHookTitle<H extends (ctx: any) => unknown>(
340-
handler: H,
339+
export function withInProcessHookTitle(
340+
handler: (ctx: any) => unknown,
341341
ql: unknown,
342342
hookName: string,
343-
): (ctx: any) => Promise<Awaited<ReturnType<H>>> {
344-
return async function inProcessHookHandler(engineCtx: any) {
345-
if (engineCtx === null || typeof engineCtx !== 'object') return (await handler(engineCtx)) as Awaited<ReturnType<H>>;
343+
): (ctx: any) => Promise<unknown> {
344+
return async function inProcessHookHandler(engineCtx: any): Promise<unknown> {
345+
if (engineCtx === null || typeof engineCtx !== 'object') return handler(engineCtx);
346346
const surface = buildTitleSurface(engineCtx, ql, { kind: 'hook', name: hookName });
347347
const title = async (field?: unknown): Promise<string | null> =>
348348
(await surface(field === undefined || field === null ? undefined : String(field), undefined)) ?? null;
349349
const view = new Proxy(engineCtx, {
350350
get: (target, prop) => (prop === 'title' ? title : Reflect.get(target, prop)),
351351
has: (target, prop) => prop === 'title' || Reflect.has(target, prop),
352352
});
353-
return (await handler(view)) as Awaited<ReturnType<H>>;
353+
return handler(view);
354354
};
355355
}
356356

0 commit comments

Comments
 (0)