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
28 changes: 28 additions & 0 deletions .changeset/hook-bodyrunner-metadata-service.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@objectstack/objectql': patch
'@objectstack/metadata-protocol': patch
'@objectstack/runtime': patch
---

Runtime-authored (Studio) hooks now execute their `body` (#2588).

Previously a hook authored at runtime (saved via `protocol.saveMetaItem` /
`publish-drafts`) loaded into the registry but its L1/L2 `body` never ran — the
metadata-service bind path passed no `bodyRunner` and the engine's
`_defaultBodyRunner` fallback was never installed, so the binder silently
skipped the body. Now:

- `AppPlugin` installs the QuickJS-sandboxed hook body runner as the engine
default at boot (`engine.setDefaultBodyRunner`), so bind paths without an
explicit runner can execute bodies. Opt out with
`OS_DISABLE_AUTHORED_HOOKS=1` to keep runtime-authored hook bodies inert.
- `ObjectQLPlugin` re-binds runtime-authored hooks from their `sys_metadata`
rows at `kernel:ready` (cold boot — env-scoped kernels never surfaced these
rows before), on `metadata:reloaded`, and on every hook mutation through the
new `protocol.onMetadataMutation` listener — so saves, publishes, edits, and
deletes take effect live, without a restart. Package-artifact hooks are
excluded from this bind path (AppPlugin already binds them with an explicit
runner) so they no longer risk double execution.
- `@objectstack/metadata-protocol` gains a server-side
`onMetadataMutation(listener)` API: `saveMetaItem` / `publishMetaItem` /
`deleteMetaItem` notify subscribers after persistence succeeds.
66 changes: 66 additions & 0 deletions packages/metadata-protocol/src/mutation-listeners.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Metadata-mutation listener contract (#2588).
*
* `onMetadataMutation` is the post-persistence notification every authoring
* surface funnels through (saveMetaItem / publishMetaItem / deleteMetaItem
* all emit). Runtime consumers — first: ObjectQLPlugin's authored-hook
* rebind — subscribe to it instead of each HTTP surface hand-announcing.
* The end-to-end emit points are exercised by the runtime integration flow;
* these tests pin the listener contract itself.
*/

import { describe, it, expect, vi } from 'vitest';
import { ObjectStackProtocolImplementation } from './protocol.js';
import type { MetadataMutationEvent } from './protocol.js';

function makeProtocol() {
// The listener plumbing never touches the engine.
return new ObjectStackProtocolImplementation({} as any);
}

const evt = (over: Partial<MetadataMutationEvent> = {}): MetadataMutationEvent => ({
type: 'hook',
name: 'rebind_probe_hook',
state: 'active',
organizationId: null,
...over,
});

describe('ObjectStackProtocolImplementation.onMetadataMutation', () => {
it('notifies subscribed listeners with the event', () => {
const p = makeProtocol();
const seen: MetadataMutationEvent[] = [];
p.onMetadataMutation((e) => seen.push(e));

(p as any).emitMetadataMutation(evt());
(p as any).emitMetadataMutation(evt({ state: 'deleted' }));

expect(seen.map((e) => e.state)).toEqual(['active', 'deleted']);
expect(seen[0].type).toBe('hook');
expect(seen[0].name).toBe('rebind_probe_hook');
});

it('returns an unsubscribe function that stops delivery', () => {
const p = makeProtocol();
const listener = vi.fn();
const unsubscribe = p.onMetadataMutation(listener);

(p as any).emitMetadataMutation(evt());
unsubscribe();
(p as any).emitMetadataMutation(evt());

expect(listener).toHaveBeenCalledTimes(1);
});

it('isolates a throwing listener — remaining listeners still run', () => {
const p = makeProtocol();
const after = vi.fn();
p.onMetadataMutation(() => { throw new Error('boom'); });
p.onMetadataMutation(after);

expect(() => (p as any).emitMetadataMutation(evt())).not.toThrow();
expect(after).toHaveBeenCalledTimes(1);
});
});
82 changes: 82 additions & 0 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,20 @@ export type PublishMaterializer = (args: {
actor: string;
}) => Promise<PublishMaterializeResult>;

/**
* Post-persistence metadata-mutation notification (#2588). Emitted by
* `saveMetaItem` / `publishMetaItem` / `deleteMetaItem` AFTER the write
* landed. `type` is the singular metadata type name. Subscribe via
* {@link ObjectStackProtocolImplementation.onMetadataMutation}.
*/
export interface MetadataMutationEvent {
type: string;
name: string;
/** Resulting lifecycle state of the row the mutation produced. */
state: 'active' | 'draft' | 'deleted';
organizationId?: string | null;
}

export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
private engine: MetadataHostEngine;
private getServicesRegistry?: () => Map<string, any>;
Expand Down Expand Up @@ -778,6 +792,50 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
this.publishMaterializers.set(singular, materializer);
}

/**
* Runtime-mutation listeners (#2588). Every metadata mutation that lands
* through this protocol — `saveMetaItem` (draft AND direct-active saves),
* `publishMetaItem` (per-item and package publish-drafts), and
* `deleteMetaItem` — notifies these listeners AFTER persistence succeeds.
*
* This is the ONE choke point every authoring surface funnels through
* (rest-server, http-dispatcher, AI builders, direct protocol callers),
* so boot-cached runtime consumers can re-sync on authoring without each
* HTTP surface hand-announcing. First consumer: ObjectQLPlugin re-binds
* runtime-authored hooks when a `hook` row changes.
*
* Server-side extension only — NOT part of the ObjectStackProtocol wire
* contract (same status as `loadMetaFromDb`).
*/
private metadataMutationListeners: Array<(evt: MetadataMutationEvent) => void> = [];

/** Subscribe to post-persistence metadata mutations. Returns an unsubscribe fn. */
onMetadataMutation(listener: (evt: MetadataMutationEvent) => void): () => void {
this.metadataMutationListeners.push(listener);
return () => {
const i = this.metadataMutationListeners.indexOf(listener);
if (i >= 0) this.metadataMutationListeners.splice(i, 1);
};
}

/**
* Notify mutation listeners (best-effort, synchronous fan-out). A
* listener failure must never fail the write it observes — the row is
* already persisted — so each listener is isolated in its own try/catch.
*/
private emitMetadataMutation(evt: MetadataMutationEvent): void {
for (const listener of this.metadataMutationListeners) {
try {
listener(evt);
} catch (e) {
console.warn(
`[Protocol] metadata-mutation listener failed for ${evt.type}/${evt.name}: `
+ `${e instanceof Error ? e.message : String(e)}`,
);
}
}
}

/**
* Lazily obtain a SysMetadataRepository for the given organization.
* Env-wide overlays (organizationId == null) share a singleton under
Expand Down Expand Up @@ -3905,6 +3963,12 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
source: 'protocol.saveMetaItem',
note: mode === 'draft' ? 'draft' : 'active',
});
this.emitMetadataMutation({
type: singularTypeForRepo,
name: request.name,
state: mode === 'draft' ? 'draft' : 'active',
organizationId: orgId,
});
return {
success: true,
version: result.version,
Expand Down Expand Up @@ -3996,6 +4060,12 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
await this.engine.insert('sys_metadata', row);
}

this.emitMetadataMutation({
type: PLURAL_TO_SINGULAR[request.type] ?? request.type,
name: request.name,
state: 'active',
organizationId: orgId,
});
return {
success: true,
message: orgId
Expand Down Expand Up @@ -4192,6 +4262,12 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
};
}
}
this.emitMetadataMutation({
type: singularType,
name: request.name,
state: 'active',
organizationId: orgId,
});
return response;
} catch (err: any) {
if (err instanceof ConflictError) {
Expand Down Expand Up @@ -5434,6 +5510,12 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
note: targetState,
});

this.emitMetadataMutation({
type: singularTypeForRepo,
name: request.name,
state: 'deleted',
organizationId: orgId,
});
return {
success: true,
reset: true,
Expand Down
45 changes: 45 additions & 0 deletions packages/objectql/src/hook-binder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,51 @@ describe('bindHooksToEngine', () => {
expect(calls).toEqual(['v2']);
});

it('skips body-hooks when no bodyRunner is supplied and no default is installed', () => {
const engine = makeEngine();
const hook: Hook = {
name: 'body-no-runner', object: 'account', events: ['beforeInsert'], priority: 100,
body: { language: 'js', source: "ctx.input.x = 1;" },
} as unknown as Hook;
const result = bindHooksToEngine(engine, [hook], { packageId: 'p' });
expect(result.registered).toBe(0);
expect(result.skipped).toBe(1);
expect(result.errors[0]?.reason).toMatch(/no bodyRunner/);
});

it('falls back to the engine default bodyRunner via engine.bindHooks (#2588)', async () => {
const engine = makeEngine();
const calls: string[] = [];
// Fake runner — the real QuickJS bridge lives in @objectstack/runtime;
// here we only verify the engine-level fallback plumbing.
engine.setDefaultBodyRunner((hook: Hook) => async () => {
calls.push(hook.name);
});
const hook: Hook = {
name: 'body-default-runner', object: 'account', events: ['beforeInsert'], priority: 100,
body: { language: 'js', source: "ctx.input.x = 1;" },
} as unknown as Hook;
engine.bindHooks([hook], { packageId: 'metadata-service' });
await engine.triggerHooks('beforeInsert', makeCtx());
expect(calls).toEqual(['body-default-runner']);
});

it('explicit bodyRunner wins over the engine default', async () => {
const engine = makeEngine();
const calls: string[] = [];
engine.setDefaultBodyRunner(() => async () => { calls.push('default'); });
const hook: Hook = {
name: 'body-explicit', object: 'account', events: ['beforeInsert'], priority: 100,
body: { language: 'js', source: "ctx.input.x = 1;" },
} as unknown as Hook;
engine.bindHooks([hook], {
packageId: 'app:x',
bodyRunner: () => async () => { calls.push('explicit'); },
});
await engine.triggerHooks('beforeInsert', makeCtx());
expect(calls).toEqual(['explicit']);
});

it('keeps hooks bound under different packageIds isolated', async () => {
const engine = makeEngine();
const calls: string[] = [];
Expand Down
Loading