diff --git a/.changeset/studio-metadata-runtime-wiring.md b/.changeset/studio-metadata-runtime-wiring.md new file mode 100644 index 0000000000..f4e6974133 --- /dev/null +++ b/.changeset/studio-metadata-runtime-wiring.md @@ -0,0 +1,48 @@ +--- +'@objectstack/objectql': patch +'@objectstack/runtime': patch +'@objectstack/core': patch +'@objectstack/service-i18n': patch +'@objectstack/plugin-sharing': patch +--- + +Wire three more Studio-authored metadata surfaces at runtime (#2605 — the +"declared but never wired" family, following the #2596 hooks template). + +**Authored actions now execute (#2605 item 1).** `engine.executeAction`'s map +was only ever populated from the app bundle at boot, so a published `action` +row (standalone or embedded in an authored object's `actions[]`) was stored +and listed but never executable — before OR after a restart. Now: + +- `AppPlugin` installs a QuickJS-sandboxed default action runner at boot + (`engine.setDefaultActionRunner`), the action-path twin of the #2596 hook + body runner. Opt out with `OS_DISABLE_AUTHORED_ACTIONS=1`. +- `ObjectQLPlugin` re-registers runtime-authored actions from their + `sys_metadata` rows under `packageId: 'metadata-service'` at + `kernel:ready`, on `metadata:reloaded`, and on `action`/`object` protocol + mutations — saves, publishes, edits, and deletes take effect live. + Package-artifact actions are excluded (AppPlugin owns those; re-registering + would clobber their handlers). + +**Authored translations reach the i18n runtime (#2591).** `translation` +metadata items (single-locale `AppTranslationBundle` payloads; locale from +`_meta.locale`, a top-level `locale`, or a BCP-47-shaped item name) now load +into the i18n service as a separate authored layer that overlays static +bundles. Both adapters carry the layer — service-i18n's `FileI18nAdapter` +AND the kernel's in-memory fallback (`createMemoryI18n`), which is what dev +and standalone stacks actually run. The shared sync +(`wireAuthoredTranslationSync`, exported from `@objectstack/core`, wired by +the runtime's AppPlugin and by I18nServicePlugin with single-owner +semantics) runs at `kernel:ready`, on `metadata:reloaded`, and on +`translation` protocol mutations, with clear-then-reload semantics so +deleted items/keys stop resolving instead of lingering in the deep-merged +map. + +**Sharing rules created at runtime bind without a restart (#2592).** +`bindRuleHooks` was boot-only, so the first rule authored at runtime for an +object with no boot-time rule silently never evaluated (rule authoring is a +data insert — `metadata:reloaded` never fires). The sharing plugin now binds +afterInsert/afterUpdate/afterDelete triggers on `sys_sharing_rule` that +unbind + re-bind the rule-hook package from a fresh `listRules()`, serialized +so overlapping writes can't leave a stale snapshot bound, and fail-safe so a +rebind failure never fails the rule write. diff --git a/packages/core/src/fallbacks/authored-translation-sync.ts b/packages/core/src/fallbacks/authored-translation-sync.ts new file mode 100644 index 0000000000..c88737c428 --- /dev/null +++ b/packages/core/src/fallbacks/authored-translation-sync.ts @@ -0,0 +1,189 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Runtime-authored translation sync (#2591). + * + * Translations authored in the Studio persist as `translation` sys_metadata + * rows (`allowRuntimeCreate: true`), but historically only STATIC bundles + * (app `bundle.translations`, plugin `translations/`) were ever loaded into + * the i18n runtime — a published translation was a dead-end on publish AND + * after a restart. + * + * This module is the single shared implementation of the fix, wired by both + * hosts an `i18n` service can come from: + * - the runtime's AppPlugin (covers the kernel's in-memory fallback — the + * dev/standalone reality) and + * - @objectstack/service-i18n's I18nServicePlugin (the file-based adapter). + * Both adapters expose `replaceAuthoredTranslations(byLocale)`; the sync + * computes the full authored layer from the rows and REPLACES it wholesale + * (clear-then-reload), so deleted items/keys stop resolving. + * + * Item payload is a single-locale `AppTranslationBundle` (the `translation` + * type's canonical schema). Locale resolution, in order: `_meta.locale`, a + * top-level `locale` string, then the item name when it looks like a BCP-47 + * tag (an item named `zh-CN` translates that locale). Items with no + * resolvable locale are skipped with a warning. Multiple items on one locale + * deep-merge in name order (deterministic). + * + * Trigger points (wired by {@link wireAuthoredTranslationSync}): + * • `kernel:ready` — cold-boot coverage; + * • `metadata:reloaded` — publish-while-running coverage (#2576); + * • protocol `onMetadataMutation` — direct-active saves / deletes of + * `translation` rows that don't go through a package publish (#2588's + * mutation stream). + * + * Rows are read straight from `sys_metadata` through the engine — the same + * discipline as the authored-hook re-sync (#2588): env-scoped kernels + * surface authored rows nowhere else, and the i18n map is process-wide so + * rows are taken across all organizations. Best-effort: a failed read keeps + * the currently applied authored layer. + */ + +import { deepMerge } from './memory-i18n.js'; + +type AnyRecord = Record; + +interface MinimalCtx { + logger: { debug?: (...a: any[]) => void; info?: (...a: any[]) => void; warn?: (...a: any[]) => void }; + getService(name: string): any; + hook?(name: string, fn: () => Promise | void): void; +} + +/** + * Ownership marker: several plugins may wire the sync against the same + * kernel (AppPlugin AND I18nServicePlugin on a production server). The first + * wirer to touch a given i18n service instance claims it; later wirers + * no-op, so the layer is computed once per change instead of N times. + */ +const OWNER_PROP = '__authoredTranslationSyncOwner'; + +// Deliberately narrow (language + optional script/region only): item names +// are snake_case, so a permissive multi-segment pattern would classify names +// like `my_custom_strings` as locales. +const LOCALE_LIKE = /^[a-z]{2,3}([_-]([A-Za-z]{4}|[A-Za-z]{2}|[0-9]{3}))?$/; + +/** + * Read ACTIVE `translation` metadata rows and compute the authored layer, + * keyed by locale. Returns `null` when the read failed (callers must keep + * the current layer, never tear it down on an error). + */ +export async function readAuthoredTranslationLayer( + engine: { find(object: string, opts?: AnyRecord): Promise }, + logger?: MinimalCtx['logger'], +): Promise> | null> { + let rows: any[]; + try { + rows = (await engine.find('sys_metadata', { + where: { type: 'translation', state: 'active' }, + })) ?? []; + if (rows.length === 0) { + // Legacy plural rows — mirrors the protocol's singular/plural fallback. + rows = (await engine.find('sys_metadata', { + where: { type: 'translations', state: 'active' }, + })) ?? []; + } + } catch (err: any) { + logger?.debug?.('[i18n] authored-translation read failed — keeping current layer', { + error: err?.message, + }); + return null; + } + + const byLocale: Record> = {}; + const sorted = [...rows].sort((a, b) => String(a?.name ?? '').localeCompare(String(b?.name ?? ''))); + for (const row of sorted) { + let data: any; + try { + data = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata; + } catch { + continue; // malformed row — skip it, keep the rest + } + if (!data || typeof data !== 'object') continue; + const locale: string | undefined = + (typeof data?._meta?.locale === 'string' && data._meta.locale) + || (typeof data?.locale === 'string' && data.locale) + || (typeof row?.name === 'string' && LOCALE_LIKE.test(row.name) ? row.name : undefined) + || undefined; + if (!locale) { + logger?.warn?.( + `[i18n] authored translation '${row?.name}' has no resolvable locale ` + + '(set _meta.locale, or name the item after its BCP-47 locale) — skipped', + ); + continue; + } + // Strip authoring bookkeeping; everything else is translation data. + const { name: _n, locale: _l, _packageId: _p, _provenance: _pr, _lock: _lk, ...payload } = data; + byLocale[locale] = deepMerge(byLocale[locale] ?? {}, payload as Record); + } + return byLocale; +} + +/** + * Wire the authored-translation sync into a plugin context: registers the + * `kernel:ready` / `metadata:reloaded` hooks and (at kernel:ready) the + * protocol mutation subscription. Idempotent per i18n service instance via + * the ownership marker. Safe to call on kernels with no engine, no protocol, + * or an i18n service without `replaceAuthoredTranslations` — every path + * degrades to a no-op. + */ +export function wireAuthoredTranslationSync(ctx: MinimalCtx): void { + if (typeof ctx.hook !== 'function') return; + + const token = Symbol('authored-translation-sync'); + const resolveOwnedI18n = (): any | null => { + let i18n: any; + try { i18n = ctx.getService('i18n'); } catch { return null; } + if (!i18n || typeof i18n.replaceAuthoredTranslations !== 'function') return null; + const current = i18n[OWNER_PROP]; + if (current === undefined) { + i18n[OWNER_PROP] = token; + return i18n; + } + return current === token ? i18n : null; // another wirer owns this instance + }; + + // Serialized: overlapping publishes must not finish out of order and leave + // the older authored snapshot applied. + let chain: Promise = Promise.resolve(); + const sync = (): Promise => { + const run = chain.then(async () => { + const i18n = resolveOwnedI18n(); + if (!i18n) return; + let engine: any; + try { engine = ctx.getService('objectql'); } catch { return; } + if (!engine || typeof engine.find !== 'function') return; + const layer = await readAuthoredTranslationLayer(engine, ctx.logger); + if (layer === null) return; // failed read — keep current layer + i18n.replaceAuthoredTranslations(layer); + ctx.logger.info?.('[i18n] synced runtime-authored translations', { + locales: Object.keys(layer), + }); + }); + chain = run.catch(() => undefined); + return run; + }; + + ctx.hook('kernel:ready', async () => { + // Subscribe to translation mutations through the protocol choke point + // (#2588). Only the owning wirer subscribes. + if (resolveOwnedI18n()) { + let protocol: any = null; + try { protocol = ctx.getService('protocol'); } catch { /* no protocol on this kernel */ } + if (protocol && typeof protocol.onMetadataMutation === 'function') { + protocol.onMetadataMutation((evt: { type: string; name: string; state: string }) => { + if (evt?.type !== 'translation' || evt.state === 'draft') return; + void sync().catch((err: any) => { + ctx.logger.warn?.('[i18n] authored-translation re-sync after mutation failed', { + item: evt.name, + error: err?.message, + }); + }); + }); + } + } + await sync(); + }); + ctx.hook('metadata:reloaded', async () => { + await sync(); + }); +} diff --git a/packages/core/src/fallbacks/index.ts b/packages/core/src/fallbacks/index.ts index 5fa6f794a9..a3f7542fc1 100644 --- a/packages/core/src/fallbacks/index.ts +++ b/packages/core/src/fallbacks/index.ts @@ -9,8 +9,12 @@ import { createMemoryMetadata } from './memory-metadata.js'; export { createMemoryCache } from './memory-cache.js'; export { createMemoryQueue } from './memory-queue.js'; export { createMemoryJob } from './memory-job.js'; -export { createMemoryI18n, resolveLocale } from './memory-i18n.js'; +export { createMemoryI18n, resolveLocale, deepMerge } from './memory-i18n.js'; export { createMemoryMetadata } from './memory-metadata.js'; +export { + wireAuthoredTranslationSync, + readAuthoredTranslationLayer, +} from './authored-translation-sync.js'; /** * Map of core-criticality service names to their in-memory fallback factories. diff --git a/packages/core/src/fallbacks/memory-i18n.ts b/packages/core/src/fallbacks/memory-i18n.ts index df9ed5106d..7214becf9f 100644 --- a/packages/core/src/fallbacks/memory-i18n.ts +++ b/packages/core/src/fallbacks/memory-i18n.ts @@ -5,8 +5,9 @@ * rather than replaced, so multiple plugins can each contribute their own * slice of a locale's translations (e.g. `{objects: {account: ...}}` and * `{objects: {task: ...}}`) without clobbering one another. + * Exported for the authored-translation sync (#2591). */ -function deepMerge( +export function deepMerge( target: Record, source: Record, ): Record { @@ -77,6 +78,11 @@ export function resolveLocale(requestedLocale: string, availableLocales: string[ */ export function createMemoryI18n() { const translations = new Map>(); + // Runtime-AUTHORED overlay (#2591): translations published as `translation` + // metadata. Kept separate from the static map so a re-sync can REPLACE the + // whole authored layer (clear-then-reload — deleted keys must not linger), + // while authored values win over static bundle values on read. + const authored = new Map>(); let defaultLocale = 'en'; /** @@ -92,16 +98,26 @@ export function createMemoryI18n() { return typeof current === 'string' ? current : undefined; } + /** Merged (static ⊕ authored) view of a single, exact locale key. */ + function mergedLocale(locale: string): Record | undefined { + const stat = translations.get(locale); + const auth = authored.get(locale); + if (stat && auth) return deepMerge(stat, auth); + return auth ?? stat; + } + /** * Find translation data for a locale, with fallback resolution. */ function resolveTranslations(locale: string): Record | undefined { // Exact match - if (translations.has(locale)) return translations.get(locale); + const exact = mergedLocale(locale); + if (exact) return exact; // Locale fallback (zh → zh-CN, en-us → en-US, etc.) - const resolved = resolveLocale(locale, [...translations.keys()]); - if (resolved) return translations.get(resolved); + const allLocales = [...new Set([...translations.keys(), ...authored.keys()])]; + const resolved = resolveLocale(locale, allLocales); + if (resolved) return mergedLocale(resolved); return undefined; } @@ -110,7 +126,7 @@ export function createMemoryI18n() { _fallback: true, _serviceName: 'i18n', t(key: string, locale: string, params?: Record): string { - const data = resolveTranslations(locale) ?? translations.get(defaultLocale); + const data = resolveTranslations(locale) ?? mergedLocale(defaultLocale); const value = data ? resolveKey(data, key) : undefined; if (value == null) return key; if (!params) return value; @@ -131,8 +147,22 @@ export function createMemoryI18n() { } }, + /** + * Replace the ENTIRE runtime-authored translation layer (#2591). Called + * by the authored-translation sync with the full current set of active + * `translation` metadata items keyed by locale. Wholesale replacement — + * not a merge — so deleted items/keys stop resolving on the next sync. + */ + replaceAuthoredTranslations(byLocale: Record>): void { + authored.clear(); + for (const [locale, data] of Object.entries(byLocale ?? {})) { + if (!data || typeof data !== 'object') continue; + authored.set(locale, { ...data }); + } + }, + getLocales(): string[] { - return [...translations.keys()]; + return [...new Set([...translations.keys(), ...authored.keys()])]; }, getDefaultLocale(): string { diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index fa247740c0..ab0ba62417 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -538,6 +538,20 @@ export class ObjectQL implements IDataEngine { (this as any)._defaultBodyRunner = runner; } + /** + * Install a default ACTION body-runner factory: `(actionDef) => handler | + * undefined`. The runtime layer sets this once per engine (same boot point + * as {@link setDefaultBodyRunner}) so runtime-authored `action` metadata — + * which registers through paths that have no sandbox access of their own, + * notably ObjectQLPlugin's metadata-service re-sync — can turn a declarative + * `body` into an executable `registerAction` handler. The factory returns + * `undefined` for actions it cannot run (no `body`, invalid shape), which + * callers must treat as "skip", not an error. + */ + setDefaultActionRunner(runner: (actionDef: any) => ((ctx: any) => Promise) | undefined): void { + (this as any)._defaultActionRunner = runner; + } + /** * Toggle strict hook-binding mode for this engine. When enabled, every * subsequent `bindHooks` call rejects on the first unresolved hook diff --git a/packages/objectql/src/plugin-authored-actions.test.ts b/packages/objectql/src/plugin-authored-actions.test.ts new file mode 100644 index 0000000000..8cdfaf6b2a --- /dev/null +++ b/packages/objectql/src/plugin-authored-actions.test.ts @@ -0,0 +1,317 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Runtime-authored action re-sync (#2605 item 1). + * + * Actions authored in the Studio persist as `action` sys_metadata rows (or + * embedded in authored `object` rows' `actions[]`), but `engine.executeAction` + * only ever dispatched handlers registered from the app bundle at boot — a + * published action was stored + listed but never executable, before or after + * a restart. `ObjectQLPlugin.resyncAuthoredActions` registers them from the + * rows themselves at `kernel:ready`, on `metadata:reloaded`, and on protocol + * metadata mutations, through the engine's default action runner (installed + * at boot by the runtime's AppPlugin). These tests exercise the re-sync + * against a mocked engine — 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 { + registerAction: vi.fn(), + removeActionsByPackage: vi.fn(), + find: vi.fn(async () => []), + registry: { + getArtifactItem: vi.fn(() => undefined), + }, + // Default action runner as the runtime installs it: body → handler. + _defaultActionRunner: vi.fn((action: AnyRecord) => + action?.body ? async () => `ran:${action.name}` : 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 actionRow = (name: string, extra: AnyRecord = {}, payload: AnyRecord = {}) => ({ + type: 'action', + name, + state: 'active', + metadata: JSON.stringify({ + name, + label: name, + objectName: 'showcase_task', + type: 'script', + body: { language: 'js', source: 'return 42;' }, + ...payload, + }), + ...extra, +}); + +describe('ObjectQLPlugin.resyncAuthoredActions (#2605)', () => { + let ql: AnyRecord; + + beforeEach(() => { + ql = makeQlMock(); + }); + + it('registers active sys_metadata action rows under packageId metadata-service', async () => { + ql.find.mockImplementation(async (_obj: string, q: AnyRecord) => + q?.where?.type === 'action' ? [actionRow('issue_license')] : []); + const plugin = makePlugin(ql); + + await (plugin as any).resyncAuthoredActions(makeCtx()); + + expect(ql.removeActionsByPackage).toHaveBeenCalledWith('metadata-service'); + expect(ql.registerAction).toHaveBeenCalledTimes(1); + const [objectKey, name, handler, packageId] = ql.registerAction.mock.calls[0]; + expect(objectKey).toBe('showcase_task'); + expect(name).toBe('issue_license'); + expect(packageId).toBe('metadata-service'); + await expect(handler({})).resolves.toBe('ran:issue_license'); + }); + + it('registers object-less actions under the global key', async () => { + ql.find.mockImplementation(async (_obj: string, q: AnyRecord) => + q?.where?.type === 'action' + ? [actionRow('export_all', {}, { objectName: undefined })] + : []); + const plugin = makePlugin(ql); + + await (plugin as any).resyncAuthoredActions(makeCtx()); + + expect(ql.registerAction.mock.calls[0][0]).toBe('global'); + }); + + it('registers actions embedded in authored object rows, attaching the object name', async () => { + ql.find.mockImplementation(async (_obj: string, q: AnyRecord) => { + if (q?.where?.type === 'object') { + return [{ + type: 'object', + name: 'ops_ticket', + state: 'active', + metadata: JSON.stringify({ + name: 'ops_ticket', + label: 'Ticket', + actions: [ + { name: 'escalate', type: 'script', body: { language: 'js', source: 'return 1;' } }, + { name: 'open_url', type: 'url', target: 'https://example.com' }, // bodyless → skipped + ], + }), + }]; + } + return []; + }); + const plugin = makePlugin(ql); + + await (plugin as any).resyncAuthoredActions(makeCtx()); + + expect(ql.registerAction).toHaveBeenCalledTimes(1); + const [objectKey, name] = ql.registerAction.mock.calls[0]; + expect(objectKey).toBe('ops_ticket'); + expect(name).toBe('escalate'); + }); + + it('filters out artifact-shipped actions (registered by AppPlugin) to prevent clobbering', async () => { + ql.registry.getArtifactItem.mockImplementation((type: string, name: string) => + type === 'action' && name === 'convert_lead' ? { name } : undefined); + ql.find.mockImplementation(async (_obj: string, q: AnyRecord) => + q?.where?.type === 'action' + ? [actionRow('authored_action'), actionRow('convert_lead')] + : []); + const plugin = makePlugin(ql); + + await (plugin as any).resyncAuthoredActions(makeCtx()); + + expect(ql.registerAction).toHaveBeenCalledTimes(1); + expect(ql.registerAction.mock.calls[0][1]).toBe('authored_action'); + }); + + it('filters actions embedded in a PACKAGED object artifact (object-editor overlay of a bundle object)', async () => { + ql.registry.getArtifactItem.mockImplementation((type: string, name: string) => + type === 'object' && name === 'crm_lead' + ? { name, _packageId: 'com.example.crm', actions: [{ name: 'convert' }] } + : undefined); + // The authored overlay of crm_lead carries a copy of the packaged action + // plus a genuinely new one — only the new one may register. + ql.find.mockImplementation(async (_obj: string, q: AnyRecord) => { + if (q?.where?.type === 'object') { + return [{ + type: 'object', name: 'crm_lead', state: 'active', + metadata: JSON.stringify({ + name: 'crm_lead', + actions: [ + { name: 'convert', type: 'script', body: { language: 'js', source: 'x' } }, + { name: 'authored_extra', type: 'script', body: { language: 'js', source: 'y' } }, + ], + }), + }]; + } + return []; + }); + const plugin = makePlugin(ql); + + await (plugin as any).resyncAuthoredActions(makeCtx()); + + expect(ql.registerAction).toHaveBeenCalledTimes(1); + expect(ql.registerAction.mock.calls[0][1]).toBe('authored_extra'); + }); + + it('unions metadata-service actions with DB rows; the DB row wins by object:name', async () => { + const metadataService = { + loadMany: vi.fn(async (type: string) => + type === 'action' + ? [ + { name: 'fs_action', objectName: 'showcase_task', body: { language: 'js', source: 'a' } }, + { name: 'edited_action', objectName: 'showcase_task', body: { language: 'js', source: 'stale' } }, + ] + : []), + }; + ql.find.mockImplementation(async (_obj: string, q: AnyRecord) => + q?.where?.type === 'action' ? [actionRow('edited_action')] : []); + const plugin = makePlugin(ql); + + await (plugin as any).resyncAuthoredActions(makeCtx({ metadata: metadataService })); + + const registeredNames = ql.registerAction.mock.calls.map((c: any[]) => c[1]).sort(); + expect(registeredNames).toEqual(['edited_action', 'fs_action']); + const runnerInputs = ql._defaultActionRunner.mock.calls.map((c: any[]) => c[0]); + const edited = runnerInputs.find((a: AnyRecord) => a.name === 'edited_action'); + expect(edited.body.source).toBe('return 42;'); // fresh DB body replaced the stale service copy + }); + + it('tears the package set down (and registers nothing) when the last authored action was deleted', async () => { + ql.find.mockResolvedValue([]); + const plugin = makePlugin(ql); + + await (plugin as any).resyncAuthoredActions(makeCtx()); + + expect(ql.removeActionsByPackage).toHaveBeenCalledWith('metadata-service'); + expect(ql.registerAction).not.toHaveBeenCalled(); + }); + + 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).resyncAuthoredActions(makeCtx()); // no metadata service either + + expect(ql.removeActionsByPackage).not.toHaveBeenCalled(); + expect(ql.registerAction).not.toHaveBeenCalled(); + }); + + it('skips bodyless actions without registering a handler', async () => { + ql.find.mockImplementation(async (_obj: string, q: AnyRecord) => + q?.where?.type === 'action' + ? [actionRow('flow_action', {}, { type: 'flow', target: 'my_flow', body: undefined })] + : []); + const plugin = makePlugin(ql); + + await (plugin as any).resyncAuthoredActions(makeCtx()); + + expect(ql.registerAction).not.toHaveBeenCalled(); + }); + + it('warns (and registers nothing) when no default action runner is installed', async () => { + ql._defaultActionRunner = undefined; + ql.find.mockImplementation(async (_obj: string, q: AnyRecord) => + q?.where?.type === 'action' ? [actionRow('orphan_action')] : []); + const plugin = makePlugin(ql); + const ctx = makeCtx(); + + await (plugin as any).resyncAuthoredActions(ctx); + + expect(ql.registerAction).not.toHaveBeenCalled(); + expect(ctx.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('no default action runner'), + expect.anything(), + ); + }); + + 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 === 'actions') return [actionRow('legacy_plural_action', { type: 'actions' })]; + return []; + }); + const plugin = makePlugin(ql); + + await (plugin as any).resyncAuthoredActions(makeCtx()); + + expect(ql.registerAction.mock.calls[0][1]).toBe('legacy_plural_action'); + }); + + it('skips malformed rows without dropping the rest', async () => { + ql.find.mockImplementation(async (_obj: string, q: AnyRecord) => + q?.where?.type === 'action' + ? [{ type: 'action', name: 'broken', state: 'active', metadata: '{not json' }, actionRow('good_action')] + : []); + const plugin = makePlugin(ql); + + await (plugin as any).resyncAuthoredActions(makeCtx()); + + expect(ql.registerAction).toHaveBeenCalledTimes(1); + expect(ql.registerAction.mock.calls[0][1]).toBe('good_action'); + }); +}); + +describe('ObjectQLPlugin protocol-mutation subscription (#2605)', () => { + it('re-syncs authored actions on action AND object mutations (skips drafts and hooks)', 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 resyncActions = vi + .spyOn(plugin as any, 'resyncAuthoredActions') + .mockResolvedValue(undefined); + const resyncHooks = vi + .spyOn(plugin as any, 'resyncAuthoredHooks') + .mockResolvedValue(undefined); + + (protocol as any).emitMetadataMutation({ type: 'action', name: 'a1', state: 'active' }); + expect(resyncActions).toHaveBeenCalledTimes(1); + + // Object rows embed actions[] — an object edit re-syncs actions too. + (protocol as any).emitMetadataMutation({ type: 'object', name: 'o1', state: 'active' }); + expect(resyncActions).toHaveBeenCalledTimes(2); + + // Drafts are not live — no re-sync. + (protocol as any).emitMetadataMutation({ type: 'action', name: 'a1', state: 'draft' }); + expect(resyncActions).toHaveBeenCalledTimes(2); + + // Hook mutations churn the hook bind, not the action set. + (protocol as any).emitMetadataMutation({ type: 'hook', name: 'h1', state: 'active' }); + expect(resyncActions).toHaveBeenCalledTimes(2); + expect(resyncHooks).toHaveBeenCalledTimes(1); + + // Deletes re-sync (teardown). + (protocol as any).emitMetadataMutation({ type: 'action', name: 'a1', state: 'deleted' }); + expect(resyncActions).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/objectql/src/plugin.integration.test.ts b/packages/objectql/src/plugin.integration.test.ts index ae65748a10..3bd1595091 100644 --- a/packages/objectql/src/plugin.integration.test.ts +++ b/packages/objectql/src/plugin.integration.test.ts @@ -980,8 +980,11 @@ 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 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'. + // sys_metadata. The only permitted boot reads are the runtime-authored + // re-syncs: hooks (#2588, type='hook') and actions (#2605 — + // type='action' plus type='object' rows scanned ONLY to extract their + // embedded `actions[]`). Neither registers objects — the registry + // assertion below pins that. const findCalls: Array<{ object: string; query?: any }> = []; await kernel.use({ name: 'mock-noopt-driver', @@ -996,7 +999,7 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => { const metaReads = findCalls.filter((c) => c.object === 'sys_metadata'); for (const read of metaReads) { - expect(read.query?.where?.type).toMatch(/^hooks?$/); + expect(read.query?.where?.type).toMatch(/^(hooks?|actions?|object)$/); } 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 7cc4768549..672e4fce7f 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -215,23 +215,35 @@ export class ObjectQLPlugin implements Plugin { ctx.registerService('protocol', protocolShim); ctx.logger.info('Protocol service registered'); - // ── Runtime-authored hook rebind on authoring (#2588) ──────────────── + // ── Runtime-authored hook/action rebind on authoring (#2588, #2605) ── // 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. + // builders). When a `hook` or `action` row lands (direct-active save, + // publish) or is deleted, re-sync the authored set so the change is live + // without a restart. Draft saves are skipped — drafts are not live by + // design. Fire-and-forget: a resync 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, + if (evt?.state === 'draft') return; + if (evt?.type === 'hook') { + void this.resyncAuthoredHooks(ctx).catch((e: any) => { + ctx.logger.warn('[ObjectQLPlugin] authored-hook rebind after mutation failed', { + hook: evt.name, + error: e?.message, + }); }); - }); + } else if (evt?.type === 'action' || evt?.type === 'object') { + // `object` rows carry embedded `actions[]`, so an object edit can + // add/remove an authored action too — re-sync on both. + void this.resyncAuthoredActions(ctx).catch((e: any) => { + ctx.logger.warn('[ObjectQLPlugin] authored-action rebind after mutation failed', { + item: evt.name, + error: e?.message, + }); + }); + } }, ); this.metadataUnsubscribes.push(unsubscribe); @@ -322,9 +334,11 @@ export class ObjectQLPlugin implements Plugin { // set, so edited hooks re-bind and deleted hooks tear down. ctx.hook('kernel:ready', async () => { await this.resyncAuthoredHooks(ctx); + await this.resyncAuthoredActions(ctx); }); ctx.hook('metadata:reloaded', async () => { await this.resyncAuthoredHooks(ctx); + await this.resyncAuthoredActions(ctx); }); // Discover features from Kernel Services @@ -1116,6 +1130,236 @@ export class ObjectQLPlugin implements Plugin { }); } + /** + * Resolve the engine object key an action registers under. Standalone + * `action` metadata declares `objectName` (spec `ActionSchema`); bundle + * collectors attach `object`; object-less actions register under the + * `'global'` wildcard key, matching AppPlugin's bundle registration. + */ + private actionObjectKey(action: any): string { + if (typeof action?.objectName === 'string' && action.objectName.length > 0) return action.objectName; + if (typeof action?.object === 'string' && action.object.length > 0) return action.object; + return 'global'; + } + + /** + * True when an action of this name is shipped by an installed CODE + * package — either as a standalone `action` artifact, or embedded in a + * packaged object's `actions[]` array (the common `defineStack` shape). + * Those handlers are registered by AppPlugin under `app:` with its + * own runner, and `engine.registerAction` REPLACES by `:` + * key — so re-registering here would clobber the packaged handler with a + * metadata copy. Artifact-wins, same rule as {@link isArtifactShippedHook}. + */ + private isArtifactShippedAction(action: any): boolean { + const name = action?.name; + if (typeof name !== 'string' || name.length === 0) return false; + const registry: any = this.ql?.registry; + if (!registry || typeof registry.getArtifactItem !== 'function') return false; + if (registry.getArtifactItem('action', name) !== undefined) return true; + const objectKey = this.actionObjectKey(action); + if (objectKey !== 'global') { + const artifactObject: any = registry.getArtifactItem('object', objectKey); + if (Array.isArray(artifactObject?.actions) + && artifactObject.actions.some((a: any) => a?.name === name)) { + return true; + } + } + return false; + } + + /** + * Read the ACTIVE runtime-authored action definitions from `sys_metadata`. + * + * Two authoring shapes both land here (and both are dead without this + * re-sync — #2605 item 1): + * 1. standalone `action` rows (the Studio's Action editor / PUT + * `/meta/action/:name`), plus legacy plural `actions` rows; + * 2. actions EMBEDDED in authored `object` rows' `actions[]` — the + * object-editor path. The object schema itself is read live, but the + * handler still needs registering. + * + * Same read discipline as {@link readAuthoredHookRows}: direct table read + * (env-scoped kernels surface authored rows nowhere else), all + * organizations (engine actions are process-wide), `null` on a failed + * read so callers never tear down live registrations on an error. + */ + private async readAuthoredActionRows(ctx: PluginContext): Promise { + if (!this.ql) return null; + const parseRow = (row: any): any | undefined => { + try { + const data = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata; + if (!data || typeof data !== 'object') return undefined; + const recPkg = row.package_id ?? undefined; + if (recPkg && data._packageId === undefined) data._packageId = recPkg; + return data; + } catch { + return undefined; // malformed row — skip it, keep the rest + } + }; + try { + let rows: any[] = (await this.ql.find('sys_metadata', { + where: { type: 'action', state: 'active' }, + })) ?? []; + if (rows.length === 0) { + rows = (await this.ql.find('sys_metadata', { + where: { type: 'actions', state: 'active' }, + })) ?? []; + } + const actions: any[] = []; + for (const row of rows) { + const data = parseRow(row); + if (data && typeof data.name === 'string') actions.push(data); + } + + // Embedded shape: authored object rows may carry their own actions. + const objectRows: any[] = (await this.ql.find('sys_metadata', { + where: { type: 'object', state: 'active' }, + })) ?? []; + for (const row of objectRows) { + const obj = parseRow(row); + if (!obj || typeof obj.name !== 'string' || !Array.isArray(obj.actions)) continue; + for (const action of obj.actions) { + if (!action || typeof action !== 'object' || typeof action.name !== 'string') continue; + const copy = { ...action }; + if (typeof copy.object !== 'string' && typeof copy.objectName !== 'string') { + copy.object = obj.name; + } + if (obj._packageId && copy._packageId === undefined) copy._packageId = obj._packageId; + actions.push(copy); + } + } + return actions; + } catch (e: any) { + ctx.logger.debug('[ObjectQLPlugin] authored-action read from sys_metadata failed', { + error: e?.message, + }); + return null; + } + } + + /** + * Serializes {@link resyncAuthoredActions} runs — same rationale as + * {@link authoredHookResyncChain}: overlapping read→register sequences + * must not finish out of order and leave the older snapshot registered. + */ + private authoredActionResyncChain: Promise = Promise.resolve(); + + /** + * (Re-)register runtime-authored actions on the engine (#2605 item 1 — + * the action-path parallel of {@link resyncAuthoredHooks}). + * + * Both action dispatch surfaces (`POST /api/v1/actions/:object/:action` + * and the MCP `run_action` bridge) resolve handlers through + * `engine.executeAction`, whose map was only ever populated from the app + * bundle at boot — a published `action` row was stored + listed but never + * executable, before OR after a restart. + * + * Sources, unioned by `:` (fresher DB row wins): + * 1. `metadataService.loadMany('action')` — FS-scanned action items; + * 2. authored `sys_metadata` rows (standalone AND object-embedded) via + * {@link readAuthoredActionRows}. + * + * Package-artifact actions are filtered out (AppPlugin registers those + * under `app:`; registerAction replaces by key, so re-registering + * would clobber them). Handlers are built through the engine's default + * action runner installed at boot by the runtime's AppPlugin; when that + * runner is absent (e.g. `OS_DISABLE_AUTHORED_ACTIONS=1`, or a bare + * engine without the runtime) bodies are skipped with a warning. Bodyless + * actions (target-bound script / flow / url) register nothing here — + * their dispatch is either code (registered by the app) or the flow + * runner, not a metadata body. + * + * Idempotent: the whole `'metadata-service'` action set is torn down and + * re-registered, so edits re-register and deleted rows unregister. + * Best-effort: when BOTH sources are unreadable the resync is a no-op. + */ + private resyncAuthoredActions(ctx: PluginContext): Promise { + const run = this.authoredActionResyncChain.then(() => this.resyncAuthoredActionsNow(ctx)); + // The chain must never hold a rejection (it would poison every later + // resync); callers still see the failure through `run`. + this.authoredActionResyncChain = run.catch(() => undefined); + return run; + } + + private async resyncAuthoredActionsNow(ctx: PluginContext): Promise { + const ql: any = this.ql; + if (!ql + || typeof ql.registerAction !== 'function' + || typeof ql.removeActionsByPackage !== 'function') { + return; + } + + let serviceActions: any[] | null = null; + try { + const metadataService = ctx.getService('metadata') as any; + if (metadataService && typeof metadataService.loadMany === 'function') { + serviceActions = (await metadataService.loadMany('action')) ?? []; + } + } catch { + serviceActions = null; // no metadata service on this kernel + } + + const authoredActions = await this.readAuthoredActionRows(ctx); + if (serviceActions === null && authoredActions === null) return; // nothing readable — keep current registrations + + const byKey = new Map(); + for (const a of serviceActions ?? []) { + if (a && typeof a.name === 'string') byKey.set(`${this.actionObjectKey(a)}:${a.name}`, a); + } + for (const a of authoredActions ?? []) { + if (a && typeof a.name === 'string') byKey.set(`${this.actionObjectKey(a)}:${a.name}`, a); + } + + const bindable = Array.from(byKey.values()).filter( + (a) => !this.isArtifactShippedAction(a), + ); + + // Full replace: tear down the package set, then re-register survivors — + // deleting the last authored action must unregister it. + ql.removeActionsByPackage('metadata-service'); + + const runner: any = ql._defaultActionRunner; + let registered = 0; + let skippedNoHandler = 0; + for (const action of bindable) { + if (typeof runner !== 'function') { + skippedNoHandler++; + continue; + } + let handler: any; + try { + handler = runner(action); + } catch (e: any) { + ctx.logger.warn('[ObjectQLPlugin] default action runner rejected an authored action', { + action: action.name, + error: e?.message, + }); + continue; + } + if (typeof handler !== 'function') { + skippedNoHandler++; // no body (target/flow/url action) or invalid body shape + continue; + } + ql.registerAction(this.actionObjectKey(action), action.name, handler, 'metadata-service'); + registered++; + } + if (typeof runner !== 'function' && bindable.length > 0) { + ctx.logger.warn( + '[ObjectQLPlugin] authored actions present but no default action runner is installed ' + + '— their bodies will not execute (is the runtime AppPlugin booted, ' + + 'or is OS_DISABLE_AUTHORED_ACTIONS=1 set?)', + { actions: bindable.slice(0, 5).map((a: any) => a.name) }, + ); + } + ctx.logger.info('[ObjectQLPlugin] re-synced runtime-authored actions', { + registered, + authoredRows: authoredActions?.length ?? 0, + artifactSkipped: byKey.size - bindable.length, + skippedNoHandler, + }); + } + /** * Load metadata from external metadata service into ObjectQL registry * This enables ObjectQL to use file-based or remote metadata diff --git a/packages/plugins/plugin-sharing/src/rule-hooks.ts b/packages/plugins/plugin-sharing/src/rule-hooks.ts index 02b643cd11..3d1e66a8f4 100644 --- a/packages/plugins/plugin-sharing/src/rule-hooks.ts +++ b/packages/plugins/plugin-sharing/src/rule-hooks.ts @@ -7,6 +7,14 @@ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; export const SHARING_RULE_HOOK_PACKAGE = 'plugin-sharing:rules'; +/** + * Package id for the `sys_sharing_rule` DATA-change triggers that re-run the + * bind (#2592). Deliberately distinct from {@link SHARING_RULE_HOOK_PACKAGE} + * so {@link unbindAllRuleHooks} — which the rebind itself calls — can never + * tear down the triggers that drive it. + */ +export const RULE_REBIND_TRIGGER_PACKAGE = 'plugin-sharing:rule-rebind'; + interface MinimalEngine { registerHook(event: string, handler: (ctx: any) => any | Promise, options?: { object?: string | string[]; diff --git a/packages/plugins/plugin-sharing/src/rule-rebind.test.ts b/packages/plugins/plugin-sharing/src/rule-rebind.test.ts new file mode 100644 index 0000000000..a0d2d2d1e6 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/rule-rebind.test.ts @@ -0,0 +1,150 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Sharing-rule hook rebind on `sys_sharing_rule` DATA changes (#2592). + * + * `bindRuleHooks` runs once at kernel:ready with the rules that existed at + * boot, registering lifecycle hooks only for objects that had ≥1 rule then. + * A rule created at runtime for an object with no boot-time rule therefore + * never evaluated until restart — and because rule authoring is a data + * insert (not a metadata publish), the `metadata:reloaded` rebind pattern + * never fires. The plugin now binds afterInsert/afterUpdate/afterDelete + * triggers on `sys_sharing_rule` itself that unbind + re-bind the whole + * rule-hook package from a fresh `listRules()`. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SharingServicePlugin } from './sharing-plugin.js'; +import { + SHARING_RULE_HOOK_PACKAGE, + RULE_REBIND_TRIGGER_PACKAGE, +} from './rule-hooks.js'; + +type AnyRecord = Record; +type HookEntry = { event: string; handler: (ctx: any) => any; options: AnyRecord }; + +function makeEngine() { + const hooks: HookEntry[] = []; + return { + hooks, + registerHook: vi.fn((event: string, handler: (ctx: any) => any, options: AnyRecord = {}) => { + hooks.push({ event, handler, options }); + }), + unregisterHooksByPackage: vi.fn((packageId: string) => { + let removed = 0; + for (let i = hooks.length - 1; i >= 0; i--) { + if (hooks[i].options.packageId === packageId) { hooks.splice(i, 1); removed++; } + } + return removed; + }), + /** Test helper: hooks bound for a given package. */ + boundFor(packageId: string): HookEntry[] { + return hooks.filter((h) => h.options.packageId === packageId); + }, + /** Test helper: fire the rebind trigger like a real rule write would. */ + async fire(event: string, object: string, ctx: AnyRecord = {}) { + for (const h of [...hooks]) { + if (h.event === event && h.options.object === object) await h.handler(ctx); + } + }, + }; +} + +function makeCtx() { + return { + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + } as AnyRecord; +} + +describe('SharingServicePlugin sys_sharing_rule data-change rebind (#2592)', () => { + let engine: ReturnType; + let plugin: SharingServicePlugin; + let rules: AnyRecord[]; + let ruleService: AnyRecord; + + beforeEach(() => { + engine = makeEngine(); + plugin = new SharingServicePlugin(); + rules = []; + ruleService = { + listRules: vi.fn(async () => rules), + }; + (plugin as any).ruleService = ruleService; + (plugin as any).bindRuleRebindTriggers(engine, makeCtx()); + }); + + it('binds insert/update/delete triggers on sys_sharing_rule under its own package id', () => { + const triggers = engine.boundFor(RULE_REBIND_TRIGGER_PACKAGE); + expect(triggers.map((t) => t.event).sort()).toEqual(['afterDelete', 'afterInsert', 'afterUpdate']); + for (const t of triggers) expect(t.options.object).toBe('sys_sharing_rule'); + }); + + it('binds the FIRST rule for an object without a restart (the #2592 repro)', async () => { + // Boot state: no rules at all → bindRuleHooks bound nothing. + expect(engine.boundFor(SHARING_RULE_HOOK_PACKAGE)).toHaveLength(0); + + // Runtime: admin creates the first rule for `project` (a data insert). + rules = [{ name: 'r1', object_name: 'project', active: true }]; + await engine.fire('afterInsert', 'sys_sharing_rule', { result: { id: 'r1' } }); + + const bound = engine.boundFor(SHARING_RULE_HOOK_PACKAGE); + expect(bound.map((h) => h.event).sort()).toEqual(['afterInsert', 'afterUpdate']); + for (const h of bound) expect(h.options.object).toBe('project'); + }); + + it('tears down hooks when the last rule for an object is deleted', async () => { + rules = [{ name: 'r1', object_name: 'project', active: true }]; + await engine.fire('afterInsert', 'sys_sharing_rule', {}); + expect(engine.boundFor(SHARING_RULE_HOOK_PACKAGE)).toHaveLength(2); + + rules = []; + await engine.fire('afterDelete', 'sys_sharing_rule', {}); + + expect(engine.boundFor(SHARING_RULE_HOOK_PACKAGE)).toHaveLength(0); + }); + + it('never unbinds its own rebind triggers when re-binding', async () => { + rules = [{ name: 'r1', object_name: 'project', active: true }]; + await engine.fire('afterInsert', 'sys_sharing_rule', {}); + await engine.fire('afterUpdate', 'sys_sharing_rule', {}); + + expect(engine.boundFor(RULE_REBIND_TRIGGER_PACKAGE)).toHaveLength(3); + }); + + it('keeps previous bindings and does not throw when listRules fails', async () => { + rules = [{ name: 'r1', object_name: 'project', active: true }]; + await engine.fire('afterInsert', 'sys_sharing_rule', {}); + expect(engine.boundFor(SHARING_RULE_HOOK_PACKAGE)).toHaveLength(2); + + ruleService.listRules = vi.fn(async () => { throw new Error('db gone'); }); + await expect( + engine.fire('afterUpdate', 'sys_sharing_rule', {}), + ).resolves.toBeUndefined(); // the write must not fail + + // The failed rebind ran before unbind — previous bindings intact. + expect(engine.boundFor(SHARING_RULE_HOOK_PACKAGE)).toHaveLength(2); + }); + + it('serializes overlapping rebinds so the newest rule snapshot wins', async () => { + let release!: () => void; + const gate = new Promise((r) => { release = r; }); + let call = 0; + ruleService.listRules = vi.fn(async () => { + call++; + if (call === 1) { + await gate; // first rebind stalls on its read + return [{ name: 'r1', object_name: 'alpha', active: true }]; + } + return [{ name: 'r2', object_name: 'beta', active: true }]; + }); + + const first = engine.fire('afterInsert', 'sys_sharing_rule', {}); + const second = engine.fire('afterUpdate', 'sys_sharing_rule', {}); + release(); + await Promise.all([first, second]); + + // The second (newest) snapshot is the one left bound. + const bound = engine.boundFor(SHARING_RULE_HOOK_PACKAGE); + expect(new Set(bound.map((h) => h.options.object))).toEqual(new Set(['beta'])); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index 27fe66d9ff..962e1757d0 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -9,7 +9,7 @@ import { SharingService, type SharingEngine } from './sharing-service.js'; import { SharingRuleService } from './sharing-rule-service.js'; import { ShareLinkService } from './share-link-service.js'; import { registerShareLinkRoutes } from './share-link-routes.js'; -import { bindRuleHooks, unbindAllRuleHooks } from './rule-hooks.js'; +import { bindRuleHooks, unbindAllRuleHooks, RULE_REBIND_TRIGGER_PACKAGE } from './rule-hooks.js'; import { bindPrimaryBuHooks, backfillPrimaryBu } from './primary-bu-projection.js'; import { bootstrapDeclaredSharingRules } from './bootstrap-declared-sharing-rules.js'; @@ -80,6 +80,64 @@ export class SharingServicePlugin implements Plugin { this.options = options; } + /** + * Serializes rule-hook rebinds triggered by `sys_sharing_rule` data + * changes, so two rapid writes can't interleave their unbind→bind + * sequences and leave the older rule snapshot bound. + */ + private ruleRebindChain: Promise = Promise.resolve(); + + /** + * [#2592] Rebind rule hooks whenever `sys_sharing_rule` DATA changes. + * + * `bindRuleHooks` above runs once at `kernel:ready` and registers + * lifecycle hooks only for the objects that had ≥1 rule at that moment. + * Rule *evaluation* reads `sys_sharing_rule` live, but a rule created at + * runtime for an object with no boot-time rule never got a hook — so it + * silently no-oped until the next restart. And because authoring a rule + * is a data INSERT (not a metadata publish), the `metadata:reloaded` + * rebind pattern (#2576) never fires here — the trigger must be a + * data-change hook on the rule table itself. + * + * The rebind mirrors boot exactly: unbind the whole rule-hook package, + * re-bind from a fresh `listRules()`. Runs AFTER the write inside the + * same lifecycle pipeline (awaited, so the rule is enforceable the moment + * the authoring call returns), but never fails the write — a rebind + * failure logs and leaves the previous bindings in place. + */ + private bindRuleRebindTriggers(engine: any, ctx: PluginContext): void { + const scheduleRebind = (): Promise => { + const run = this.ruleRebindChain.then(async () => { + const ruleService = this.ruleService; + if (!ruleService) return; + const rules = await ruleService.listRules({ activeOnly: true }, { isSystem: true } as any); + unbindAllRuleHooks(engine); + bindRuleHooks(engine, ruleService, rules, ctx.logger as any); + }); + // The chain must never hold a rejection (it would poison later + // rebinds); callers observe failures through `run`. + this.ruleRebindChain = run.catch(() => undefined); + return run; + }; + const handler = async () => { + try { + await scheduleRebind(); + } catch (err: any) { + ctx.logger.warn('SharingServicePlugin: sharing-rule hook rebind failed — previous bindings kept', { + error: err?.message, + }); + } + }; + for (const event of ['afterInsert', 'afterUpdate', 'afterDelete']) { + engine.registerHook(event, handler, { + object: 'sys_sharing_rule', + packageId: RULE_REBIND_TRIGGER_PACKAGE, + priority: 200, + }); + } + ctx.logger.info('SharingServicePlugin: sharing-rule data-change rebind triggers bound'); + } + async init(ctx: PluginContext): Promise { // Register sys_record_share via the manifest service. ctx.getService<{ register(m: any): void }>('manifest').register({ @@ -202,6 +260,7 @@ export class SharingServicePlugin implements Plugin { const rules = await this.ruleService.listRules({ activeOnly: true }, { isSystem: true } as any); unbindAllRuleHooks(engine); bindRuleHooks(engine, this.ruleService, rules, ctx.logger as any); + this.bindRuleRebindTriggers(engine, ctx); } else { ctx.logger.warn('SharingServicePlugin: engine has no hook API — sharing rule auto-evaluation disabled'); } diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 34ddbf36dd..ef9912de06 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { Plugin, PluginContext } from '@objectstack/core'; +import { Plugin, PluginContext, wireAuthoredTranslationSync } from '@objectstack/core'; import { resolveMultiOrgEnabled } from '@objectstack/types'; import { SeedLoaderService } from './seed-loader.js'; import { loadDisabledPackageIds } from './package-state-store.js'; @@ -118,6 +118,15 @@ export class AppPlugin implements Plugin { // 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); + // Same for the action runner — authored actions register in Phase 2's + // authored-action re-sync and need the sandbox bridge in place (#2605). + this.installDefaultActionBodyRunner(ctx); + // Wire the authored-translation sync (#2591) — also BEFORE the empty-env + // return: an empty env is exactly where a user authors their first + // Studio translation. Covers whatever `i18n` service this kernel ends + // up with (the core in-memory fallback included); idempotent across + // multiple wirers via the ownership marker in core. + wireAuthoredTranslationSync(ctx as any); if (this.empty) { ctx.logger.debug('[AppPlugin] empty env — no app payload, skipping init', { pluginName: this.name, @@ -211,6 +220,44 @@ export class AppPlugin implements Plugin { ctx.logger.info('[AppPlugin] Installed default hook body runner (runtime-authored hooks can execute)'); } + /** + * Install the engine's DEFAULT action body runner (`engine.setDefaultActionRunner`). + * + * The exact action-path parallel of {@link installDefaultHookBodyRunner} + * (#2605 item 1): actions authored at runtime (Studio → `action` metadata → + * publish) are registered by ObjectQLPlugin's authored-action re-sync, + * which lives in `objectql` and therefore has no sandbox of its own. This + * boot point hands it the same QuickJS-sandboxed runner that + * `defineStack({ actions })` bundles already execute through, so an + * authored `body` becomes a real `executeAction` handler instead of a + * silent "Action not found". + * + * `OS_DISABLE_AUTHORED_ACTIONS=1` opts out for deployments that want + * runtime-authored (DB-stored, non-code-reviewed) action bodies to stay + * inert; code-shipped actions are unaffected (AppPlugin registers those + * itself with its own runner). + */ + private installDefaultActionBodyRunner(ctx: PluginContext): void { + if (process.env.OS_DISABLE_AUTHORED_ACTIONS === '1') { + ctx.logger.info('[AppPlugin] OS_DISABLE_AUTHORED_ACTIONS=1 — runtime-authored action 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.setDefaultActionRunner !== 'function') return; + if (ql._defaultActionRunner) return; // another AppPlugin already installed one + ql.setDefaultActionRunner(actionBodyRunnerFactory(new QuickJSScriptRunner(), { + ql, + logger: ctx.logger, + appId: 'runtime-authored', + })); + ctx.logger.info('[AppPlugin] Installed default action body runner (runtime-authored actions can execute)'); + } + start = async (ctx: PluginContext) => { if (this.empty) { ctx.logger.debug('[AppPlugin] empty env — no app payload, skipping start', { diff --git a/packages/services/service-i18n/src/authored-translations.test.ts b/packages/services/service-i18n/src/authored-translations.test.ts new file mode 100644 index 0000000000..b51f3fcf7b --- /dev/null +++ b/packages/services/service-i18n/src/authored-translations.test.ts @@ -0,0 +1,276 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Runtime-authored translation sync (#2591). + * + * Translations authored in the Studio persist as `translation` sys_metadata + * rows, but only static bundles (app `bundle.translations`, plugin + * `translations/`) were ever loaded into the i18n runtime — a published + * translation was a dead-end on publish AND after restart. The + * I18nServicePlugin now syncs the authored layer from the rows at + * `kernel:ready`, on `metadata:reloaded`, and on protocol `translation` + * mutations; the FileI18nAdapter keeps that layer separate so a re-sync is + * clear-then-reload (deleted keys stop resolving) while authored values win + * over static bundle values on read. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { FileI18nAdapter } from './file-i18n-adapter.js'; +import { I18nServicePlugin } from './i18n-service-plugin.js'; + +type AnyRecord = Record; + +// ── Adapter layer ────────────────────────────────────────────────────────── + +describe('FileI18nAdapter.replaceAuthoredTranslations (#2591)', () => { + it('overlays authored values over the static bundle on t() and getTranslations()', () => { + const i18n = new FileI18nAdapter({ defaultLocale: 'en' }); + i18n.loadTranslations('en', { messages: { save: 'Save', cancel: 'Cancel' } }); + + i18n.replaceAuthoredTranslations({ en: { messages: { save: 'Save changes' } } }); + + expect(i18n.t('messages.save', 'en')).toBe('Save changes'); // authored wins + expect(i18n.t('messages.cancel', 'en')).toBe('Cancel'); // static still visible + const merged: any = i18n.getTranslations('en'); + expect(merged.messages.save).toBe('Save changes'); + expect(merged.messages.cancel).toBe('Cancel'); + }); + + it('clear-then-reload: keys removed from the authored layer stop resolving', () => { + const i18n = new FileI18nAdapter({ defaultLocale: 'en' }); + i18n.replaceAuthoredTranslations({ en: { messages: { greeting: 'Hello' } } }); + expect(i18n.t('messages.greeting', 'en')).toBe('Hello'); + + i18n.replaceAuthoredTranslations({ en: { messages: { farewell: 'Bye' } } }); + + expect(i18n.t('messages.greeting', 'en')).toBe('messages.greeting'); // gone, not lingering + expect(i18n.t('messages.farewell', 'en')).toBe('Bye'); + }); + + it('does not disturb the static layer when the authored layer empties', () => { + const i18n = new FileI18nAdapter({ defaultLocale: 'en' }); + i18n.loadTranslations('en', { messages: { save: 'Save' } }); + i18n.replaceAuthoredTranslations({ en: { messages: { save: 'Authored' } } }); + expect(i18n.t('messages.save', 'en')).toBe('Authored'); + + i18n.replaceAuthoredTranslations({}); + + expect(i18n.t('messages.save', 'en')).toBe('Save'); + }); + + it('surfaces authored-only locales in getLocales()', () => { + const i18n = new FileI18nAdapter({ defaultLocale: 'en' }); + i18n.loadTranslations('en', { messages: {} }); + i18n.replaceAuthoredTranslations({ 'zh-CN': { messages: { save: '保存' } } }); + + expect(i18n.getLocales().sort()).toEqual(['en', 'zh-CN']); + expect(i18n.t('messages.save', 'zh-CN')).toBe('保存'); + }); + + it('invalidates the merged cache when a static bundle loads after a read', () => { + const i18n = new FileI18nAdapter({ defaultLocale: 'en' }); + i18n.replaceAuthoredTranslations({ en: { messages: { a: 'A' } } }); + expect((i18n.getTranslations('en') as any).messages.a).toBe('A'); // primes cache + + i18n.loadTranslations('en', { messages: { b: 'B' } }); + + const merged: any = i18n.getTranslations('en'); + expect(merged.messages).toEqual({ a: 'A', b: 'B' }); + }); +}); + +// ── Plugin sync layer ────────────────────────────────────────────────────── + +function makeCtx(services: AnyRecord = {}) { + const hooks = new Map Promise>>(); + return { + ctx: { + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerService: vi.fn((name: string, svc: any) => { services[name] = svc; }), + getService: vi.fn((name: string) => { + if (name in services) return services[name]; + throw new Error(`service '${name}' not registered`); + }), + hook: vi.fn((name: string, fn: () => Promise) => { + const list = hooks.get(name) ?? []; + list.push(fn); + hooks.set(name, list); + }), + } as AnyRecord, + services, + fire: async (name: string) => { + for (const fn of hooks.get(name) ?? []) await fn(); + }, + }; +} + +const translationRow = (name: string, payload: AnyRecord, extra: AnyRecord = {}) => ({ + type: 'translation', + name, + state: 'active', + metadata: JSON.stringify(payload), + ...extra, +}); + +async function bootPlugin(services: AnyRecord) { + const plugin = new I18nServicePlugin({ registerRoutes: false }); + const harness = makeCtx(services); + await plugin.init(harness.ctx as any); + await plugin.start(harness.ctx as any); + const i18n = services['i18n'] as FileI18nAdapter; + return { plugin, harness, i18n }; +} + +describe('I18nServicePlugin authored-translation sync (#2591)', () => { + it('loads active translation rows at kernel:ready, resolving locale from the item name', async () => { + const engine = { + find: vi.fn(async (_obj: string, q: AnyRecord) => + q?.where?.type === 'translation' + ? [translationRow('zh-CN', { messages: { save: '保存' } })] + : []), + }; + const { harness, i18n } = await bootPlugin({ objectql: engine }); + + await harness.fire('kernel:ready'); + + expect(i18n.t('messages.save', 'zh-CN')).toBe('保存'); + }); + + it('prefers _meta.locale, then a top-level locale field, over the item name', async () => { + const engine = { + find: vi.fn(async (_obj: string, q: AnyRecord) => + q?.where?.type === 'translation' + ? [ + translationRow('branding_strings', { _meta: { locale: 'fr' }, messages: { save: 'Enregistrer' } }), + translationRow('other_strings', { locale: 'de', messages: { save: 'Speichern' } }), + ] + : []), + }; + const { harness, i18n } = await bootPlugin({ objectql: engine }); + + await harness.fire('kernel:ready'); + + expect(i18n.t('messages.save', 'fr')).toBe('Enregistrer'); + expect(i18n.t('messages.save', 'de')).toBe('Speichern'); + }); + + it('skips items with no resolvable locale (warns) without dropping the rest', async () => { + const engine = { + find: vi.fn(async (_obj: string, q: AnyRecord) => + q?.where?.type === 'translation' + ? [ + translationRow('no_locale_here_at_all', { messages: { x: 'X' } }), + translationRow('en', { messages: { ok: 'OK' } }), + ] + : []), + }; + const { harness, i18n } = await bootPlugin({ objectql: engine }); + + await harness.fire('kernel:ready'); + + expect(i18n.t('messages.ok', 'en')).toBe('OK'); + expect(harness.ctx.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('no resolvable locale'), + ); + }); + + it('deep-merges multiple items on the same locale in name order', async () => { + const engine = { + find: vi.fn(async (_obj: string, q: AnyRecord) => + q?.where?.type === 'translation' + ? [ + translationRow('en', { messages: { a: 'A', shared: 'first' } }), + translationRow('en_extras', { locale: 'en', messages: { b: 'B', shared: 'second' } }), + ] + : []), + }; + const { harness, i18n } = await bootPlugin({ objectql: engine }); + + await harness.fire('kernel:ready'); + + expect(i18n.t('messages.a', 'en')).toBe('A'); + expect(i18n.t('messages.b', 'en')).toBe('B'); + expect(i18n.t('messages.shared', 'en')).toBe('second'); // later name wins + }); + + it('re-syncs on metadata:reloaded with clear-then-reload semantics', async () => { + let rows = [translationRow('en', { messages: { greeting: 'Hello' } })]; + const engine = { + find: vi.fn(async (_obj: string, q: AnyRecord) => + q?.where?.type === 'translation' ? rows : []), + }; + const { harness, i18n } = await bootPlugin({ objectql: engine }); + await harness.fire('kernel:ready'); + expect(i18n.t('messages.greeting', 'en')).toBe('Hello'); + + rows = [translationRow('en', { messages: { farewell: 'Bye' } })]; // greeting deleted + await harness.fire('metadata:reloaded'); + + expect(i18n.t('messages.greeting', 'en')).toBe('messages.greeting'); + expect(i18n.t('messages.farewell', 'en')).toBe('Bye'); + }); + + it('re-syncs on protocol translation mutations (skips drafts and other types)', async () => { + const listeners: Array<(evt: AnyRecord) => void> = []; + const protocol = { + onMetadataMutation: vi.fn((fn: (evt: AnyRecord) => void) => { listeners.push(fn); return () => {}; }), + }; + let rows: AnyRecord[] = []; + const engine = { + find: vi.fn(async (_obj: string, q: AnyRecord) => + q?.where?.type === 'translation' ? rows : []), + }; + const { harness, i18n } = await bootPlugin({ objectql: engine, protocol }); + await harness.fire('kernel:ready'); + expect(protocol.onMetadataMutation).toHaveBeenCalledTimes(1); + + rows = [translationRow('en', { messages: { live: 'Live!' } })]; + listeners[0]({ type: 'translation', name: 'en', state: 'active' }); + await new Promise((r) => setTimeout(r, 0)); // sync is fire-and-forget + + expect(i18n.t('messages.live', 'en')).toBe('Live!'); + + // Draft saves and unrelated types do not churn the layer. + const callsBefore = engine.find.mock.calls.length; + listeners[0]({ type: 'translation', name: 'en', state: 'draft' }); + listeners[0]({ type: 'view', name: 'v', state: 'active' }); + await new Promise((r) => setTimeout(r, 0)); + expect(engine.find.mock.calls.length).toBe(callsBefore); + }); + + it('keeps the current authored layer when the row read fails', async () => { + let fail = false; + const engine = { + find: vi.fn(async (_obj: string, q: AnyRecord) => { + if (fail) throw new Error('db gone'); + return q?.where?.type === 'translation' + ? [translationRow('en', { messages: { keep: 'Kept' } })] + : []; + }), + }; + const { harness, i18n } = await bootPlugin({ objectql: engine }); + await harness.fire('kernel:ready'); + expect(i18n.t('messages.keep', 'en')).toBe('Kept'); + + fail = true; + await harness.fire('metadata:reloaded'); + + expect(i18n.t('messages.keep', 'en')).toBe('Kept'); // failed read never tears down + }); + + it('falls back to legacy plural rows when no singular rows exist', async () => { + const engine = { + find: vi.fn(async (_obj: string, q: AnyRecord) => { + if (q?.where?.type === 'translations') { + return [translationRow('en', { messages: { legacy: 'Old' } }, { type: 'translations' })]; + } + return []; + }), + }; + const { harness, i18n } = await bootPlugin({ objectql: engine }); + + await harness.fire('kernel:ready'); + + expect(i18n.t('messages.legacy', 'en')).toBe('Old'); + }); +}); diff --git a/packages/services/service-i18n/src/file-i18n-adapter.ts b/packages/services/service-i18n/src/file-i18n-adapter.ts index 850857c15f..81d0110ed6 100644 --- a/packages/services/service-i18n/src/file-i18n-adapter.ts +++ b/packages/services/service-i18n/src/file-i18n-adapter.ts @@ -36,8 +36,11 @@ function resolveKey(data: Record, key: string): string | undefi /** * Deep-merge two plain objects recursively. * Arrays and non-plain-object values from `source` overwrite those in `target`. + * Exported for the plugin's authored-translation re-sync (#2591), which must + * merge multiple authored items targeting the same locale with the exact + * semantics the adapter itself uses. */ -function deepMerge( +export function deepMerge( target: Record, source: Record, ): Record { @@ -104,6 +107,16 @@ function interpolate(template: string, params: Record): string */ export class FileI18nAdapter implements II18nService { private readonly translations = new Map>(); + /** + * Runtime-AUTHORED overlay (#2591): translations published as `translation` + * metadata in the Studio. Kept separate from the static `translations` map + * so a re-sync can REPLACE the whole authored layer (clear-then-reload) — + * deep-merging authored items into the static map would make deleted keys + * linger forever. Authored values win over static bundle values on read. + */ + private readonly authoredTranslations = new Map>(); + /** Per-locale merged view (static ⊕ authored), invalidated on any write. */ + private readonly mergedCache = new Map>(); private defaultLocale: string; private readonly fallbackLocale: string | undefined; @@ -137,7 +150,13 @@ export class FileI18nAdapter implements II18nService { } getTranslations(locale: string): Record { - return this.translations.get(locale) ?? {}; + const authored = this.authoredTranslations.get(locale); + if (!authored) return this.translations.get(locale) ?? {}; + const cached = this.mergedCache.get(locale); + if (cached) return cached; + const merged = deepMerge(this.translations.get(locale) ?? {}, authored); + this.mergedCache.set(locale, merged); + return merged; } loadTranslations(locale: string, translations: Record): void { @@ -149,10 +168,31 @@ export class FileI18nAdapter implements II18nService { } else { this.translations.set(locale, { ...translations }); } + this.mergedCache.delete(locale); + } + + /** + * Replace the ENTIRE runtime-authored translation layer (#2591). + * + * Called by the I18nServicePlugin's authored-translation re-sync with the + * full current set of active `translation` metadata items, keyed by + * locale. Wholesale replacement — not a merge — so keys removed from (or + * deleted with) an authored item stop resolving on the next sync, while + * the static bundle layer underneath is untouched. + */ + replaceAuthoredTranslations(byLocale: Record>): void { + this.authoredTranslations.clear(); + for (const [locale, data] of Object.entries(byLocale)) { + if (!data || typeof data !== 'object') continue; + this.authoredTranslations.set(locale, { ...data }); + } + this.mergedCache.clear(); } getLocales(): string[] { - return Array.from(this.translations.keys()); + const locales = new Set(this.translations.keys()); + for (const locale of this.authoredTranslations.keys()) locales.add(locale); + return Array.from(locales); } getDefaultLocale(): string { @@ -186,6 +226,13 @@ export class FileI18nAdapter implements II18nService { } private resolveFromLocale(key: string, locale: string): string | undefined { + // Authored layer wins over the static bundle (same precedence as + // getTranslations' merged view) — cheap two-map probe instead of a merge. + const authored = this.authoredTranslations.get(locale); + if (authored) { + const value = resolveKey(authored, key); + if (value !== undefined) return value; + } const data = this.translations.get(locale); if (!data) return undefined; return resolveKey(data, key); diff --git a/packages/services/service-i18n/src/i18n-service-plugin.test.ts b/packages/services/service-i18n/src/i18n-service-plugin.test.ts index c3712e1bcc..4800d67f13 100644 --- a/packages/services/service-i18n/src/i18n-service-plugin.test.ts +++ b/packages/services/service-i18n/src/i18n-service-plugin.test.ts @@ -150,7 +150,10 @@ describe('I18nServicePlugin', () => { await plugin.init!(ctx as any); await plugin.start!(ctx as any); - expect(ctx.hook).not.toHaveBeenCalled(); + // kernel:ready / metadata:reloaded hooks are still registered for the + // authored-translation sync (#2591) — but no HTTP route may land. + await ctx.trigger('kernel:ready'); + expect(httpServer.get).not.toHaveBeenCalled(); }); it('should gracefully skip routes when http-server is not available', async () => { diff --git a/packages/services/service-i18n/src/i18n-service-plugin.ts b/packages/services/service-i18n/src/i18n-service-plugin.ts index 1dacb8c4d0..abe0dbb647 100644 --- a/packages/services/service-i18n/src/i18n-service-plugin.ts +++ b/packages/services/service-i18n/src/i18n-service-plugin.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { Plugin, PluginContext } from '@objectstack/core'; +import { wireAuthoredTranslationSync } from '@objectstack/core'; import type { IHttpServer, IHttpRequest, IHttpResponse } from '@objectstack/spec/contracts'; import type { II18nService } from '@objectstack/spec/contracts'; import { FileI18nAdapter } from './file-i18n-adapter.js'; @@ -110,6 +111,19 @@ export class I18nServicePlugin implements Plugin { } }); } + + // ── Runtime-authored translations (#2591) ────────────────────────── + // Translations authored in the Studio persist as `translation` metadata + // (`allowRuntimeCreate: true`) but only static bundles were ever loaded + // — a published translation was a dead-end on publish AND after restart. + // The shared core sync (`wireAuthoredTranslationSync`) loads the authored + // layer at `kernel:ready`, on `metadata:reloaded`, and on `translation` + // protocol mutations, replacing it wholesale via the adapter's + // `replaceAuthoredTranslations` (clear-then-reload — deleted keys stop + // resolving). The runtime's AppPlugin wires the same sync for kernels + // running the in-memory fallback adapter; the ownership marker inside + // makes double-wiring a no-op. + wireAuthoredTranslationSync(ctx as any); } /**