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
9 changes: 9 additions & 0 deletions .changeset/24289-inprocess-hook-ctx-title.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@objectstack/runtime': patch
---

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)

Clause-②: no

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.
110 changes: 110 additions & 0 deletions packages/runtime/src/app-artifact-handlers.hook-title.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #24289 — `bindAppArtifactHandlers` hands a hook's inline `handler` function
* the `ctx.title(field?)` a hook `body` gets, through the one accessor
* (`buildTitleSurface`), on a REAL `ObjectQL` engine dispatching through its own
* `triggerHooks`.
*
* The cross-door answer (a source boot against the artifact's body, on a booted
* stack) is pinned in `@objectstack/verify`'s `harness.hook-title-door.test.ts`.
* This file pins what only the binder can show:
*
* - both forms answer, and an absent title is `null`, as the VM delivers it;
* - every write the handler makes lands on the engine's own context, while
* that context never gains a `title` key;
* - the app's declaration keeps its own function.
*
* ⚠️ `@objectstack/objectql` resolves to its `dist/` here, and so do the
* record-title imports inside `body-runner.ts`: build it first.
*/

import { describe, it, expect } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { bindAppArtifactHandlers } from './app-artifact-handlers.js';

const APP_ID = 'com.example.hook-titles';
const quiet = { debug() {}, info() {}, warn() {}, error() {} };

const PRODUCT = { name: 'ttl_product', nameField: 'name', fields: { name: { type: 'text' } } };
const LINE = {
name: 'ttl_line',
nameField: 'label',
fields: {
name: { type: 'text' },
product: { type: 'lookup', reference: 'ttl_product' },
spare: { type: 'lookup', reference: 'ttl_product' },
label: { type: 'formula', expression: "'#' + record.name" },
},
};

function engine(): ObjectQL {
const ql = new ObjectQL({ logger: quiet } as any);
for (const o of [PRODUCT, LINE]) ql.registry.registerObject(o as any, 'test');
return ql;
}

/** The engine's own hook context shape: the payload wrapped under `input.data`, and the scoped `api`. */
function hookContext(event: string, reads: string[]) {
return {
object: 'ttl_line',
event,
input: { data: { name: 'n1', product: 'p1' } },
session: { isSystem: true },
api: {
object: (name: string) => ({
findOne: async (q: { where: { id: string } }) => {
reads.push(`${name}:${q.where.id}`);
return { id: q.where.id, name: 'Widget' };
},
}),
},
} as Record<string, any>;
}

