Skip to content

Commit f2a8a48

Browse files
committed
fix(objectql,runtime,metadata-protocol): execute authored (Studio) hook bodies — wire default bodyRunner + live rebind (#2588)
A hook authored at runtime (protocol.saveMetaItem / publish-drafts) loaded into the registry but its L1/L2 body never executed — on publish AND after a full restart. Two gaps compounded: 1. No bodyRunner on the metadata-service bind path: bindHooksToEngine skips body-hooks without a runner, and engine.setDefaultBodyRunner was never called anywhere. AppPlugin.init now installs the QuickJS-sandboxed hook body runner as the engine default (same runner defineStack hooks already use), so runner-less bind paths execute bodies. Opt-out: OS_DISABLE_AUTHORED_HOOKS=1 keeps runtime-authored bodies inert. 2. The bind set never saw authored rows: env-scoped kernels have no DatabaseLoader, so metadataService.loadMany('hook') misses sys_metadata rows entirely — the hook was never even bound-and-skipped. ObjectQLPlugin now re-binds runtime-authored hooks from the rows themselves at kernel:ready (cold boot), on metadata:reloaded (dev artifact reload, package publish), and on every hook mutation via the new server-side protocol.onMetadataMutation listener (saveMetaItem / publishMetaItem / deleteMetaItem notify after persistence) — so direct saves, publishes, edits, and deletes all take effect without a restart. Double-execution guard: package-artifact hooks (bound by AppPlugin under app:<id> with an explicit runner) are excluded from the metadata-service bind via registry.getArtifactItem — previously their string-handler variants were latently double-bound, and a default runner would have made body hooks run twice per event. Deleting the last authored hook explicitly unregisters the package set (bindHooksToEngine early-returns on an empty list before its unregister step). Resyncs are serialized so overlapping mutations cannot land an older snapshot last. Verified end-to-end on a fresh showcase instance: authored beforeUpdate hook mutates input after live save, after draft+publish-drafts, and after a full restart; deletes stop it firing; the defineStack control hook still runs. Closes #2588 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PWLRAafXUQzfi2UNW9CcVp
1 parent e695fe0 commit f2a8a48

9 files changed

Lines changed: 827 additions & 9 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/objectql': patch
3+
'@objectstack/metadata-protocol': patch
4+
'@objectstack/runtime': patch
5+
---
6+
7+
Runtime-authored (Studio) hooks now execute their `body` (#2588).
8+
9+
Previously a hook authored at runtime (saved via `protocol.saveMetaItem` /
10+
`publish-drafts`) loaded into the registry but its L1/L2 `body` never ran — the
11+
metadata-service bind path passed no `bodyRunner` and the engine's
12+
`_defaultBodyRunner` fallback was never installed, so the binder silently
13+
skipped the body. Now:
14+
15+
- `AppPlugin` installs the QuickJS-sandboxed hook body runner as the engine
16+
default at boot (`engine.setDefaultBodyRunner`), so bind paths without an
17+
explicit runner can execute bodies. Opt out with
18+
`OS_DISABLE_AUTHORED_HOOKS=1` to keep runtime-authored hook bodies inert.
19+
- `ObjectQLPlugin` re-binds runtime-authored hooks from their `sys_metadata`
20+
rows at `kernel:ready` (cold boot — env-scoped kernels never surfaced these
21+
rows before), on `metadata:reloaded`, and on every hook mutation through the
22+
new `protocol.onMetadataMutation` listener — so saves, publishes, edits, and
23+
deletes take effect live, without a restart. Package-artifact hooks are
24+
excluded from this bind path (AppPlugin already binds them with an explicit
25+
runner) so they no longer risk double execution.
26+
- `@objectstack/metadata-protocol` gains a server-side
27+
`onMetadataMutation(listener)` API: `saveMetaItem` / `publishMetaItem` /
28+
`deleteMetaItem` notify subscribers after persistence succeeds.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Metadata-mutation listener contract (#2588).
5+
*
6+
* `onMetadataMutation` is the post-persistence notification every authoring
7+
* surface funnels through (saveMetaItem / publishMetaItem / deleteMetaItem
8+
* all emit). Runtime consumers — first: ObjectQLPlugin's authored-hook
9+
* rebind — subscribe to it instead of each HTTP surface hand-announcing.
10+
* The end-to-end emit points are exercised by the runtime integration flow;
11+
* these tests pin the listener contract itself.
12+
*/
13+
14+
import { describe, it, expect, vi } from 'vitest';
15+
import { ObjectStackProtocolImplementation } from './protocol.js';
16+
import type { MetadataMutationEvent } from './protocol.js';
17+
18+
function makeProtocol() {
19+
// The listener plumbing never touches the engine.
20+
return new ObjectStackProtocolImplementation({} as any);
21+
}
22+
23+
const evt = (over: Partial<MetadataMutationEvent> = {}): MetadataMutationEvent => ({
24+
type: 'hook',
25+
name: 'rebind_probe_hook',
26+
state: 'active',
27+
organizationId: null,
28+
...over,
29+
});
30+
31+
describe('ObjectStackProtocolImplementation.onMetadataMutation', () => {
32+
it('notifies subscribed listeners with the event', () => {
33+
const p = makeProtocol();
34+
const seen: MetadataMutationEvent[] = [];
35+
p.onMetadataMutation((e) => seen.push(e));
36+
37+
(p as any).emitMetadataMutation(evt());
38+
(p as any).emitMetadataMutation(evt({ state: 'deleted' }));
39+
40+
expect(seen.map((e) => e.state)).toEqual(['active', 'deleted']);
41+
expect(seen[0].type).toBe('hook');
42+
expect(seen[0].name).toBe('rebind_probe_hook');
43+
});
44+
45+
it('returns an unsubscribe function that stops delivery', () => {
46+
const p = makeProtocol();
47+
const listener = vi.fn();
48+
const unsubscribe = p.onMetadataMutation(listener);
49+
50+
(p as any).emitMetadataMutation(evt());
51+
unsubscribe();
52+
(p as any).emitMetadataMutation(evt());
53+
54+
expect(listener).toHaveBeenCalledTimes(1);
55+
});
56+
57+
it('isolates a throwing listener — remaining listeners still run', () => {
58+
const p = makeProtocol();
59+
const after = vi.fn();
60+
p.onMetadataMutation(() => { throw new Error('boom'); });
61+
p.onMetadataMutation(after);
62+
63+
expect(() => (p as any).emitMetadataMutation(evt())).not.toThrow();
64+
expect(after).toHaveBeenCalledTimes(1);
65+
});
66+
});

packages/metadata-protocol/src/protocol.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -719,6 +719,20 @@ export type PublishMaterializer = (args: {
719719
actor: string;
720720
}) => Promise<PublishMaterializeResult>;
721721

722+
/**
723+
* Post-persistence metadata-mutation notification (#2588). Emitted by
724+
* `saveMetaItem` / `publishMetaItem` / `deleteMetaItem` AFTER the write
725+
* landed. `type` is the singular metadata type name. Subscribe via
726+
* {@link ObjectStackProtocolImplementation.onMetadataMutation}.
727+
*/
728+
export interface MetadataMutationEvent {
729+
type: string;
730+
name: string;
731+
/** Resulting lifecycle state of the row the mutation produced. */
732+
state: 'active' | 'draft' | 'deleted';
733+
organizationId?: string | null;
734+
}
735+
722736
export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
723737
private engine: MetadataHostEngine;
724738
private getServicesRegistry?: () => Map<string, any>;
@@ -778,6 +792,50 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
778792
this.publishMaterializers.set(singular, materializer);
779793
}
780794

795+
/**
796+
* Runtime-mutation listeners (#2588). Every metadata mutation that lands
797+
* through this protocol — `saveMetaItem` (draft AND direct-active saves),
798+
* `publishMetaItem` (per-item and package publish-drafts), and
799+
* `deleteMetaItem` — notifies these listeners AFTER persistence succeeds.
800+
*
801+
* This is the ONE choke point every authoring surface funnels through
802+
* (rest-server, http-dispatcher, AI builders, direct protocol callers),
803+
* so boot-cached runtime consumers can re-sync on authoring without each
804+
* HTTP surface hand-announcing. First consumer: ObjectQLPlugin re-binds
805+
* runtime-authored hooks when a `hook` row changes.
806+
*
807+
* Server-side extension only — NOT part of the ObjectStackProtocol wire
808+
* contract (same status as `loadMetaFromDb`).
809+
*/
810+
private metadataMutationListeners: Array<(evt: MetadataMutationEvent) => void> = [];
811+
812+
/** Subscribe to post-persistence metadata mutations. Returns an unsubscribe fn. */
813+
onMetadataMutation(listener: (evt: MetadataMutationEvent) => void): () => void {
814+
this.metadataMutationListeners.push(listener);
815+
return () => {
816+
const i = this.metadataMutationListeners.indexOf(listener);
817+
if (i >= 0) this.metadataMutationListeners.splice(i, 1);
818+
};
819+
}
820+
821+
/**
822+
* Notify mutation listeners (best-effort, synchronous fan-out). A
823+
* listener failure must never fail the write it observes — the row is
824+
* already persisted — so each listener is isolated in its own try/catch.
825+
*/
826+
private emitMetadataMutation(evt: MetadataMutationEvent): void {
827+
for (const listener of this.metadataMutationListeners) {
828+
try {
829+
listener(evt);
830+
} catch (e) {
831+
console.warn(
832+
`[Protocol] metadata-mutation listener failed for ${evt.type}/${evt.name}: `
833+
+ `${e instanceof Error ? e.message : String(e)}`,
834+
);
835+
}
836+
}
837+
}
838+
781839
/**
782840
* Lazily obtain a SysMetadataRepository for the given organization.
783841
* Env-wide overlays (organizationId == null) share a singleton under
@@ -3905,6 +3963,12 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
39053963
source: 'protocol.saveMetaItem',
39063964
note: mode === 'draft' ? 'draft' : 'active',
39073965
});
3966+
this.emitMetadataMutation({
3967+
type: singularTypeForRepo,
3968+
name: request.name,
3969+
state: mode === 'draft' ? 'draft' : 'active',
3970+
organizationId: orgId,
3971+
});
39083972
return {
39093973
success: true,
39103974
version: result.version,
@@ -3996,6 +4060,12 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
39964060
await this.engine.insert('sys_metadata', row);
39974061
}
39984062

4063+
this.emitMetadataMutation({
4064+
type: PLURAL_TO_SINGULAR[request.type] ?? request.type,
4065+
name: request.name,
4066+
state: 'active',
4067+
organizationId: orgId,
4068+
});
39994069
return {
40004070
success: true,
40014071
message: orgId
@@ -4192,6 +4262,12 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
41924262
};
41934263
}
41944264
}
4265+
this.emitMetadataMutation({
4266+
type: singularType,
4267+
name: request.name,
4268+
state: 'active',
4269+
organizationId: orgId,
4270+
});
41954271
return response;
41964272
} catch (err: any) {
41974273
if (err instanceof ConflictError) {
@@ -5434,6 +5510,12 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
54345510
note: targetState,
54355511
});
54365512

5513+
this.emitMetadataMutation({
5514+
type: singularTypeForRepo,
5515+
name: request.name,
5516+
state: 'deleted',
5517+
organizationId: orgId,
5518+
});
54375519
return {
54385520
success: true,
54395521
reset: true,

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,51 @@ describe('bindHooksToEngine', () => {
9595
expect(calls).toEqual(['v2']);
9696
});
9797

98+
it('skips body-hooks when no bodyRunner is supplied and no default is installed', () => {
99+
const engine = makeEngine();
100+
const hook: Hook = {
101+
name: 'body-no-runner', object: 'account', events: ['beforeInsert'], priority: 100,
102+
body: { language: 'js', source: "ctx.input.x = 1;" },
103+
} as unknown as Hook;
104+
const result = bindHooksToEngine(engine, [hook], { packageId: 'p' });
105+
expect(result.registered).toBe(0);
106+
expect(result.skipped).toBe(1);
107+
expect(result.errors[0]?.reason).toMatch(/no bodyRunner/);
108+
});
109+
110+
it('falls back to the engine default bodyRunner via engine.bindHooks (#2588)', async () => {
111+
const engine = makeEngine();
112+
const calls: string[] = [];
113+
// Fake runner — the real QuickJS bridge lives in @objectstack/runtime;
114+
// here we only verify the engine-level fallback plumbing.
115+
engine.setDefaultBodyRunner((hook: Hook) => async () => {
116+
calls.push(hook.name);
117+
});
118+
const hook: Hook = {
119+
name: 'body-default-runner', object: 'account', events: ['beforeInsert'], priority: 100,
120+
body: { language: 'js', source: "ctx.input.x = 1;" },
121+
} as unknown as Hook;
122+
engine.bindHooks([hook], { packageId: 'metadata-service' });
123+
await engine.triggerHooks('beforeInsert', makeCtx());
124+
expect(calls).toEqual(['body-default-runner']);
125+
});
126+
127+
it('explicit bodyRunner wins over the engine default', async () => {
128+
const engine = makeEngine();
129+
const calls: string[] = [];
130+
engine.setDefaultBodyRunner(() => async () => { calls.push('default'); });
131+
const hook: Hook = {
132+
name: 'body-explicit', object: 'account', events: ['beforeInsert'], priority: 100,
133+
body: { language: 'js', source: "ctx.input.x = 1;" },
134+
} as unknown as Hook;
135+
engine.bindHooks([hook], {
136+
packageId: 'app:x',
137+
bodyRunner: () => async () => { calls.push('explicit'); },
138+
});
139+
await engine.triggerHooks('beforeInsert', makeCtx());
140+
expect(calls).toEqual(['explicit']);
141+
});
142+
98143
it('keeps hooks bound under different packageIds isolated', async () => {
99144
const engine = makeEngine();
100145
const calls: string[] = [];

0 commit comments

Comments
 (0)