Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .changeset/studio-metadata-runtime-wiring.md
Original file line number Diff line number Diff line change
@@ -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.
189 changes: 189 additions & 0 deletions packages/core/src/fallbacks/authored-translation-sync.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>;

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): 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<any[]> },
logger?: MinimalCtx['logger'],
): Promise<Record<string, Record<string, unknown>> | 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<string, Record<string, unknown>> = {};
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<string, unknown>);
}
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<void> = Promise.resolve();
const sync = (): Promise<void> => {
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();
});
}
6 changes: 5 additions & 1 deletion packages/core/src/fallbacks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
42 changes: 36 additions & 6 deletions packages/core/src/fallbacks/memory-i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
source: Record<string, unknown>,
): Record<string, unknown> {
Expand Down Expand Up @@ -77,6 +78,11 @@ export function resolveLocale(requestedLocale: string, availableLocales: string[
*/
export function createMemoryI18n() {
const translations = new Map<string, Record<string, unknown>>();
// 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<string, Record<string, unknown>>();
let defaultLocale = 'en';

/**
Expand All @@ -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<string, unknown> | 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<string, unknown> | 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;
}
Expand All @@ -110,7 +126,7 @@ export function createMemoryI18n() {
_fallback: true, _serviceName: 'i18n',

t(key: string, locale: string, params?: Record<string, unknown>): 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;
Expand All @@ -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<string, Record<string, unknown>>): 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 {
Expand Down
14 changes: 14 additions & 0 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>) | 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
Expand Down
Loading