describe('#24289: bindAppArtifactHandlers hands an inline hook handler ctx.title', () => {
it('answers both forms through the one accessor; an absent title is null, never an id', async () => {
const ql = engine();
const seen: unknown[] = [];
bindAppArtifactHandlers(ql as any, {
manifest: { id: APP_ID, version: '0.1.0', type: 'app' },
hooks: [{
name: 'ttl_name_line',
object: 'ttl_line',
events: ['beforeInsert'],
handler: async (ctx: any) => {
seen.push(await ctx.title(), await ctx.title('product'), await ctx.title('spare'), await ctx.title(null));
},
}],
}, { appId: APP_ID, logger: quiet });

const reads: string[] = [];
await ql.triggerHooks('beforeInsert', hookContext('beforeInsert', reads) as any);

// This record's formula title over its own columns; the related record's
// title off the one read the hook context's own `api` served.
expect(seen).toEqual(['#n1', 'Widget', null, '#n1']);
expect(reads).toEqual(['ttl_product:p1']);
});

it("lands every write on the engine's context, which never gains a title key", async () => {
const ql = engine();
const handler = async (ctx: any) => {
ctx.input.stamped = String(await ctx.title());
ctx.result = { replaced: true };
};
const hook = { name: 'ttl_stamp_line', object: 'ttl_line', events: ['beforeInsert'], handler };
bindAppArtifactHandlers(ql as any, { manifest: { id: APP_ID, version: '0.1.0', type: 'app' }, hooks: [hook] }, {
appId: APP_ID,
logger: quiet,
});

const ctx = hookContext('beforeInsert', []);
await ql.triggerHooks('beforeInsert', ctx as any);

expect(ctx.input.data.stamped).toBe('#n1');
expect(ctx.result).toEqual({ replaced: true });
expect('title' in ctx).toBe(false);
expect(hook.handler, "the app's own declaration is not rewritten").toBe(handler);
});
});
30 changes: 29 additions & 1 deletion packages/runtime/src/app-artifact-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,13 @@ import {
} from '@objectstack/types';
import { SEMCONV } from '@objectstack/observability';
import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js';
import { hookBodyRunnerFactory, actionBodyRunnerFactory, jobBodyRunnerFactory, judgeJobBody } from './sandbox/body-runner.js';
import {
hookBodyRunnerFactory,
actionBodyRunnerFactory,
jobBodyRunnerFactory,
judgeJobBody,
withInProcessHookTitle,
} from './sandbox/body-runner.js';
import { GLOBAL_ACTION_OBJECT_KEY } from './action-execution.js';
import {
collectBundleActions,
Expand Down Expand Up @@ -249,6 +255,9 @@ export function bindAppArtifactHandlers(
);
}
}
// [#24289] An inline `handler` function runs in-process; it is handed the
// `ctx.title(field?)` a `body` gets in the sandbox (see `hookWithInProcessTitle`).
hooks = hooks.map((hook) => hookWithInProcessTitle(hook, ql));
// Entries, not bare handlers: each function's declared `effect`
// (#4396) rides along to the registry, where a `script` node reads
// it to report what its run actually did.
Expand Down Expand Up @@ -330,6 +339,25 @@ export function bindAppArtifactHandlers(
return out;
}

/**
* [#24289] `hook` with its inline `handler` function handed `ctx.title(field?)`
* ({@link withInProcessHookTitle}, the one accessor a `body` is handed too), or
* `hook` itself when its `handler` is not a function.
*
* A copy, so the app's own declaration is never rewritten. Which of `body` and
* `handler` runs stays the binder's call: a hook carrying both binds its `body`,
* and the wrapped function is never consulted.
*
* A `handler` that NAMES a function (the deprecated string form, and what the
* build leaves for a callable it could not lower) also runs in-process, but the
* function is found by name inside the engine's binder, past this point, so
* it is not handed the accessor here.
*/
function hookWithInProcessTitle(hook: any, ql: IObjectQLEngine): any {
if (!hook || typeof hook.handler !== 'function') return hook;
return { ...hook, handler: withInProcessHookTitle(hook.handler, ql, String(hook.name)) };
}

// ─── The job half (#21489) ─────────────────────────────────────────────

