Skip to content

Commit c4526a7

Browse files
fix(runtime): an in-process hook handler is handed the same ctx.title a hook body gets (#24356)
Part of #24289 Clause-②: no Basis for the Clause-② line: `ctx.title(field?)`, which the served runtime already gives a hook `body`, is now also given to a hook's inline `handler` function on the in-process path. No request is newly accepted. No published TypeScript type gains a member: `HookContext` in `@objectstack/spec` is unchanged. No package entry exports anything new: `withInProcessHookTitle` is a module export of `packages/runtime/src/sandbox/body-runner.ts`, and neither `sandbox/index.ts` nor the package `index.ts` re-exports it. ## What changed - `packages/runtime/src/sandbox/body-runner.ts`: `withInProcessHookTitle(handler, ql, hookName)` wraps a hook handler function. The function is called with a view of the engine's hook context that answers `title`, and the answer comes from `buildTitleSurface`, the same accessor the sandbox installs for a `body`. Its read channel and formula semantics (#24197, PR #24307) are not changed. The one edit to it is its return-type annotation (`NonNullable`), because it always returns the function. The view is a Proxy: every other read and every write goes to the engine's own context, so `ctx.input` and `ctx.result` writes land where the engine reads them, and the context object gains no key. - `packages/runtime/src/app-artifact-handlers.ts`: `bindAppArtifactHandlers`, the one binder of an app's handlers, gives each hook whose `handler` is a function a copy with that function wrapped, before `ql.bindHooks`. The app's declaration is not rewritten. The engine's binder still decides whether `body` or `handler` runs. - The handler sees what the VM delivers: the same one-argument call, and `null` (never the record's id) when there is no title. The read channel is the hook context's own `ctx.api`, read at call time, so a `runAs` hook reads under its declared identity. Two rules stay body-only: the reference form has no `api.read` gate (an in-process handler already holds `ctx.api`), and the stored-metadata family is not refused. ## Measured - **Repro on main's runtime (A3).** `packages/runtime/src/sandbox/body-runner.ts` and `packages/runtime/src/app-artifact-handlers.ts` were restored to `ec749fbb4`, hash-equal to the base blobs. Runtime was rebuilt, and `withInProcessHookTitle` was absent from `packages/runtime/dist` (`scripts/ablation-dist-preflight.mjs ... --absent`, exit 0). Then the new verify pin went red on the source door only, with `TypeError: ctx.title is not a function` (1 failed, 1 passed; the artifact door answered). The new runtime pin went red on both cases with the same TypeError. Both files were restored to HEAD (hash-equal, `git diff HEAD` empty) and runtime was rebuilt, with the marker present in `dist/index.js` and `dist/index.cjs`. Both pins were then green. This was one run, recorded here and not kept as a test. - **Why the wrap is in the runtime.** The suggested route was one new option for `bindHooksToEngine`, passed through the runtime's bind call. That option cannot be typed without editing two files outside this card's scope. The runtime calls `ql.bindHooks` through `IObjectQLEngine`, whose options type is in `packages/spec/src/contracts/objectql-engine.ts`. `ObjectQL.bindHooks` declares its own options in `packages/objectql/src/engine.ts`. So the wrap happens in `packages/runtime` at bind time, and `packages/objectql` and `packages/spec` are not touched. - **`os serve` over a config (A4).** This goes through the same code, so it is covered here. `packages/cli/src/commands/serve.ts` mounts `new AppPlugin(config, ...)` when the config declares metadata and no AppPlugin is already present (line 3591 at `ec749fbb4`). `AppPlugin.start` binds through `bindAppArtifactHandlers` (`packages/runtime/src/app-plugin.ts`, line 1683). This was read from the code; no `os serve` process was started. - **The published type (A5).** `HookContext` (`HookContextSchema`, `packages/spec/src/data/hook.zod.ts`) declares no `title`, and this change does not add one. A `defineStack` inline handler's parameter is untyped (`HookSchema.handler` is a `(...args: any[]) => any` custom), so `ctx.title()` compiles there. A handler annotated `(ctx: HookContext)` gets TS2339 (measured with `tsc --strict` against the built spec at `a14895b71`). This is reported to the PM and not changed here. ## Tests - `packages/verify/src/harness.hook-title-door.test.ts` (new) runs the same `beforeInsert` statements as an inline handler on a source boot and as a body on the artifact door, which is the shape `objectstack dev` serves. Both doors answer `{ own: 'L1 · Widget', product: 'Widget (W-1)', spare: 'null' }`. That is this record's one-hop formula title, the related record's own formula title, and `null` for an empty lookup. - `packages/runtime/src/app-artifact-handlers.hook-title.test.ts` (new) uses a real `ObjectQL` that dispatches through `triggerHooks`. It checks five things. Both forms answer, and an absent title is `null`. The related read goes once through the hook context's own `api`. Writes land on the engine's context. That context never gains a `title` key. The app's declaration keeps its own function. - These local runs are at `a14895b71`, the final head, which is a merge of `origin/main` `51ad8e4fb`: - `pnpm --filter @objectstack/runtime typecheck`: exit 0. - `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2`: 420 files passed; 6608 tests passed, 19 skipped. - `pnpm --filter @objectstack/verify typecheck`: exit 0. - `pnpm --filter @objectstack/verify exec vitest run --maxWorkers=2`: 42 files passed; 364 tests passed. - `pnpm exec eslint --no-inline-config` over the four changed source and test files: exit 0, no findings. - The 63 families from `node scripts/pm/dispatch-gates.mjs --commands`, plus the 10 declared wide-population families whose population covers `packages/runtime` and `packages/verify`, all exited 0. `--ran` reconciliation: 63 derived, 63 run, 0 NOT-MEASURED. `check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET, no `dist/`). After a full `turbo run build`, it exited 0. ## Acceptance notes - **The card's remaining slice: a `handler` that names a function.** #24289 remains open for it. This PR covers a hook whose `handler` is a function. A hook whose `handler` NAMES a function also runs in-process: that is the deprecated string form, and also what `objectstack build` ships for a callable it could not lower to a body. Such a hook is still not given `ctx.title`, because the engine's binder looks the function up by name after `bindAppArtifactHandlers` has run. Measured at `7bd31c8e5` in a one-off run that was not committed: `typeof ctx.title` was `'undefined'` in two cases. One was the artifact door with a `runtimeModule` and a string `handler`, which is the loader `os start --artifact` uses. The other was a source boot with `functions` and `handler: 'name'`. The same function as an inline handler answered. The measured route is one `bindHooksToEngine` option (`packages/objectql/src/hook-binder.ts`), plus the `IObjectQLEngine.bindHooks` options type (`packages/spec/src/contracts/objectql-engine.ts`, about line 396) and the `ObjectQL.bindHooks` options (`packages/objectql/src/engine.ts`, about line 4849). That makes it a cross-lane slice, engine and spec. - **`HookContext` declares no `title`.** `HookContextSchema` (`packages/spec/src/data/hook.zod.ts`) has no `title` member. Under `tsc --strict`, a handler annotated `(ctx: HookContext)` that calls `ctx.title()` gets TS2339, while an unannotated `defineStack` inline handler compiles. Declaring the member is a published-type change in the spec lane. It is sequenced after the slice above, so the declaration does not run ahead of what the runtime delivers. - `skills/objectstack-data/references/data-hooks.md` is not edited. It already describes a `body` context as a subset of the in-process one, and this change makes that true for `ctx.title` on a function handler. Written in Claude Code session `session_01SYEJqDL3NXxrJRBVqaMu9T`. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 83dfbc0 commit c4526a7

5 files changed

Lines changed: 351 additions & 2 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('account_id')` (a lookup column) 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/app-artifact-handlers.ts‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,13 @@ import {
145145
} from '@objectstack/types';
146146
import { SEMCONV } from '@objectstack/observability';
147147
import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js';
148-
import { hookBodyRunnerFactory, actionBodyRunnerFactory, jobBodyRunnerFactory, judgeJobBody } from './sandbox/body-runner.js';
148+
import {
149+
hookBodyRunnerFactory,
150+
actionBodyRunnerFactory,
151+
jobBodyRunnerFactory,
152+
judgeJobBody,
153+
withInProcessHookTitle,
154+
} from './sandbox/body-runner.js';
149155
import { GLOBAL_ACTION_OBJECT_KEY } from './action-execution.js';
150156
import {
151157
collectBundleActions,
@@ -249,6 +255,9 @@ export function bindAppArtifactHandlers(
249255
);
250256
}
251257
}
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));
252261
// Entries, not bare handlers: each function's declared `effect`
253262
// (#4396) rides along to the registry, where a `script` node reads
254263
// it to report what its run actually did.
@@ -330,6 +339,25 @@ export function bindAppArtifactHandlers(
330339
return out;
331340
}
332341

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+
333361
// ─── The job half (#21489) ─────────────────────────────────────────────
334362

335363
/**

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

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,12 +239,20 @@ function buildBodyLogSurface(
239239
* privilege escalation around it. A related record that channel does not
240240
* return leaves a title over it `undefined`, as the REST read of the same
241241
* record shows it no title.
242+
*
243+
* ## Two faces, one accessor (#24289)
244+
*
245+
* A hook's code runs in one of two places: a `body`, in the VM, which reaches
246+
* this seam through `installCtx`; and an in-process `handler` function, which
247+
* reaches it through {@link withInProcessHookTitle}. Both call THIS function, so
248+
* the two answer one question one way: there is no second title implementation
249+
* for a handler to drift from.
242250
*/
243251
function buildTitleSurface(
244252
engineCtx: any,
245253
ql: any,
246254
origin: { kind: 'hook' | 'action'; name: string },
247-
): ScriptContext['title'] {
255+
): NonNullable<ScriptContext['title']> {
248256
const label = `ctx.title (${origin.kind} '${origin.name}')`;
249257

250258
return async (field: string | undefined, api: unknown): Promise<string | undefined> => {
@@ -289,6 +297,63 @@ function buildTitleSurface(
289297
};
290298
}
291299

300+
/**
301+
* [#24289] An in-process hook `handler` function, handed the same
302+
* `ctx.title(field?)` a hook `body` is handed.
303+
*
304+
* ## Why
305+
*
306+
* A source boot (`@objectstack/verify`'s `bootStack(config)`, `os serve` over an
307+
* `objectstack.config.ts`) runs a hook's inline `handler` in the host process;
308+
* `objectstack dev` and an artifact boot run the `body` the build lowered it to.
309+
* The accessor was installed only on the second path, so one hook answered on a
310+
* served runtime and threw `ctx.title is not a function` under the test harness
311+
* an app tests it with. The hook-context reference documents the body's `ctx` as
312+
* a subset of the in-process one, so the in-process one carries the accessor too.
313+
*
314+
* ## What the handler sees
315+
*
316+
* The {@link buildTitleSurface} answer, as the VM delivers it: the same
317+
* one-argument call (`undefined` or `null` names this record, any other value is
318+
* the reference field, as `installCtx` reads it), and `null` — never the record's
319+
* id — when there is no title. The read channel is the hook context's own `api`,
320+
* read at call time, so a `runAs` hook reads under the identity it declared, as
321+
* its body would.
322+
*
323+
* Two body-only rules stay body-only. No capability gates the reference form:
324+
* `api.read` is the sandbox's token, and an in-process handler already holds
325+
* `ctx.api` whole. And a stored-metadata family row is not refused: that boundary
326+
* binds a sandboxed body, and an in-process handler reads through `ctx.api`
327+
* without it.
328+
*
329+
* ## How
330+
*
331+
* The handler is called with a view of the engine's context that answers
332+
* `title` and passes every other read, and every write, to the context itself,
333+
* so `ctx.input` / `ctx.result` writes land where the engine reads them. The
334+
* engine's own context object is never touched: a fire-and-forget `after*` hook
335+
* still running while the next hook fires on the same context neither sees nor
336+
* removes another handler's accessor, and nothing serialising the context
337+
* finds a new key on it.
338+
*/
339+
export function withInProcessHookTitle(
340+
handler: (ctx: any) => unknown,
341+
ql: unknown,
342+
hookName: string,
343+
): (ctx: any) => Promise<unknown> {
344+
return async function inProcessHookHandler(engineCtx: any): Promise<unknown> {
345+
if (engineCtx === null || typeof engineCtx !== 'object') return handler(engineCtx);
346+
const surface = buildTitleSurface(engineCtx, ql, { kind: 'hook', name: hookName });
347+
const title = async (field?: unknown): Promise<string | null> =>
348+
(await surface(field === undefined || field === null ? undefined : String(field), undefined)) ?? null;
349+
const view = new Proxy(engineCtx, {
350+
get: (target, prop) => (prop === 'title' ? title : Reflect.get(target, prop)),
351+
has: (target, prop) => prop === 'title' || Reflect.has(target, prop),
352+
});
353+
return handler(view);
354+
};
355+
}
356+
292357
/**
293358
* The evaluation scope a formula title is computed in, derived from what the
294359
* hook context actually carries.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #24289 — a hook's `ctx.title(field?)` answers the same on both doors of
5+
* `bootStack`: the source boot, where the hook's inline `handler` runs
6+
* in-process, and the artifact door, where the `body` the artifact carries runs
7+
* in the sandbox, as `objectstack dev` serves it.
8+
*
9+
* Measured before (`origin/main` ec749fbb4): on the source boot every insert was
10+
* refused with `TypeError: ctx.title is not a function`, while the artifact's
11+
* body answered. The accessor was installed on the sandbox path only, so an app
12+
* could not test a hook that used it.
13+
*
14+
* Pinned on each door, for one `beforeInsert`:
15+
*
16+
* - `ctx.title()`: this record's title, a formula that reads one hop through a
17+
* lookup, so the in-flight foreign key is followed;
18+
* - `ctx.title('product')`: the related record's own formula title;
19+
* - `ctx.title('spare')`: an empty lookup, `null` and never an id.
20+
*/
21+
22+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
23+
import { tmpdir } from 'node:os';
24+
import { join } from 'node:path';
25+
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
26+
import { defineStack } from '@objectstack/spec';
27+
import { bootStack, type VerifyStack } from './harness.js';
28+
29+
const BOOT_TIMEOUT = 120_000;
30+
31+
const manifest = { id: 'com.example.hook-title-door', name: 'Hook Title Door', namespace: 'htd', version: '1.0.0', type: 'app' };
32+
33+
const product = {
34+
name: 'htd_product', label: 'Product', pluralLabel: 'Products', sharingModel: 'public_read_write',
35+
nameField: 'label',
36+
fields: {
37+
name: { name: 'name', type: 'text', label: 'Name', required: true },
38+
code: { name: 'code', type: 'text', label: 'Code' },
39+
label: { name: 'label', type: 'formula', label: 'Label', expression: "record.name + ' (' + record.code + ')'" },
40+
},
41+
};
42+
43+
const line = {
44+
name: 'htd_line', label: 'Line', pluralLabel: 'Lines', sharingModel: 'public_read_write',
45+
nameField: 'title',
46+
fields: {
47+
name: { name: 'name', type: 'text', label: 'Name', required: true },
48+
product: { name: 'product', type: 'lookup', label: 'Product', reference: 'htd_product' },
49+
spare: { name: 'spare', type: 'lookup', label: 'Spare', reference: 'htd_product' },
50+
title: { name: 'title', type: 'formula', label: 'Title', expression: "record.name + ' · ' + record.product.name" },
51+
own_title: { name: 'own_title', type: 'text', label: 'Own title' },
52+
product_title: { name: 'product_title', type: 'text', label: 'Product title' },
53+
spare_title: { name: 'spare_title', type: 'text', label: 'Spare title' },
54+
},
55+
};
56+
57+
/** One statement set, run in-process by the handler and in the sandbox by the body. */
58+
const SOURCE = [
59+
'ctx.input.own_title = String(await ctx.title());',
60+
"ctx.input.product_title = String(await ctx.title('product'));",
61+
"ctx.input.spare_title = String(await ctx.title('spare'));",
62+
].join('\n');
63+
64+
type TitleCtx = { input: Record<string, unknown>; title: (field?: string) => Promise<string | null> };
65+
66+
/** The configuration: the hook is an inline function, which a source boot runs in-process. */
67+
const app = (): Record<string, unknown> =>
68+
defineStack({
69+
manifest,
70+
objects: [product, line],
71+
hooks: [
72+
{
73+
name: 'htd_name_line',
74+
object: 'htd_line',
75+
events: ['beforeInsert'],
76+
handler: async (ctx: TitleCtx) => {
77+
ctx.input.own_title = String(await ctx.title());
78+
ctx.input.product_title = String(await ctx.title('product'));
79+
ctx.input.spare_title = String(await ctx.title('spare'));
80+
},
81+
},
82+
],
83+
} as never) as unknown as Record<string, unknown>;
84+
85+
/** The artifact: the same app, its hook carrying the same statements as a `body`. */
86+
const artifact = (): Record<string, unknown> =>
87+
JSON.parse(
88+
JSON.stringify(
89+
defineStack({
90+
manifest,
91+
objects: [product, line],
92+
hooks: [
93+
{
94+
name: 'htd_name_line',
95+
object: 'htd_line',
96+
events: ['beforeInsert'],
97+
body: { language: 'js', source: SOURCE, capabilities: ['api.read'] },
98+
},
99+
],
100+
} as never),
101+
),
102+
) as Record<string, unknown>;
103+
104+
/** What the body answers on a served runtime, the answer the source boot owes. */
105+
const EXPECTED = { own: 'L1 · Widget', product: 'Widget (W-1)', spare: 'null' };
106+
107+
let hostRoot: string;
108+
beforeAll(() => {
109+
hostRoot = mkdtempSync(join(tmpdir(), 'verify-hook-title-door-'));
110+
mkdirSync(join(hostRoot, 'dist'));
111+
writeFileSync(join(hostRoot, 'dist', 'objectstack.json'), JSON.stringify(artifact()));
112+
});
113+
afterAll(() => {
114+
if (hostRoot) rmSync(hostRoot, { recursive: true, force: true });
115+
});
116+
117+
const live: VerifyStack[] = [];
118+
afterEach(async () => {
119+
for (const stack of live.splice(0)) await stack.stop();
120+
});
121+
122+
const titlesOn = async (stack: VerifyStack): Promise<Record<string, unknown>> => {
123+
live.push(stack);
124+
const widget = await stack.hooks.run('htd_product', 'insert', { name: 'Widget', code: 'W-1' }, { system: true });
125+
const row = await stack.hooks.run('htd_line', 'insert', { name: 'L1', product: widget.id }, { system: true });
126+
return { own: row.own_title, product: row.product_title, spare: row.spare_title };
127+
};
128+
129+
describe('#24289: a hook\'s ctx.title answers the same on both bootStack doors', () => {
130+
it('the artifact door: the body, in the sandbox, as `objectstack dev` serves it', async () => {
131+
expect(await titlesOn(await bootStack(app(), { hostRoot, artifact: 'dist/objectstack.json' }))).toEqual(EXPECTED);
132+
}, BOOT_TIMEOUT);
133+
134+
it('the source door: the inline handler, in-process, gets the same answers', async () => {
135+
expect(await titlesOn(await bootStack(app(), { hostRoot }))).toEqual(EXPECTED);
136+
}, BOOT_TIMEOUT);
137+
});

0 commit comments

Comments
 (0)