Skip to content

Commit 98eb3b9

Browse files
fix(objectql,spec)!: a hook's handler name resolves inside the hook's own package only (#21604) (#21653)
Fixes #21604 Clause-②: yes (narrowing) Executes the maintainer's ruling on #21604 (comment 5974477722, letter B, 「同意」 2026-10-03T23:11Z): **a hook's `handler` name resolves inside the hook's own package only.** The functions the package's own runtime module registers keep resolving; a name the package does not hold is refused at registration, with a refusal that names it; `HookSchema.handler`'s declaration changes in the same PR. #21585's landed install-local refusal (#21615, `045b946256`) is untouched: no file of `packages/runtime` or `packages/cloud-connection` source changes here. ## Census first (the ruling's first step): zero dependents Every composition that could rely on cross-package resolution by name, read before any refusal was written: | Composition | Tree | What was read | Dependents | |:--|:--|:--|:--| | objectstack `examples/**` | objectstack `15fe567c9c` | string `handler:` values, `registerFunction` calls, `functions:` declarations, `composeStacks`, each app's hook forms | **0.** The only string `handler` is a job's (app-showcase `sweepProjectHealth`), which the job half resolves against its own bundle. app-showcase's 5 hooks all carry `body`; app-crm and app-todo each have 1 inline-function hook, which `os build` lowers to the hook's own name inside that app's own runtime module (same owner). app-multi-package and embed-objectql declare no hooks or functions. | | this repository's `--artifact` runtime modules | objectstack `15fe567c9c` | tracked `objectstack-runtime*.mjs`; tracked non-TS files naming `runtimeModule` | **0 committed.** A runtime module is a build output of an app's own config. | | hotcrm | `f24c196588` | the same greps | **0.** No string `handler:`, no `registerFunction`. Its 19 hook files are inline functions inside one `composeStacks` app (one owner), each lowered to its own name. | | objectos | `7612ffebd1` | the same greps | **0.** No hit for any of them. | | cloud | none | | **NOT MEASURED.** Unreachable from this session: a shallow clone has no credentials, a REST read answers 403 "not enabled for this session", and `add_repo` answers no access. | No stop condition fired: no real dependent was found, and owner-scoped resolution needed no new authorable spelling (see boundary flag 9). ## What changed, and where - `packages/objectql/src/hook-binder.ts`: `resolveHandler` resolves a string `handler` against the functions handed to the bind (the package's `functions`, which an `--artifact` runtime module supplies), then against the engine entry of that name **only if the entry's `packageId` equals the bind's `packageId`** (`ownPackageFunction`, reading the owner through the existing `resolveFunctionEntry`). A string handler that resolves to neither is refused at registration: an `Error` carrying `code: 'INVALID_REFERENCE'`, `status: 400`, `hook`, `handler` and `packageId`, recorded on `BindHooksResult.errors[]` (which gains optional `code` and `status`), logged at `error` with the `Error` in the logger's error slot, and thrown under `strict`. The hook is not bound. - `packages/objectql/src/engine.ts`: doc comments only (the registry and `registerFunction`). The lookup itself is unchanged: the entry already carried its owner. - `packages/spec/src/data/hook.zod.ts`: `HookSchema.handler`'s TSDoc stops declaring the engine-wide fallback ("anything `engine.registerFunction(name, fn)` added") and states the own-package rule, the refusal, and the route for a runtime-authored hook. The schema and its `.describe()` are unchanged, so no generated artifact moves (`check:generated`: all 15 up to date). - The landing matches the dispatch's expected surface; no producer elsewhere needed the fix. ## ① The accept set, before and after A hook whose `handler` is a function name and which has no `body` (a hook with a `body` binds exactly as before, body first): | Door | Before | After | |:--|:--|:--| | Boot of a code package (`AppPlugin`, a `defineStack` config, `os start --artifact`) | its own `functions` (runtime module included), then any function any package registered | its own `functions` (runtime module included), then functions its own package registered earlier; **another app's function is refused** | | Install-local (`os package install`) | handler-only hooks already refused at install and withheld on rehydrate | unchanged | | Metadata door (`PUT /api/v1/meta/hook/NAME`, bound under owner `metadata-service`) | any function any package registered | **none**: the owner registers no functions, so every handler-only authored hook is refused when the door binds it | | Multi-app composition (several apps on one engine) | app Y's hook could bind app X's function | **refused** | | Direct `bindHooksToEngine` with no `packageId` | any engine function | only the functions handed to that bind | | A name no package holds (typo) | skipped, `warn`, reason `unknown function 'NAME'` | refused: same envelope as above, `error` | ## ② Semver `minor` for `@objectstack/objectql` and `@objectstack/spec`, **BREAKING**, `!` in the title, `Clause-②: yes (narrowing)`, under the launch-window convention for narrowings of an accept set. The changeset (`.changeset/21604-hook-handler-package-scope.md`) carries the ADR-0087 disposition `not-required (no-migration-prescription)`, written from the census facts above: no authorable key, spelling, export of a published release or stored shape moves, so `objectstack migrate meta` has nothing to rewrite. (The marker sits in the changeset as the gate's comment-form marker; `check-adr-0087-registration --base origin/main` reads it green.) ## ③ Boundary flags 1. **Log level and text.** An unresolved string handler used to log `warn` with reason `unknown function 'NAME'`; it now logs `error` with the coded refusal's sentence, beside the binder's other coded registration refusal (the stored-metadata body boundary, also logged at `error` by this binder). The ruling asks for a loud refusal at registration. 2. **Result type.** `BindHooksResult.errors[]` gains optional `code` and `status` (additive output). `HOOK_HANDLER_NOT_IN_PACKAGE_CODE` and `HOOK_HANDLER_NOT_IN_PACKAGE_STATUS` are exported from `hook-binder.ts` only; neither `index.ts` nor `core.ts` re-exports them, so the package's public entry gains no symbol. 3. **The envelope (H4).** No coded refusal existed for this condition (the binder's unresolved branch carried a bare reason string). `INVALID_REFERENCE` / 400 is the standard catalog's member for a reference that does not resolve where it must. The ledger's admission rule sends a generic condition to the standard catalog, so no code is registered; `plugin-auth` already answers `INVALID_REFERENCE` for both a missing and a cross-scope reference. The sibling registration refusal's `PERMISSION_DENIED` / 403 was not reused: that refusal is about a permission on a table; this one is about a name that does not resolve, and a typo is no permission question. 4. **`strict`** (`OBJECTQL_STRICT_HOOKS=1`) throws the refusal. A strict runtime whose hook bound across packages now fails that bind, exactly as it already failed an unknown name. 5. **The metadata door (H2).** A runtime-authored hook is bound under the synthetic owner `metadata-service`, which registers no function, so a handler-only authored hook is always refused at bind. The save itself still answers as before: the pin records `200` for that `PUT`. That is the same posture as the stored-metadata body boundary, which refuses at bind and leaves the save door's answer unchanged. A `sys_metadata` hook row stamped with a `package_id` (the Studio package authoring workspace) is still bound under `metadata-service`, so it does not reach that package's runtime-module functions; before, it reached every function. Measured pull: zero string handlers anywhere in the census. Resolving by a row's own `package_id` would let any metadata author claim a package's code, which is the channel the ruling closes. 6. **A bind that names no package (H2).** With no `packageId`, a hook resolves only the functions handed to that bind; an engine entry registered without an owner is resolvable by no hook. Measured: every first-party door stamps an owner (`app:APPID`, `metadata-service`, `sys:audit`). After a platform boot (ObjectQL, sqlite-wasm, Hono, one app, platform objects, auth, security, sharing, REST, dispatcher), the engine's function registry holds exactly one entry, the app's own (`h3_fn`, owner `app:com.h3.probe`). I read "a name the package does not hold is refused" as covering a bind with no package; the reviewer may weigh that reading. 7. **The platform's own functions (H3).** None reach the engine registry. The formula stdlib's `registerFunction` registers into a `cel-js` `Environment`, not the engine (`packages/formula/src/stdlib.ts`). So no platform function's resolution changes; H3's "formula stdlib" leg is falsified. 8. **One artifact, one owner.** A multi-package artifact (`packages[]`, `composeStacks`) is bound under one owner `app:APPID`, with its functions flattened, so a hook in one composed package can still name a sibling package's function inside the same artifact. Census: app-multi-package declares no hooks or functions, and hotcrm's composition lowers each hook to its own name. Scoping inside one artifact would need per-package attribution in the bundle collectors, beyond "only as far as owner-scoped resolution needs it". 9. **No new spelling.** "Cross-package reuse must name the owning package explicitly" is met by an existing spelling: import the function from the package that owns it and declare it in your own `functions`. The refusal and the changeset prescribe exactly that, and no `pkg/fn` or `{ package, name }` form was minted. 10. **Install-local** is untouched. Its CLI integration pin (`packages/cli/test/package-install-local-hooks.integration.test.ts`, whose host hook names its own runtime-module function) is in the CLI integration tier and is declared to CI; this diff touches no CLI file. ## Pins - `packages/objectql/src/hook-binder-package-scope.test.ts`. Refusals, each asserting `code` `INVALID_REFERENCE`, `status` 400 and that the hook did not bind (the other package's function never runs): another package's function; the same under `strict` (thrown, with `hook`, `handler` and `packageId`); a name nobody holds; the metadata-door owner, read off the engine logger's `error` call; a bind with no package naming an unowned entry. Controls: a function handed to the hook's own bind; a function its own package registered in an earlier bind. - `packages/runtime/src/hook-handler-package-scope.pin.test.ts`, a composed kernel. ① Multi-app composition: app Y's hook naming app X's `x_stamp` is refused, and Y's insert is not stamped by X. ② Metadata door: `PUT /api/v1/meta/hook/scope_authored_cross` naming `x_stamp` is refused when the door binds it, while an authored `body` hook (the re-sync witness) fires. Controls: X's own hook binds and runs; app Z, loaded through `loadArtifactBundle` from an artifact whose runtime module exports `z_stamp`, binds and runs. - Re-triaged fixtures in `hook-binder.test.ts`: the two cases that pinned the text `unknown function` (the refused branch) now assert the envelope. ## Reverse verification (committed first, at `1eb671bac6`) The owner check was ablated through `scripts/ablation-replace.mjs` in WRAP mode, with an absolute-path `git checkout HEAD -- PATH` trap. The ablated `ownPackageFunction` resolves any entry by name, which is the old fallback. On-disk proof: anchor 1 → 0, replacement 0 → 1, blob `9301e0130c` → `49bf4c96cc`. `pnpm --filter @objectstack/objectql build` exited 0, and `ablation-dist-preflight` found the marker in all 4 JS files the runtime suite consumes. - objectql pins: **4 red** (another package's function, `strict`, metadata-door owner, unowned bind) and **30 green** (the typo refusal, both controls, the existing binder suite). - runtime composed pin: **2 red**, with the defect itself as the reason: Y's insert came back `|x-fn`, and the authored row came back `|x-fn|authored-body|x-fn`. **2 controls green.** - Restore: blob back to the `HEAD` blob `9301e0130c`, `git diff HEAD` empty, whole-tree `git status --porcelain` empty. After the rebuild, the marker is absent from all 14 `dist/` files and the pins are green again (34/34 and 4/4). - A first ablation run read the same red and green split, but its DTS step failed on the then-unused `packageId` parameter (the JS bundles still carried the marker). It was rerun with `void packageId;` so the build leg exits 0, and the figures above are from that clean run. ## Tests Suites at `1eb671bac6`; the later merges of `origin/main` (`b43c6fe76f`, `308ae946b9`) bring only service-analytics and CLI files, with no overlap. Build order: `turbo build --filter='@objectstack/runtime^...'`, then `--filter='@objectstack/dogfood^...' --filter=@objectstack/rest --filter=@objectstack/service-automation`, after the objectql change. - `@objectstack/objectql`: `local` project 370 files / 7441 passed; `repo` 1 / 5 passed; `typecheck` green (test layer within its pinned debt). - `@objectstack/runtime` (reads objectql's `dist/`): `local` 319 files / 4534 passed, 19 skipped; `repo` 3 / 751 passed; `typecheck` green. - `@objectstack/rest`: `local` 260 files / 4897 passed, 326 skipped; `repo` 5 / 177 passed, 1 skipped. - `@objectstack/service-automation`: 168 files / 2078 passed. - dogfood hook files (`hook-error-format`, `hook-refusal-user-facing-marking`, `hook-runas-fls`, `webhook-materialization`): 4 files / 13 passed. - `@objectstack/spec`: `check:generated`, all 15 artifacts up to date against a `dist/` whose declaration stamp matches. Direction: these are downstream consumers of objectql (runtime, rest, service-automation, dogfood); the spec edit is TSDoc only. ## Gates (at `308ae946b9`) `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, with no paths, derived 91 commands. That is the dispatch list plus `check-empty-changeset` (both), `release-rehearsal-clone --self-test`, `release-pending-publish --self-test`, `check:engine-double-contract`, `check:objectql-double-limit`, `check:objectui-changeset`, `check:pm-changeset-deadline-census`, `check:query-options-erasure`, `check:stack-collection-maps`, `check:swallow-census-controls`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. All 91 ran, each exit code captured before any pipe, and all 91 exited 0. `--ran` reconciliation: 91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN. On the first pass, `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET: 8 packages unrelated to this diff had no `dist/` in this worktree. Those were built, and it measured green. Lint, narrowed and proven: `eslint --no-inline-config --format json` over the 6 touched TS files reports 6 files linted, 0 errors and 0 warnings. That covers every TS file in the diff under the config's `**/*.ts` and `packages/**` globs. `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules), so the diff cannot move any untouched file's verdict. The repo-wide `pnpm lint` is CI's. ## NOT MEASURED - The cloud census: unreachable, as above. - CI-only families the derivation names, which have no local invocation: Test Core shards, Dogfood Regression Gate, Dogfood Verify CLI, Build Core, Temporal Conformance, and the workspace type-check lanes. - The CLI integration tier: declared to CI. - `check:objectui-pin-citations`: its self-test's live objectui round trip was skipped, because there is no objectui checkout here; the gate itself passed. ## Acceptance notes (observed, not filed) - `Action.target` and a flow `script` node's `config.function` still resolve through the engine's function registry by bare name (`service-automation` bridges `objectql.resolveFunction`). The ruling covers a hook's `handler` only. This is the same family on other surfaces, recorded from a code-read with no measured reach. - The registry stays keyed by bare name: two packages registering one name leave the later one's entry. A hook bound in the same call resolves its own bundle first, so boot binding is unaffected. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent be55fd2 commit 98eb3b9

8 files changed

Lines changed: 606 additions & 24 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/spec': minor
4+
---
5+
6+
fix(objectql,spec)!: a hook's `handler` name resolves inside the hook's own package only (#21604)
7+
8+
Clause-②: yes (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) no authorable key, spelling, export of a published release or stored shape moves: `HookSchema`'s shape is unchanged (only `HookSchema.handler`'s doc changes), so `objectstack migrate meta` has nothing to rewrite. What changes is which function a string `handler` may bind to at registration. Census of compositions relying on cross-package resolution by name, at the claim: zero in objectstack `examples/**` (15fe567c9c, whose only string `handler` is a job's), hotcrm (f24c196588) and objectos (7612ffebd1); this repository commits no `--artifact` runtime module; cloud is NOT MEASURED (unreachable from the claim's session). The other categories are closed on facts: both packages publish (not `unpublished`); no ADR-0087 id covers a binding rule (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
11+
12+
**BREAKING**: a hook whose `handler` is a function NAME (the deprecated form, `handler: 'my_fn'`, with no `body`) now binds only to a function its own package holds. It used to fall back to the engine-wide function registry, which is keyed by bare name, so the hook could bind to a function another package registered under the same name and run that package's code on its own events.
13+
14+
- **Accepted before:** a string `handler` resolved against the functions handed to the hook's bind, then against every function any package had registered on the engine. A name found nowhere was skipped with a `warn`.
15+
- **Accepted now:** a string `handler` resolves against the functions handed to the hook's bind (the package's `functions`, which an `--artifact` runtime module supplies), then against the functions the same package (`packageId`) registered on the engine. Nothing else.
16+
- **Refused now, at registration:** a name the hook's own package does not hold, whether another package registered it or nobody did. The hook is not bound. The refusal carries `INVALID_REFERENCE` with status `400` (ADR-0112), names the hook, the function and the package, and is recorded on the bind result (`BindHooksResult.errors[]` gains `code` and `status`) and logged at `error`. Under `strict` (`OBJECTQL_STRICT_HOOKS=1`) it is thrown.
17+
- **The doors:** a hook authored at runtime through the metadata API (`PUT /api/v1/meta/hook/:name`) ships with no code package and holds no functions, so a `handler`-only hook authored there is refused when the door binds it; the save itself still answers as before. In a composition of several apps, one app's hook can no longer bind to another app's function. A bind that names no owning package (direct `bindHooksToEngine` use without `packageId`) resolves only the functions handed to it.
18+
- **Unchanged:** a hook with a `body` binds as before. An app's hook naming its own `defineStack({ functions })` entry, or a function its own `--artifact` runtime module exports, binds as before. The install-local door's refusal of a hook with no `body` is unchanged.
19+
20+
What to do with a refused hook: give it a `body` (sandboxed JS), or declare the function in the hook's own package's `functions`. To reuse another package's function, import it from the package that owns it and declare it there. This ships as `minor`, under the launch-window convention for narrowings of an accept set.

‎packages/objectql/src/engine.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3657,10 +3657,11 @@ export class ObjectQL implements IObjectQLEngine {
36573657
*/
36583658
private readonly actionActivation = new ActionActivationProjection();
36593659

3660-
// Function registry: name → handler. Used by `bindHooksToEngine` to
3661-
// resolve string-named hook handlers (the JSON-safe form). Populated by
3662-
// `defineStack({ functions })` via `AppPlugin`, or directly via
3663-
// `engine.registerFunction(...)`.
3660+
// Function registry: name → handler, each entry stamped with its owning
3661+
// package. Used by `bindHooksToEngine` to resolve string-named hook
3662+
// handlers (the JSON-safe form) — only against entries the hook's OWN
3663+
// package registered. Populated by `defineStack({ functions })` via
3664+
// `AppPlugin`, or directly via `engine.registerFunction(...)`.
36643665
private functions = new Map<string, FunctionEntry>();
36653666

36663667
// Realtime service for event publishing
@@ -3867,7 +3868,8 @@ export class ObjectQL implements IObjectQLEngine {
38673868
* string from a `Hook.handler` field, an `Action.target`, or a flow
38683869
* `script` node's `config.function`. This is the JSON-safe form of
38693870
* handler binding — declarative metadata persisted to disk or shipped
3870-
* over the wire only carries the name.
3871+
* over the wire only carries the name. A `Hook.handler` reaches the entry
3872+
* only from a hook of the same `packageId` (`bindHooksToEngine`).
38713873
*
38723874
* The third parameter accepts either the owning `packageId` (its original
38733875
* shape, unchanged for every existing caller) or a
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A hook's `handler` name resolves inside the hook's OWN package only.
5+
*
6+
* The engine's function registry is keyed by bare name, and the binder used to
7+
* fall back to it unscoped: a hook naming `shared_stamp` bound to whichever
8+
* package had registered a function of that name, so that package's code ran on
9+
* this package's events. Now a name resolves against the functions handed to
10+
* the hook's own bind, then against the entries the SAME package registered; a
11+
* name the package does not hold is refused at registration with the ADR-0112
12+
* envelope (`INVALID_REFERENCE`, 400) and the hook is not bound.
13+
*
14+
* Every refusal here asserts the code, the status and that the hook did not
15+
* bind (the other package's function never runs on the event). The controls
16+
* are the two shapes a package's own functions take: the functions handed to
17+
* the same bind, and a function the same package registered in an earlier bind.
18+
*/
19+
20+
import { describe, it, expect, vi } from 'vitest';
21+
import { ObjectQL } from './engine.js';
22+
import {
23+
bindHooksToEngine,
24+
HOOK_HANDLER_NOT_IN_PACKAGE_CODE,
25+
HOOK_HANDLER_NOT_IN_PACKAGE_STATUS,
26+
} from './hook-binder.js';
27+
import type { Hook, HookContext } from '@objectstack/spec/data';
28+
29+
function captureLogger() {
30+
const logger: any = {
31+
debug: vi.fn(),
32+
info: vi.fn(),
33+
warn: vi.fn(),
34+
error: vi.fn(),
35+
trace: vi.fn(),
36+
fatal: vi.fn(),
37+
};
38+
logger.child = () => logger;
39+
return logger;
40+
}
41+
42+
function makeEngine(logger = captureLogger()) {
43+
return { engine: new ObjectQL({ logger }), logger };
44+
}
45+
46+
function ctxFor(object = 'account'): HookContext {
47+
return { object, event: 'beforeInsert', input: { data: {} }, ql: undefined } as unknown as HookContext;
48+
}
49+
50+
const hookNaming = (name: string, handler: string): Hook => ({
51+
name,
52+
object: 'account',
53+
events: ['beforeInsert'],
54+
priority: 100,
55+
handler,
56+
});
57+
58+
/** Package A registers `shared_stamp` the way a code package does: through its own bind's `functions`. */
59+
function registerPackageA(engine: ObjectQL, ran: string[]) {
60+
bindHooksToEngine(engine, [], {
61+
packageId: 'app:com.example.a',
62+
functions: { shared_stamp: async () => { ran.push('a:shared_stamp'); } },
63+
});
64+
}
65+
66+
describe('a hook handler name resolves inside its own package only', () => {
67+
it('the envelope constants are the standard catalog member and its status', () => {
68+
expect(HOOK_HANDLER_NOT_IN_PACKAGE_CODE).toBe('INVALID_REFERENCE');
69+
expect(HOOK_HANDLER_NOT_IN_PACKAGE_STATUS).toBe(400);
70+
});
71+
72+
it('refuses a hook naming a function ANOTHER package registered, and that function never runs', async () => {
73+
const { engine } = makeEngine();
74+
const ran: string[] = [];
75+
registerPackageA(engine, ran);
76+
77+
const result = bindHooksToEngine(engine, [hookNaming('b_cross', 'shared_stamp')], {
78+
packageId: 'app:com.example.b',
79+
});
80+
81+
expect(result.registered).toBe(0);
82+
expect(result.skipped).toBe(1);
83+
expect(result.errors).toHaveLength(1);
84+
expect(result.errors[0]).toMatchObject({ hook: 'b_cross', code: 'INVALID_REFERENCE', status: 400 });
85+
expect(result.errors[0]!.reason).toContain("'shared_stamp'");
86+
expect(result.errors[0]!.reason).toContain("'app:com.example.b'");
87+
88+
await engine.triggerHooks('beforeInsert', ctxFor());
89+
expect(ran, "package A's function ran on package B's event").toEqual([]);
90+
});
91+
92+
it('under strict, the refusal is thrown with its code and status, and nothing binds', async () => {
93+
const { engine } = makeEngine();
94+
const ran: string[] = [];
95+
registerPackageA(engine, ran);
96+
97+
let thrown: any;
98+
try {
99+
bindHooksToEngine(engine, [hookNaming('b_cross_strict', 'shared_stamp')], {
100+
packageId: 'app:com.example.b',
101+
strict: true,
102+
});
103+
} catch (err) {
104+
thrown = err;
105+
}
106+
expect(thrown).toBeInstanceOf(Error);
107+
expect(thrown).toMatchObject({
108+
code: 'INVALID_REFERENCE',
109+
status: 400,
110+
hook: 'b_cross_strict',
111+
handler: 'shared_stamp',
112+
packageId: 'app:com.example.b',
113+
});
114+
115+
await engine.triggerHooks('beforeInsert', ctxFor());
116+
expect(ran).toEqual([]);
117+
});
118+
119+
it('refuses a name no package holds with the same envelope', async () => {
120+
const { engine } = makeEngine();
121+
const result = bindHooksToEngine(engine, [hookNaming('typo_hook', 'shard_stamp')], {
122+
packageId: 'app:com.example.b',
123+
});
124+
expect(result.registered).toBe(0);
125+
expect(result.errors[0]).toMatchObject({ hook: 'typo_hook', code: 'INVALID_REFERENCE', status: 400 });
126+
});
127+
128+
it('the metadata door (owner `metadata-service`) cannot reach a code package\'s function; the refusal is logged at error with its envelope', async () => {
129+
const { engine, logger } = makeEngine();
130+
const ran: string[] = [];
131+
registerPackageA(engine, ran);
132+
133+
// The door the runtime-authored hooks are bound through.
134+
engine.bindHooks([hookNaming('authored_cross', 'shared_stamp')], { packageId: 'metadata-service' });
135+
136+
await engine.triggerHooks('beforeInsert', ctxFor());
137+
expect(ran, 'a runtime-authored hook ran a code package\'s function').toEqual([]);
138+
139+
const refusals = logger.error.mock.calls.filter(
140+
(call: any[]) => call[2]?.hook === 'authored_cross',
141+
);
142+
expect(refusals).toHaveLength(1);
143+
expect(refusals[0][1]).toBeInstanceOf(Error);
144+
expect(refusals[0][2]).toMatchObject({
145+
code: 'INVALID_REFERENCE',
146+
status: 400,
147+
handler: 'shared_stamp',
148+
packageId: 'metadata-service',
149+
});
150+
});
151+
152+
it('a bind that names no owning package resolves only what it was handed — never an unowned engine entry', async () => {
153+
const { engine } = makeEngine();
154+
const ran: string[] = [];
155+
engine.registerFunction('loose_fn', async () => { ran.push('loose_fn'); });
156+
157+
const result = bindHooksToEngine(engine, [hookNaming('unowned_hook', 'loose_fn')], {});
158+
expect(result.registered).toBe(0);
159+
expect(result.errors[0]).toMatchObject({ hook: 'unowned_hook', code: 'INVALID_REFERENCE', status: 400 });
160+
161+
await engine.triggerHooks('beforeInsert', ctxFor());
162+
expect(ran).toEqual([]);
163+
});
164+
165+
it('control: a hook naming a function handed to its own bind binds and runs', async () => {
166+
const { engine } = makeEngine();
167+
const ran: string[] = [];
168+
registerPackageA(engine, ran);
169+
170+
const result = bindHooksToEngine(engine, [hookNaming('b_own', 'b_stamp')], {
171+
packageId: 'app:com.example.b',
172+
functions: { b_stamp: async () => { ran.push('b:b_stamp'); } },
173+
});
174+
expect(result.registered).toBe(1);
175+
expect(result.errors).toEqual([]);
176+
177+
await engine.triggerHooks('beforeInsert', ctxFor());
178+
expect(ran).toEqual(['b:b_stamp']);
179+
});
180+
181+
it('control: a hook naming a function its OWN package registered in an earlier bind binds and runs', async () => {
182+
const { engine } = makeEngine();
183+
const ran: string[] = [];
184+
registerPackageA(engine, ran);
185+
186+
const result = bindHooksToEngine(engine, [hookNaming('a_own_later', 'shared_stamp')], {
187+
packageId: 'app:com.example.a',
188+
});
189+
expect(result.registered).toBe(1);
190+
expect(result.errors).toEqual([]);
191+
192+
await engine.triggerHooks('beforeInsert', ctxFor());
193+
expect(ran).toEqual(['a:shared_stamp']);
194+
});
195+
});

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ describe('bindHooksToEngine', () => {
6262
expect(seen).toEqual(['called']);
6363
});
6464

65-
it('skips hooks whose string handler cannot be resolved', () => {
65+
it('refuses a hook whose string handler names no function of its package', () => {
6666
const engine = makeEngine();
6767
const hook: Hook = {
6868
name: 'h3',
@@ -74,7 +74,7 @@ describe('bindHooksToEngine', () => {
7474
const result = bindHooksToEngine(engine, [hook], { packageId: 'p' });
7575
expect(result.registered).toBe(0);
7676
expect(result.skipped).toBe(1);
77-
expect(result.errors[0]?.reason).toMatch(/unknown function/);
77+
expect(result.errors[0]).toMatchObject({ hook: 'h3', code: 'INVALID_REFERENCE', status: 400 });
7878
});
7979

8080
// #4001: `normalizeObjects` used to widen a blank target to `['*']`, the
@@ -164,7 +164,7 @@ describe('bindHooksToEngine', () => {
164164
};
165165

166166
expect(() => bindHooksToEngine(engine, [hook], { strict: true }))
167-
.toThrow(/unknown function 'no_such_fn'/);
167+
.toThrowError(expect.objectContaining({ code: 'INVALID_REFERENCE', status: 400, handler: 'no_such_fn' }));
168168
});
169169

170170
it('still records-and-continues when strict is off', () => {

0 commit comments

Comments
 (0)