/**
Expand Down
67 changes: 66 additions & 1 deletion packages/runtime/src/sandbox/body-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,12 +239,20 @@ function buildBodyLogSurface(
* privilege escalation around it. A related record that channel does not
* return leaves a title over it `undefined`, as the REST read of the same
* record shows it no title.
*
* ## Two faces, one accessor (#24289)
*
* A hook's code runs in one of two places: a `body`, in the VM, which reaches
* this seam through `installCtx`; and an in-process `handler` function, which
* reaches it through {@link withInProcessHookTitle}. Both call THIS function, so
* the two answer one question one way: there is no second title implementation
* for a handler to drift from.
*/
function buildTitleSurface(
engineCtx: any,
ql: any,
origin: { kind: 'hook' | 'action'; name: string },
): ScriptContext['title'] {
): NonNullable<ScriptContext['title']> {
const label = `ctx.title (${origin.kind} '${origin.name}')`;

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

/**
* [#24289] An in-process hook `handler` function, handed the same
* `ctx.title(field?)` a hook `body` is handed.
*
* ## Why
*
* A source boot (`@objectstack/verify`'s `bootStack(config)`, `os serve` over an
* `objectstack.config.ts`) runs a hook's inline `handler` in the host process;
* `objectstack dev` and an artifact boot run the `body` the build lowered it to.
* The accessor was installed only on the second path, so one hook answered on a
* served runtime and threw `ctx.title is not a function` under the test harness
* an app tests it with. The hook-context reference documents the body's `ctx` as
* a subset of the in-process one, so the in-process one carries the accessor too.
*
* ## What the handler sees
*
* The {@link buildTitleSurface} answer, as the VM delivers it: the same
* one-argument call (`undefined` or `null` names this record, any other value is
* the reference field, as `installCtx` reads it), and `null` — never the record's
* id — when there is no title. The read channel is the hook context's own `api`,
* read at call time, so a `runAs` hook reads under the identity it declared, as
* its body would.
*
* Two body-only rules stay body-only. No capability gates the reference form:
* `api.read` is the sandbox's token, and an in-process handler already holds
* `ctx.api` whole. And a stored-metadata family row is not refused: that boundary
* binds a sandboxed body, and an in-process handler reads through `ctx.api`
* without it.
*
* ## How
*
* The handler is called with a view of the engine's context that answers
* `title` and passes every other read, and every write, to the context itself,
* so `ctx.input` / `ctx.result` writes land where the engine reads them. The
* engine's own context object is never touched: a fire-and-forget `after*` hook
* still running while the next hook fires on the same context neither sees nor
* removes another handler's accessor, and nothing serialising the context
* finds a new key on it.
*/
export function withInProcessHookTitle(
handler: (ctx: any) => unknown,
ql: unknown,
hookName: string,
): (ctx: any) => Promise<unknown> {
return async function inProcessHookHandler(engineCtx: any): Promise<unknown> {
if (engineCtx === null || typeof engineCtx !== 'object') return handler(engineCtx);
const surface = buildTitleSurface(engineCtx, ql, { kind: 'hook', name: hookName });
const title = async (field?: unknown): Promise<string | null> =>
(await surface(field === undefined || field === null ? undefined : String(field), undefined)) ?? null;
const view = new Proxy(engineCtx, {
get: (target, prop) => (prop === 'title' ? title : Reflect.get(target, prop)),
has: (target, prop) => prop === 'title' || Reflect.has(target, prop),
});
return handler(view);
};
}

/**
* The evaluation scope a formula title is computed in, derived from what the
* hook context actually carries.
Expand Down
137 changes: 137 additions & 0 deletions packages/verify/src/harness.hook-title-door.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #24289 — a hook's `ctx.title(field?)` answers the same on both doors of
* `bootStack`: the source boot, where the hook's inline `handler` runs
* in-process, and the artifact door, where the `body` the artifact carries runs
* in the sandbox, as `objectstack dev` serves it.
*
* Measured before (`origin/main` ec749fbb4): on the source boot every insert was
* refused with `TypeError: ctx.title is not a function`, while the artifact's
* body answered. The accessor was installed on the sandbox path only, so an app
* could not test a hook that used it.
*
* Pinned on each door, for one `beforeInsert`:
*
* - `ctx.title()`: this record's title, a formula that reads one hop through a
* lookup, so the in-flight foreign key is followed;
* - `ctx.title('product')`: the related record's own formula title;
* - `ctx.title('spare')`: an empty lookup, `null` and never an id.
*/

import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { defineStack } from '@objectstack/spec';
import { bootStack, type VerifyStack } from './harness.js';

const BOOT_TIMEOUT = 120_000;

const manifest = { id: 'com.example.hook-title-door', name: 'Hook Title Door', namespace: 'htd', version: '1.0.0', type: 'app' };

const product = {
name: 'htd_product', label: 'Product', pluralLabel: 'Products', sharingModel: 'public_read_write',
nameField: 'label',
fields: {
name: { name: 'name', type: 'text', label: 'Name', required: true },
code: { name: 'code', type: 'text', label: 'Code' },
label: { name: 'label', type: 'formula', label: 'Label', expression: "record.name + ' (' + record.code + ')'" },
},
};

const line = {
name: 'htd_line', label: 'Line', pluralLabel: 'Lines', sharingModel: 'public_read_write',
nameField: 'title',
fields: {
name: { name: 'name', type: 'text', label: 'Name', required: true },
product: { name: 'product', type: 'lookup', label: 'Product', reference: 'htd_product' },
spare: { name: 'spare', type: 'lookup', label: 'Spare', reference: 'htd_product' },
title: { name: 'title', type: 'formula', label: 'Title', expression: "record.name + ' · ' + record.product.name" },
own_title: { name: 'own_title', type: 'text', label: 'Own title' },
product_title: { name: 'product_title', type: 'text', label: 'Product title' },
spare_title: { name: 'spare_title', type: 'text', label: 'Spare title' },
},
};

/** One statement set, run in-process by the handler and in the sandbox by the body. */
const SOURCE = [
'ctx.input.own_title = String(await ctx.title());',
"ctx.input.product_title = String(await ctx.title('product'));",
"ctx.input.spare_title = String(await ctx.title('spare'));",
].join('\n');

type TitleCtx = { input: Record<string, unknown>; title: (field?: string) => Promise<string | null> };

/** The configuration: the hook is an inline function, which a source boot runs in-process. */
const app = (): Record<string, unknown> =>
defineStack({
manifest,
objects: [product, line],
hooks: [
{
name: 'htd_name_line',
object: 'htd_line',
events: ['beforeInsert'],
handler: async (ctx: TitleCtx) => {
ctx.input.own_title = String(await ctx.title());
ctx.input.product_title = String(await ctx.title('product'));
ctx.input.spare_title = String(await ctx.title('spare'));
},
},
],
} as never) as unknown as Record<string, unknown>;

/** The artifact: the same app, its hook carrying the same statements as a `body`. */
const artifact = (): Record<string, unknown> =>
JSON.parse(
JSON.stringify(
defineStack({
manifest,
objects: [product, line],
hooks: [
{
name: 'htd_name_line',
object: 'htd_line',
events: ['beforeInsert'],
body: { language: 'js', source: SOURCE, capabilities: ['api.read'] },
},
],
} as never),
),
) as Record<string, unknown>;

/** What the body answers on a served runtime, the answer the source boot owes. */
const EXPECTED = { own: 'L1 · Widget', product: 'Widget (W-1)', spare: 'null' };

let hostRoot: string;
beforeAll(() => {
hostRoot = mkdtempSync(join(tmpdir(), 'verify-hook-title-door-'));
mkdirSync(join(hostRoot, 'dist'));
writeFileSync(join(hostRoot, 'dist', 'objectstack.json'), JSON.stringify(artifact()));
});
afterAll(() => {
if (hostRoot) rmSync(hostRoot, { recursive: true, force: true });
});

const live: VerifyStack[] = [];
afterEach(async () => {
for (const stack of live.splice(0)) await stack.stop();
});

const titlesOn = async (stack: VerifyStack): Promise<Record<string, unknown>> => {
live.push(stack);
const widget = await stack.hooks.run('htd_product', 'insert', { name: 'Widget', code: 'W-1' }, { system: true });
const row = await stack.hooks.run('htd_line', 'insert', { name: 'L1', product: widget.id }, { system: true });
return { own: row.own_title, product: row.product_title, spare: row.spare_title };
};

describe('#24289: a hook\'s ctx.title answers the same on both bootStack doors', () => {
it('the artifact door: the body, in the sandbox, as `objectstack dev` serves it', async () => {
expect(await titlesOn(await bootStack(app(), { hostRoot, artifact: 'dist/objectstack.json' }))).toEqual(EXPECTED);
}, BOOT_TIMEOUT);

it('the source door: the inline handler, in-process, gets the same answers', async () => {
expect(await titlesOn(await bootStack(app(), { hostRoot }))).toEqual(EXPECTED);
}, BOOT_TIMEOUT);
});
Loading