diff --git a/.changeset/hook-bodyrunner-metadata-service.md b/.changeset/hook-bodyrunner-metadata-service.md new file mode 100644 index 0000000000..21695ddaeb --- /dev/null +++ b/.changeset/hook-bodyrunner-metadata-service.md @@ -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. diff --git a/packages/metadata-protocol/src/mutation-listeners.test.ts b/packages/metadata-protocol/src/mutation-listeners.test.ts new file mode 100644 index 0000000000..c0d66a5252 --- /dev/null +++ b/packages/metadata-protocol/src/mutation-listeners.test.ts @@ -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 => ({ + 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); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index f1b0e50570..018b084e11 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -719,6 +719,20 @@ export type PublishMaterializer = (args: { actor: string; }) => Promise; +/** + * 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; @@ -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 @@ -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, @@ -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 @@ -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) { @@ -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, diff --git a/packages/objectql/src/hook-binder.test.ts b/packages/objectql/src/hook-binder.test.ts index 8d30c1ba22..eeb579a0ed 100644 --- a/packages/objectql/src/hook-binder.test.ts +++ b/packages/objectql/src/hook-binder.test.ts @@ -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[] = []; diff --git a/packages/objectql/src/plugin-authored-hooks.test.ts b/packages/objectql/src/plugin-authored-hooks.test.ts new file mode 100644 index 0000000000..9ba41abf43 --- /dev/null +++ b/packages/objectql/src/plugin-authored-hooks.test.ts @@ -0,0 +1,249 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Runtime-authored hook re-sync (#2588). + * + * Hooks authored in the Studio persist as `sys_metadata` rows that the + * metadata service never surfaces on env-scoped kernels, so the boot bind + * misses them entirely. `ObjectQLPlugin.resyncAuthoredHooks` re-binds them + * from the rows themselves at `kernel:ready` and on `metadata:reloaded`. + * These tests exercise that logic against a mocked engine — the sandbox + * execution of `body` itself is covered by @objectstack/runtime tests. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ObjectQLPlugin } from './plugin.js'; +import type { ObjectQL } from './engine.js'; + +type AnyRecord = Record; + +function makeQlMock(overrides: AnyRecord = {}) { + return { + bindHooks: vi.fn(), + unregisterHooksByPackage: vi.fn(), + find: vi.fn(async () => []), + registry: { + getArtifactItem: vi.fn(() => undefined), + }, + ...overrides, + }; +} + +function makeCtx(services: AnyRecord = {}) { + return { + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + getService: vi.fn((name: string) => { + if (name in services) return services[name]; + throw new Error(`service '${name}' not registered`); + }), + hook: vi.fn(), + } as AnyRecord; +} + +function makePlugin(ql: AnyRecord) { + return new ObjectQLPlugin({ ql: ql as unknown as ObjectQL }); +} + +const hookRow = (name: string, extra: AnyRecord = {}) => ({ + type: 'hook', + name, + state: 'active', + metadata: JSON.stringify({ + name, + object: 'showcase_task', + events: ['beforeUpdate'], + body: { language: 'js', source: "ctx.input.assignee = 'HOOKED';" }, + priority: 10, + }), + ...extra, +}); + +describe('ObjectQLPlugin.resyncAuthoredHooks (#2588)', () => { + let ql: AnyRecord; + + beforeEach(() => { + ql = makeQlMock(); + }); + + it('binds active sys_metadata hook rows under packageId metadata-service', async () => { + ql.find.mockImplementation(async (obj: string, q: AnyRecord) => + q?.where?.type === 'hook' ? [hookRow('rebind_probe_hook')] : []); + const plugin = makePlugin(ql); + const ctx = makeCtx(); + + await (plugin as any).resyncAuthoredHooks(ctx); + + expect(ql.bindHooks).toHaveBeenCalledTimes(1); + const [hooks, opts] = ql.bindHooks.mock.calls[0]; + expect(opts).toEqual({ packageId: 'metadata-service' }); + expect(hooks).toHaveLength(1); + expect(hooks[0].name).toBe('rebind_probe_hook'); + expect(hooks[0].body?.source).toContain('HOOKED'); + }); + + it('grafts the row package_id as _packageId onto the parsed hook', async () => { + ql.find.mockImplementation(async (_obj: string, q: AnyRecord) => + q?.where?.type === 'hook' ? [hookRow('h1', { package_id: 'com.example.ops' })] : []); + const plugin = makePlugin(ql); + + await (plugin as any).resyncAuthoredHooks(makeCtx()); + + const [hooks] = ql.bindHooks.mock.calls[0]; + expect(hooks[0]._packageId).toBe('com.example.ops'); + }); + + it('filters out artifact-shipped hooks (bound by AppPlugin) to prevent double execution', async () => { + ql.registry.getArtifactItem.mockImplementation((_type: string, name: string) => + name === 'showcase_normalize_task_title' ? { name } : undefined); + ql.find.mockImplementation(async (_obj: string, q: AnyRecord) => + q?.where?.type === 'hook' + ? [hookRow('authored_hook'), hookRow('showcase_normalize_task_title')] + : []); + const plugin = makePlugin(ql); + + await (plugin as any).resyncAuthoredHooks(makeCtx()); + + const [hooks] = ql.bindHooks.mock.calls[0]; + expect(hooks.map((h: AnyRecord) => h.name)).toEqual(['authored_hook']); + }); + + it('unions metadata-service hooks with DB rows; the DB row wins by name', async () => { + const metadataService = { + loadMany: vi.fn(async (type: string) => + type === 'hook' + ? [ + { name: 'fs_hook', object: 'a', events: ['beforeInsert'], handler: 'fn_a' }, + { name: 'edited_hook', object: 'a', events: ['beforeInsert'], handler: 'stale' }, + ] + : []), + }; + ql.find.mockImplementation(async (_obj: string, q: AnyRecord) => + q?.where?.type === 'hook' + ? [hookRow('edited_hook'), hookRow('new_authored_hook')] + : []); + const plugin = makePlugin(ql); + + await (plugin as any).resyncAuthoredHooks(makeCtx({ metadata: metadataService })); + + const [hooks] = ql.bindHooks.mock.calls[0]; + const names = hooks.map((h: AnyRecord) => h.name).sort(); + expect(names).toEqual(['edited_hook', 'fs_hook', 'new_authored_hook']); + const edited = hooks.find((h: AnyRecord) => h.name === 'edited_hook'); + expect(edited.handler).toBeUndefined(); // fresh DB body replaced the stale service copy + expect(edited.body?.source).toContain('HOOKED'); + }); + + it('tears down all bindings when the last authored hook row was deleted', async () => { + ql.find.mockResolvedValue([]); + const plugin = makePlugin(ql); + + await (plugin as any).resyncAuthoredHooks(makeCtx()); + + // Zero rows is a legitimate state. bindHooksToEngine early-returns on an + // empty list BEFORE its unregister step, so the resync must tear the + // package set down explicitly or the deleted hook keeps firing. + expect(ql.bindHooks).not.toHaveBeenCalled(); + expect(ql.unregisterHooksByPackage).toHaveBeenCalledWith('metadata-service'); + }); + + it('is a no-op when neither source is readable (never tears down on a failed read)', async () => { + ql.find.mockRejectedValue(new Error('no such table: sys_metadata')); + const plugin = makePlugin(ql); + + await (plugin as any).resyncAuthoredHooks(makeCtx()); // no metadata service either + + expect(ql.bindHooks).not.toHaveBeenCalled(); + }); + + it('falls back to legacy plural rows when no singular rows exist', async () => { + ql.find.mockImplementation(async (_obj: string, q: AnyRecord) => { + if (q?.where?.type === 'hooks') return [hookRow('legacy_plural_hook', { type: 'hooks' })]; + return []; + }); + const plugin = makePlugin(ql); + + await (plugin as any).resyncAuthoredHooks(makeCtx()); + + const [hooks] = ql.bindHooks.mock.calls[0]; + expect(hooks.map((h: AnyRecord) => h.name)).toEqual(['legacy_plural_hook']); + }); + + it('skips malformed rows without dropping the rest', async () => { + ql.find.mockImplementation(async (_obj: string, q: AnyRecord) => + q?.where?.type === 'hook' + ? [{ type: 'hook', name: 'broken', state: 'active', metadata: '{not json' }, hookRow('good_hook')] + : []); + const plugin = makePlugin(ql); + + await (plugin as any).resyncAuthoredHooks(makeCtx()); + + const [hooks] = ql.bindHooks.mock.calls[0]; + expect(hooks.map((h: AnyRecord) => h.name)).toEqual(['good_hook']); + }); +}); + +describe('ObjectQLPlugin protocol-mutation subscription (#2588)', () => { + it('re-syncs authored hooks when a hook row mutation lands (skips drafts and other types)', async () => { + const ql = makeQlMock({ registerApp: vi.fn(), setDatasourceMapping: vi.fn() }); + const plugin = new ObjectQLPlugin({ ql: ql as unknown as ObjectQL, environmentId: 'env_t' }); + const registered = new Map(); + const ctx = { + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerService: vi.fn((name: string, svc: any) => registered.set(name, svc)), + getService: vi.fn(() => { throw new Error('none'); }), + } as AnyRecord; + + await (plugin as any).init(ctx); + + const protocol = registered.get('protocol'); + expect(protocol).toBeDefined(); + const resync = vi + .spyOn(plugin as any, 'resyncAuthoredHooks') + .mockResolvedValue(undefined); + + (protocol as any).emitMetadataMutation({ type: 'hook', name: 'h1', state: 'active' }); + expect(resync).toHaveBeenCalledTimes(1); + + // Drafts are not live — no rebind. + (protocol as any).emitMetadataMutation({ type: 'hook', name: 'h1', state: 'draft' }); + expect(resync).toHaveBeenCalledTimes(1); + + // Other metadata types don't churn the hook bindings. + (protocol as any).emitMetadataMutation({ type: 'view', name: 'v1', state: 'active' }); + expect(resync).toHaveBeenCalledTimes(1); + + // Deletes rebind (teardown). + (protocol as any).emitMetadataMutation({ type: 'hook', name: 'h1', state: 'deleted' }); + expect(resync).toHaveBeenCalledTimes(2); + }); +}); + +describe('ObjectQLPlugin boot bind artifact filter (#2588)', () => { + it('excludes artifact-shipped hooks from the metadata-service boot bind', async () => { + const ql = makeQlMock({ + registry: { + getArtifactItem: vi.fn((_type: string, name: string) => + name === 'artifact_hook' ? { name } : undefined), + registerItem: vi.fn(), + }, + registerFunction: vi.fn(), + }); + const metadataService = { + loadMany: vi.fn(async (type: string) => + type === 'hook' + ? [ + { name: 'artifact_hook', object: 'a', events: ['beforeInsert'], body: { language: 'js', source: 'x' }, _packageId: 'com.example.app' }, + { name: 'authored_hook', object: 'a', events: ['beforeInsert'], body: { language: 'js', source: 'y' } }, + ] + : []), + }; + const plugin = makePlugin(ql); + + await (plugin as any).loadMetadataFromService(metadataService, makeCtx()); + + expect(ql.bindHooks).toHaveBeenCalledTimes(1); + const [hooks, opts] = ql.bindHooks.mock.calls[0]; + expect(opts).toEqual({ packageId: 'metadata-service' }); + expect(hooks.map((h: AnyRecord) => h.name)).toEqual(['authored_hook']); + }); +}); diff --git a/packages/objectql/src/plugin.integration.test.ts b/packages/objectql/src/plugin.integration.test.ts index fa8ddc1cc7..ae65748a10 100644 --- a/packages/objectql/src/plugin.integration.test.ts +++ b/packages/objectql/src/plugin.integration.test.ts @@ -941,14 +941,18 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => { // A driver that serves one runtime-created object (with inline fields) // from sys_metadata — models an isolated, proxy-free project kernel. - const makeLocalMetadataDriver = (findCalls: Array<{ object: string }>) => ({ + const makeLocalMetadataDriver = (findCalls: Array<{ object: string; query?: any }>) => ({ name: 'local-meta-driver', version: '1.0.0', connect: async () => {}, disconnect: async () => {}, - find: async (object: string) => { - findCalls.push({ object }); + find: async (object: string, query?: any) => { + findCalls.push({ object, query }); if (object === 'sys_metadata') { + // Honour a type filter (the authored-hook resync queries + // type='hook'); the object-hydration path queries without one. + const typeFilter = query?.where?.type; + if (typeFilter !== undefined && typeFilter !== 'object') return []; return [ { id: '1', @@ -975,8 +979,10 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => { it('does NOT hydrate a project kernel (environmentId set) without the opt-in', async () => { // Default behavior: a project kernel sources metadata from the artifact / - // control-plane proxy, so boot must not read a local sys_metadata. - const findCalls: Array<{ object: string }> = []; + // control-plane proxy, so boot must not hydrate OBJECTS from a local + // sys_metadata. The only permitted boot read is the runtime-authored + // hook re-sync (#2588), which is narrowly scoped to type='hook'. + const findCalls: Array<{ object: string; query?: any }> = []; await kernel.use({ name: 'mock-noopt-driver', type: 'driver', @@ -988,7 +994,10 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => { await kernel.use(plugin); await kernel.bootstrap(); - expect(findCalls.find((c) => c.object === 'sys_metadata')).toBeUndefined(); + const metaReads = findCalls.filter((c) => c.object === 'sys_metadata'); + for (const read of metaReads) { + expect(read.query?.where?.type).toMatch(/^hooks?$/); + } const registry = (kernel.getService('objectql') as any).registry; expect(registry.getObject('product')).toBeUndefined(); }); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index d0c1c55a67..7cc4768549 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -215,6 +215,28 @@ export class ObjectQLPlugin implements Plugin { ctx.registerService('protocol', protocolShim); ctx.logger.info('Protocol service registered'); + // ── Runtime-authored hook rebind on authoring (#2588) ──────────────── + // The protocol is the ONE choke point every metadata-authoring surface + // funnels through (rest-server PUT /meta, dispatcher, publish-drafts, AI + // builders). When a `hook` row lands (direct-active save, publish) or is + // deleted, re-bind the authored-hook set so the change is live without a + // restart. Draft saves are skipped — drafts are not live by design. + // Fire-and-forget: a rebind failure is logged, never fails the write. + if (typeof (protocolShim as any).onMetadataMutation === 'function') { + const unsubscribe = (protocolShim as any).onMetadataMutation( + (evt: { type: string; name: string; state: string }) => { + if (evt?.type !== 'hook' || evt.state === 'draft') return; + void this.resyncAuthoredHooks(ctx).catch((e: any) => { + ctx.logger.warn('[ObjectQLPlugin] authored-hook rebind after mutation failed', { + hook: evt.name, + error: e?.message, + }); + }); + }, + ); + this.metadataUnsubscribes.push(unsubscribe); + } + // Register an `analytics` service adapter that maps the dispatcher's // expected interface (query / getMeta / generateSql) onto the // protocol shim's `analyticsQuery`. Without this, HttpDispatcher's @@ -284,7 +306,27 @@ export class ObjectQLPlugin implements Plugin { } catch (e: any) { ctx.logger.debug('No external metadata service to sync from'); } - + + // ── Runtime-authored hook bind (#2588) ─────────────────────────────── + // Hooks authored in the Studio live as `sys_metadata` rows, which the + // metadata service's loadMany() above does NOT surface on env-scoped + // kernels (no DatabaseLoader there) — so the boot bind never sees them + // and their bodies never run, even after a restart. Re-bind from the + // rows themselves: + // • at `kernel:ready` — cold-boot coverage, once every plugin has + // registered its packages (so the artifact filter can classify); + // • on `metadata:reloaded` — publish-while-running coverage (the + // runtime dispatcher announces after publishPackageDrafts, #2576), + // mirroring service-automation's flow re-sync. + // Idempotent: the bind fully replaces the 'metadata-service' package + // set, so edited hooks re-bind and deleted hooks tear down. + ctx.hook('kernel:ready', async () => { + await this.resyncAuthoredHooks(ctx); + }); + ctx.hook('metadata:reloaded', async () => { + await this.resyncAuthoredHooks(ctx); + }); + // Discover features from Kernel Services if (ctx.getServices && this.ql) { const services = ctx.getServices(); @@ -911,6 +953,169 @@ export class ObjectQLPlugin implements Plugin { } } + /** + * True when a hook of this name is shipped by an installed CODE package — + * i.e. the SchemaRegistry holds a composite (`:`) artifact + * entry for it (registered by `registerApp` / the artifact loader). Those + * hooks are bound by AppPlugin under `app:` with an explicit + * bodyRunner + functions map, so every OTHER bind path must skip them or + * they execute twice per event. + * + * Runtime-authored hooks — including ones published INTO a runtime-created + * package (their sys_metadata row carries a `package_id`) — have no + * artifact entry and are NOT matched. `getArtifactItem` is immune to + * plain-key overlay shadows, so an authored customization of a packaged + * hook classifies as artifact-shipped (the packaged version stays the one + * that runs — same artifact-wins rule as ADR-0010 lock resolution). + */ + private isArtifactShippedHook(name: unknown): boolean { + if (typeof name !== 'string' || name.length === 0) return false; + const registry: any = this.ql?.registry; + if (!registry || typeof registry.getArtifactItem !== 'function') return false; + return registry.getArtifactItem('hook', name) !== undefined; + } + + /** + * Read the ACTIVE runtime-authored hook rows from `sys_metadata`. + * + * Reads the table directly (like `protocol.getMetaItems` does) instead of + * going through the metadata service, because (a) env-scoped kernels have + * no DatabaseLoader so the service never surfaces these rows, and (b) rows + * published from a Studio session are org-scoped — engine hooks fire + * process-wide, so we take active rows across ALL organizations rather + * than one org's overlay view. + * + * Returns `null` when the read failed (e.g. no sys_metadata table on this + * kernel) — callers must treat that as "couldn't read", NOT "zero hooks", + * so a failed read never tears down live bindings. + */ + private async readAuthoredHookRows(ctx: PluginContext): Promise { + if (!this.ql) return null; + try { + // No environment filter: per ADR-0005 (revised 2026-05) each + // environment has its own physical DB, so this kernel's sys_metadata + // only ever holds its own rows (saveMetaItem no longer stamps + // environment_id). Rows across ALL organizations are taken — engine + // hooks fire process-wide, matching flow-trigger semantics. + let rows: any[] = (await this.ql.find('sys_metadata', { + where: { type: 'hook', state: 'active' }, + })) ?? []; + if (rows.length === 0) { + // Legacy plural rows — mirrors getMetaItems' singular/plural fallback. + rows = (await this.ql.find('sys_metadata', { + where: { type: 'hooks', state: 'active' }, + })) ?? []; + } + const hooks: any[] = []; + for (const row of rows) { + try { + const data = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata; + if (!data || typeof data !== 'object' || typeof data.name !== 'string') continue; + // Surface the persisted package binding (parity with getMetaItems) + // so provenance-aware consumers of the bound hook can read it. + const recPkg = row.package_id ?? undefined; + if (recPkg && data._packageId === undefined) data._packageId = recPkg; + hooks.push(data); + } catch { + // Malformed row — skip it, keep the rest. + } + } + return hooks; + } catch (e: any) { + ctx.logger.debug('[ObjectQLPlugin] authored-hook read from sys_metadata failed', { + error: e?.message, + }); + return null; + } + } + + /** + * Serializes {@link resyncAuthoredHooks} runs. Mutation events, publishes, + * and the boot sync can overlap; two interleaved read→bind sequences could + * otherwise finish out of order and leave the OLDER snapshot bound. + */ + private authoredHookResyncChain: Promise = Promise.resolve(); + + /** + * (Re-)bind runtime-authored hooks into the execution pipeline (#2588). + * + * Serialized: overlapping calls queue behind each other so the last + * completed bind always reflects the newest read. + * + * Sources, unioned by hook name (fresher DB row wins): + * 1. `metadataService.loadMany('hook')` — the same view the boot bind in + * {@link loadMetadataFromService} consumed (covers FS-scanned hooks + * and, on platform kernels, the DatabaseLoader), re-read so the set + * reflects post-boot changes; + * 2. active `sys_metadata` hook rows read directly — the ONLY source + * that surfaces Studio-authored hooks on env-scoped kernels. + * + * Package-artifact hooks are filtered out (bound by AppPlugin — see + * {@link isArtifactShippedHook}). The result replaces the whole + * `'metadata-service'` package set (`bindHooksToEngine` unregisters it + * first), so this is idempotent: edited hooks re-bind with their new + * definition and hooks whose rows were deleted tear down. Bodies execute + * through the engine's default bodyRunner installed at boot by the + * runtime's AppPlugin; when that runner is absent (e.g. + * `OS_DISABLE_AUTHORED_HOOKS=1`) the binder skips bodies with a warning, + * exactly as before. + * + * Best-effort: when BOTH sources are unavailable the resync is a no-op — + * it never tears down live hooks on a failed read. + */ + private resyncAuthoredHooks(ctx: PluginContext): Promise { + const run = this.authoredHookResyncChain.then(() => this.resyncAuthoredHooksNow(ctx)); + // The chain itself must never hold a rejection (it would poison every + // later resync); callers still see the failure through `run`. + this.authoredHookResyncChain = run.catch(() => undefined); + return run; + } + + private async resyncAuthoredHooksNow(ctx: PluginContext): Promise { + const ql: any = this.ql; + if (!ql || typeof ql.bindHooks !== 'function') return; + + let serviceHooks: any[] | null = null; + try { + const metadataService = ctx.getService('metadata') as any; + if (metadataService && typeof metadataService.loadMany === 'function') { + serviceHooks = (await metadataService.loadMany('hook')) ?? []; + } + } catch { + serviceHooks = null; // no metadata service on this kernel + } + + const authoredHooks = await this.readAuthoredHookRows(ctx); + if (serviceHooks === null && authoredHooks === null) return; // nothing readable — keep current bindings + + const byName = new Map(); + for (const h of serviceHooks ?? []) { + if (h && typeof h.name === 'string') byName.set(h.name, h); + } + for (const h of authoredHooks ?? []) { + if (h && typeof h.name === 'string') byName.set(h.name, h); + } + + const bindable = Array.from(byName.values()).filter( + (h) => !this.isArtifactShippedHook(h.name), + ); + if (bindable.length === 0) { + // bindHooksToEngine early-returns on an empty list BEFORE its + // unregister step, so deleting the last authored hook would leave the + // stale binding firing forever. Tear the package set down explicitly. + if (typeof ql.unregisterHooksByPackage === 'function') { + ql.unregisterHooksByPackage('metadata-service'); + } + } else { + ql.bindHooks(bindable, { packageId: 'metadata-service' }); + } + ctx.logger.info('[ObjectQLPlugin] re-synced runtime-authored hooks', { + bound: bindable.length, + authoredRows: authoredHooks?.length ?? 0, + artifactSkipped: byName.size - bindable.length, + }); + } + /** * Load metadata from external metadata service into ObjectQL registry * This enables ObjectQL to use file-based or remote metadata @@ -974,8 +1179,18 @@ export class ObjectQLPlugin implements Plugin { // canonical binder so declarative semantics (condition, // retry, timeout, async, onError, priority, packageId) // are honoured uniformly with the AppPlugin path. + // + // Package-artifact hooks are EXCLUDED: AppPlugin already + // binds the same hooks (from the bundle) under + // `app:` WITH an explicit bodyRunner + functions + // map. Binding them here too used to be harmless only + // because this path had no bodyRunner (bodies were + // silently skipped); now that the engine carries a + // default runner (#2588) a second bind would execute + // every artifact hook twice per event. if (type === 'hook' && this.ql && typeof (this.ql as any).bindHooks === 'function') { - (this.ql as any).bindHooks(items, { + const bindable = items.filter((h: any) => !this.isArtifactShippedHook(h?.name)); + (this.ql as any).bindHooks(bindable, { packageId: 'metadata-service', }); } diff --git a/packages/runtime/src/app-plugin.test.ts b/packages/runtime/src/app-plugin.test.ts index dd61a7b2eb..f185897515 100644 --- a/packages/runtime/src/app-plugin.test.ts +++ b/packages/runtime/src/app-plugin.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AppPlugin } from './app-plugin'; import { PluginContext } from '@objectstack/core'; @@ -315,4 +315,82 @@ describe('AppPlugin', () => { expect(plugin.name).toBe('plugin.app.name-only'); }); }); + + // ═══════════════════════════════════════════════════════════════ + // Default hook body runner (#2588) + // ═══════════════════════════════════════════════════════════════ + + describe('default hook body runner install', () => { + let mockQL: any; + let mockManifest: any; + + beforeEach(() => { + delete process.env.OS_DISABLE_AUTHORED_HOOKS; + mockQL = { setDefaultBodyRunner: vi.fn(), registry: {} }; + mockManifest = { register: vi.fn() }; + vi.mocked(mockContext.getService).mockImplementation(((name: string) => { + if (name === 'objectql') return mockQL; + if (name === 'manifest') return mockManifest; + throw new Error(`service '${name}' not found`); + }) as any); + }); + + afterEach(() => { + delete process.env.OS_DISABLE_AUTHORED_HOOKS; + }); + + it('installs the engine default body runner during init', async () => { + const plugin = new AppPlugin({ id: 'com.test.app' }); + await plugin.init(mockContext); + expect(mockQL.setDefaultBodyRunner).toHaveBeenCalledTimes(1); + const runner = mockQL.setDefaultBodyRunner.mock.calls[0][0]; + expect(typeof runner).toBe('function'); + // The factory-produced runner yields a handler for body-hooks… + const handler = runner({ + name: 'h', + object: 'a', + events: ['beforeInsert'], + body: { language: 'js', source: 'ctx.input.x = 1;' }, + }); + expect(typeof handler).toBe('function'); + // …and nothing for hooks without a body. + expect(runner({ name: 'nobody', object: 'a', events: ['beforeInsert'] })).toBeUndefined(); + }); + + it('installs the runner even for empty environments', async () => { + // An empty env is exactly where a user authors their first hook. + const plugin = new AppPlugin( + { manifest: { plugins: [] }, functions: [] } as any, + { environmentId: 'env-a', organizationId: 'org-a' } as any, + ); + await plugin.init(mockContext); + expect(mockQL.setDefaultBodyRunner).toHaveBeenCalledTimes(1); + // Empty env still short-circuits the rest of init. + expect(mockManifest.register).not.toHaveBeenCalled(); + }); + + it('does not replace an already-installed default runner', async () => { + mockQL._defaultBodyRunner = () => undefined; + const plugin = new AppPlugin({ id: 'com.test.app' }); + await plugin.init(mockContext); + expect(mockQL.setDefaultBodyRunner).not.toHaveBeenCalled(); + }); + + it('honours the OS_DISABLE_AUTHORED_HOOKS=1 opt-out', async () => { + process.env.OS_DISABLE_AUTHORED_HOOKS = '1'; + const plugin = new AppPlugin({ id: 'com.test.app' }); + await plugin.init(mockContext); + expect(mockQL.setDefaultBodyRunner).not.toHaveBeenCalled(); + }); + + it('init survives a kernel without an objectql service', async () => { + vi.mocked(mockContext.getService).mockImplementation(((name: string) => { + if (name === 'manifest') return mockManifest; + throw new Error(`service '${name}' not found`); + }) as any); + const plugin = new AppPlugin({ id: 'com.test.app' }); + await expect(plugin.init(mockContext)).resolves.toBeUndefined(); + expect(mockManifest.register).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 71894f42fc..34ddbf36dd 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -113,6 +113,11 @@ export class AppPlugin implements Plugin { } init = async (ctx: PluginContext) => { + // Install the engine-wide default hook body runner FIRST — even for + // empty envs (an empty env is exactly where a user will author their + // first Studio hook). Runs in init (Phase 1) so it is in place before + // ObjectQLPlugin.start binds metadata-service hooks in Phase 2 (#2588). + this.installDefaultHookBodyRunner(ctx); if (this.empty) { ctx.logger.debug('[AppPlugin] empty env — no app payload, skipping init', { pluginName: this.name, @@ -165,6 +170,47 @@ export class AppPlugin implements Plugin { ctx.getService<{ register(m: any): void }>('manifest').register(servicePayload); } + /** + * Install the engine's DEFAULT hook body runner (`engine.setDefaultBodyRunner`). + * + * Hooks authored at runtime (Studio → `protocol.saveMetaItem` → publish) + * bind through paths that pass no explicit `bodyRunner` — notably + * ObjectQLPlugin's metadata-service bind — so without this default their + * L1/L2 `body` is silently dropped by `bindHooksToEngine` and the hook + * never runs (#2588). The runtime owns the sandbox bridge (objectql stays + * sandbox-free), so this is the boot point that wires it: same + * QuickJS-sandboxed, capability-gated runner the `defineStack({ hooks })` + * bind already uses. + * + * `OS_DISABLE_AUTHORED_HOOKS=1` opts out for deployments that want + * runtime-authored (DB-stored, non-code-reviewed) hook bodies to stay + * inert; code-shipped hooks are unaffected (AppPlugin passes its own + * runner explicitly). + * + * Idempotent: the first AppPlugin to run installs it; the runner is + * bundle-agnostic (it only closes over the engine + logger). + */ + private installDefaultHookBodyRunner(ctx: PluginContext): void { + if (process.env.OS_DISABLE_AUTHORED_HOOKS === '1') { + ctx.logger.info('[AppPlugin] OS_DISABLE_AUTHORED_HOOKS=1 — runtime-authored hook bodies will not execute'); + return; + } + let ql: any; + try { + ql = ctx.getService('objectql'); + } catch { + return; // no engine on this kernel — nothing to wire + } + if (!ql || typeof ql.setDefaultBodyRunner !== 'function') return; + if (ql._defaultBodyRunner) return; // another AppPlugin already installed one + ql.setDefaultBodyRunner(hookBodyRunnerFactory(new QuickJSScriptRunner(), { + ql, + logger: ctx.logger, + appId: 'runtime-authored', + })); + ctx.logger.info('[AppPlugin] Installed default hook body runner (runtime-authored hooks can execute)'); + } + start = async (ctx: PluginContext) => { if (this.empty) { ctx.logger.debug('[AppPlugin] empty env — no app payload, skipping start', {