From 515a8dffb5b2e187db480804b07c11ead198c478 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:48:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(metadata-protocol,objectql):=20MetadataPro?= =?UTF-8?q?tocolPlugin=20+=20registerProtocol=20opt-out=20=E2=80=94=20ADR-?= =?UTF-8?q?0076=20Step=202=20PR-A=20(#2462)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-repo window opened. Additive first leg of the three-leg sequence: - metadata-protocol grows createMetadataProtocolPlugin() owning the historical inline assembly: ObjectStackProtocolImplementation + 'protocol' registration, the metadata-storage platform objects (same environmentId===undefined gate), and the D12 degraded analytics fallback. Assembly-conflict guard turns the kernel's generic duplicate throw into the actionable fix. - ObjectQLPlugin grows registerProtocol?: boolean (default true, zero behavior change). false skips the assembly block; the authored hook/action mutation rebind is extracted to a shared private method and arms lazily from start() against whatever registered 'protocol'. DB hydration already consumed getService('protocol') + a type guard — untouched. - Combo tests live in objectql (metadata-protocol must not depend on objectql even as devDep — turbo flags the cycle, the PR#2415 lesson): surface parity, protocol-free boot, conflict guard, default-mode back-compat. PR-B flips cloud's 3 boot sites; PR-C retires the built-in assembly + objectql's protocol re-exports + the dependency. Verified: objectql 1086, metadata-protocol 70, runtime 653, dogfood 351, full build green. Co-Authored-By: Claude Fable 5 --- .changeset/step2-metadata-protocol-plugin.md | 20 +++ packages/metadata-protocol/src/index.ts | 2 + packages/metadata-protocol/src/plugin.ts | 139 +++++++++++++++++++ packages/objectql/src/plugin.step2.test.ts | 67 +++++++++ packages/objectql/src/plugin.ts | 84 +++++++---- 5 files changed, 287 insertions(+), 25 deletions(-) create mode 100644 .changeset/step2-metadata-protocol-plugin.md create mode 100644 packages/metadata-protocol/src/plugin.ts create mode 100644 packages/objectql/src/plugin.step2.test.ts diff --git a/.changeset/step2-metadata-protocol-plugin.md b/.changeset/step2-metadata-protocol-plugin.md new file mode 100644 index 0000000000..7cf5a03719 --- /dev/null +++ b/.changeset/step2-metadata-protocol-plugin.md @@ -0,0 +1,20 @@ +--- +"@objectstack/metadata-protocol": minor +"@objectstack/objectql": minor +--- + +feat(metadata-protocol,objectql): MetadataProtocolPlugin + `registerProtocol` opt-out — ADR-0076 Step 2 PR-A (#2462) + +`createMetadataProtocolPlugin()` now owns what `ObjectQLPlugin` historically +assembled inline: the `ObjectStackProtocolImplementation` construction + +`protocol` registration, the metadata-storage platform objects, and the D12 +`degraded` analytics fallback (pattern: plugin-security — named plugin, +`dependencies` on the engine, `ctx.getService('objectql')`). `ObjectQLPlugin` +grows `registerProtocol?: boolean` (default `true`, fully backward +compatible): pass `false` when mounting the new plugin. Protocol CONSUMERS +stay on the engine plugin either way — DB hydration and the authored +hook/action rebind resolve `protocol` lazily (the rebind arms from `start()` +in delegated mode) and degrade gracefully. Mixing both assemblies fails fast +with the fix in the message. This is the additive first leg of the +cross-repo sequence; cloud's 3 boot sites flip in PR-B, the built-in +assembly + re-exports retire in PR-C. diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index b37d674040..0f51a3587f 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -1,6 +1,8 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. export { ObjectStackProtocolImplementation, ConcurrentUpdateError, normalizeViewMetadata } from './protocol.js'; +export { createMetadataProtocolPlugin } from './plugin.js'; +export type { MetadataProtocolPluginOptions } from './plugin.js'; export type { UninstallCleanup, UninstallCleanupOutcome } from './protocol.js'; export type { MetadataMutationEvent, MetadataMutationProjector, MutationProjectionOutcome } from './protocol.js'; export type { MetadataAuthoringGate, MetadataAuthoringGateContext } from './protocol.js'; diff --git a/packages/metadata-protocol/src/plugin.ts b/packages/metadata-protocol/src/plugin.ts new file mode 100644 index 0000000000..e2ef303d07 --- /dev/null +++ b/packages/metadata-protocol/src/plugin.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * MetadataProtocolPlugin — ADR-0076 Step 2 (#2462, cross-repo window). + * + * Owns what `ObjectQLPlugin` historically assembled inline: the + * `ObjectStackProtocolImplementation` construction + `protocol` service + * registration, the metadata-storage platform objects, and the lightweight + * `analytics` fallback. Registering it NEXT TO `ObjectQLPlugin` (with + * `registerProtocol: false` on the engine plugin) makes `@objectstack/objectql` + * effectively protocol-free at boot-assembly level — the engine plugin keeps + * only protocol CONSUMERS (DB hydration + authored hook/action rebind, both of + * which resolve `getService('protocol')` lazily and degrade gracefully). + * + * Pattern follows plugin-security: named plugin + `dependencies` on the engine + * + `ctx.getService('objectql')`. + * + * Assembly contract: exactly ONE of {this plugin, ObjectQLPlugin's built-in + * assembly} may register `protocol` per kernel. `registerService` throws on + * duplicates by design (see the kernel contract); this plugin turns that into + * an actionable configuration message. + */ + +import type { Plugin, PluginContext } from '@objectstack/core'; +import { SERVICE_SELF_INFO_KEY, type ServiceSelfInfo } from '@objectstack/spec/api'; +import { + SysMetadataObject, + SysMetadataHistoryObject, + SysMetadataCommitObject, + SysMetadataAuditObject, + SysViewDefinitionObject, +} from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +export interface MetadataProtocolPluginOptions { + /** + * Per-project scope (cloud per-env kernels). When set, `saveMetaItem` + * stamps `environment_id` on new sys_metadata rows, `loadMetaFromDb` + * filters by it, and the metadata-storage objects are NOT provisioned + * locally (per-project kernels source metadata from the control plane). + * Mirrors `ObjectQLPluginOptions.environmentId` — pass the same value. + */ + environmentId?: string; +} + +export function createMetadataProtocolPlugin(options: MetadataProtocolPluginOptions = {}): Plugin { + const { environmentId } = options; + return { + name: 'com.objectstack.metadata.protocol', + version: '1.0.0', + dependencies: ['com.objectstack.engine.objectql'], + + init: async (ctx: PluginContext) => { + const ql: any = ctx.getService('objectql'); + + // Assembly-conflict guard: the engine plugin's built-in assembly + // (registerProtocol !== false) already registered `protocol`. + // Fail with the fix instead of the kernel's generic duplicate + // throw so the boot author knows which knob to turn. + let already: unknown; + try { already = ctx.getService('protocol'); } catch { /* not registered — good */ } + if (already) { + throw new Error( + '[MetadataProtocolPlugin] a `protocol` service is already registered — ' + + 'pass `registerProtocol: false` to ObjectQLPlugin when mounting MetadataProtocolPlugin (ADR-0076 Step 2).', + ); + } + + // Metadata-storage platform objects (sys_metadata + history/audit + // siblings + sys_view_definition). Same `environmentId === undefined` + // gate as the historical assembly: platform / standalone kernels own + // their local sys_metadata; per-project (cloud) kernels source + // metadata from the control plane and must NOT provision these + // tables locally. registerApp is idempotent — a MetadataPlugin that + // also registers them is harmless. + if (environmentId === undefined) { + ql.registerApp({ + id: 'com.objectstack.metadata-objects', + name: 'Metadata Platform Objects', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysMetadataObject, + SysMetadataHistoryObject, + SysMetadataCommitObject, + SysMetadataAuditObject, + SysViewDefinitionObject, + ], + }); + } + + const protocolShim = new ObjectStackProtocolImplementation( + ql, + () => (ctx.getServices ? ctx.getServices() : new Map()), + environmentId, + ); + ctx.registerService('protocol', protocolShim); + ctx.logger.info('Protocol service registered (MetadataProtocolPlugin)'); + + // Lightweight `analytics` fallback mapped onto the protocol shim's + // `analyticsQuery` — kept with the protocol assembly so `/analytics` + // keeps answering on installs without service-analytics. Honest + // capabilities (ADR-0076 D12): self-identifies as `degraded`; + // AnalyticsServicePlugin replaces it (ctx.replaceService) with the + // real engine. + ctx.registerService('analytics', { + [SERVICE_SELF_INFO_KEY]: { + status: 'degraded', + handlerReady: true, + message: 'Lightweight ObjectQL analytics fallback — install @objectstack/service-analytics for the full engine', + } satisfies ServiceSelfInfo, + // HttpDispatcher passes the raw POST body (AnalyticsQuery + // shape); the shim's `analyticsQuery` expects the wrapped + // `{ cube, query }` envelope and returns its own `{ success, + // data }` — reshape in, unwrap out (one level), exactly as the + // historical inline adapter did. + query: async (body: any) => { + const envelope = body && typeof body === 'object' && 'query' in body && 'cube' in body + ? body + : { cube: body?.cube, query: body }; + const result = await protocolShim.analyticsQuery(envelope); + if (result && typeof result === 'object' && 'success' in result && 'data' in result) { + return (result as any).data; + } + return result; + }, + getMeta: async () => ({ + cubes: [], + message: 'Analytics meta endpoint not implemented by ObjectQL adapter', + }), + generateSql: async (_body: any) => ({ + sql: null, + message: 'Analytics SQL generation not implemented by ObjectQL adapter', + }), + }); + }, + }; +} diff --git a/packages/objectql/src/plugin.step2.test.ts b/packages/objectql/src/plugin.step2.test.ts new file mode 100644 index 0000000000..aebca61466 --- /dev/null +++ b/packages/objectql/src/plugin.step2.test.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0076 Step 2 (#2462) — delegated protocol assembly. + * + * `ObjectQLPlugin({ registerProtocol: false })` + `createMetadataProtocolPlugin()` + * must produce the same service surface the built-in assembly does; mixing the + * built-in assembly WITH the plugin must fail with the actionable message. + * (This test lives in the objectql package: metadata-protocol must not depend + * on objectql, even as a devDependency — turbo flags the cycle.) + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { SERVICE_SELF_INFO_KEY } from '@objectstack/spec/api'; +import { createMetadataProtocolPlugin, ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { ObjectQLPlugin } from './plugin'; + +describe('ADR-0076 Step 2 — delegated protocol assembly', () => { + let kernel: ObjectKernel; + + beforeEach(() => { + kernel = new ObjectKernel({ logLevel: 'silent' }); + }); + + it('registerProtocol:false + MetadataProtocolPlugin reproduces the built-in service surface', async () => { + await kernel.use(new ObjectQLPlugin({ registerProtocol: false })); + await kernel.use(createMetadataProtocolPlugin()); + await kernel.bootstrap(); + + const protocol = kernel.getService('protocol'); + expect(protocol).toBeInstanceOf(ObjectStackProtocolImplementation); + // Hydration compatibility: the engine plugin's start() consumes this via + // getService('protocol') + the loadMetaFromDb type guard. + expect(typeof (protocol as any).loadMetaFromDb).toBe('function'); + + // The lightweight analytics fallback rides with the protocol assembly and + // keeps its D12 honest-capabilities self-descriptor. + const analytics: any = kernel.getService('analytics'); + expect(analytics).toBeDefined(); + expect(analytics[SERVICE_SELF_INFO_KEY]?.status).toBe('degraded'); + expect(typeof analytics.query).toBe('function'); + }); + + it('registerProtocol:false WITHOUT the plugin boots protocol-free (consumers degrade)', async () => { + await kernel.use(new ObjectQLPlugin({ registerProtocol: false })); + await kernel.bootstrap(); + + expect(() => kernel.getService('protocol')).toThrow(); + expect(() => kernel.getService('analytics')).toThrow(); + // The engine itself is fully alive. + expect(kernel.getService('objectql')).toBeDefined(); + }); + + it('built-in assembly + MetadataProtocolPlugin fails with the actionable configuration message', async () => { + await kernel.use(new ObjectQLPlugin()); + await kernel.use(createMetadataProtocolPlugin()); + await expect(kernel.bootstrap()).rejects.toThrow(/registerProtocol: false/); + }); + + it('default assembly is unchanged (backward compatibility)', async () => { + await kernel.use(new ObjectQLPlugin()); + await kernel.bootstrap(); + expect(kernel.getService('protocol')).toBeInstanceOf(ObjectStackProtocolImplementation); + expect((kernel.getService('analytics') as any)[SERVICE_SELF_INFO_KEY]?.status).toBe('degraded'); + }); +}); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index f14797b2ad..afcbd297d9 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -101,6 +101,16 @@ export interface ObjectQLPluginOptions { * Safe to leave unset: hydration tolerates a missing table. */ hydrateMetadataFromDb?: boolean; + /** + * ADR-0076 Step 2 (#2462): when `false`, this plugin SKIPS its built-in + * protocol assembly (the `protocol` service, the metadata-storage platform + * objects, and the lightweight `analytics` fallback) — mount + * `createMetadataProtocolPlugin()` from `@objectstack/metadata-protocol` + * alongside to own them instead. Protocol CONSUMERS stay here either way + * (DB hydration + authored hook/action rebind resolve `protocol` lazily). + * Defaults to `true` (built-in assembly, fully backward compatible). + */ + registerProtocol?: boolean; /** * ADR-0057 LifecycleService tuning. Lifecycle enforcement is a platform * primitive and defaults ON — objects without a `lifecycle` declaration are @@ -134,6 +144,7 @@ export class ObjectQLPlugin implements Plugin { /** Serializes reload-time schema syncs so overlapping reloads can't race DDL. */ private reloadSchemaSync: Promise = Promise.resolve(); private hydrateMetadataFromDb = false; + private registerProtocol = true; /** * Armed at the end of `start()` (AFTER the one-shot * {@link bridgeObjectsToMetadataService}, where that runs). From that @@ -176,9 +187,43 @@ export class ObjectQLPlugin implements Plugin { ? opts.skipSchemaSync : process.env.OS_SKIP_SCHEMA_SYNC === '1'; this.hydrateMetadataFromDb = opts.hydrateMetadataFromDb === true; + this.registerProtocol = opts.registerProtocol !== false; this.lifecycleOptions = opts.lifecycle; } + /** + * Arm the authored hook/action rebind on protocol metadata mutations + * (#2588, #2605). Shared by both assembly modes: called with the in-house + * shim when `registerProtocol` is on, and lazily from `start()` against + * whatever registered `protocol` (MetadataProtocolPlugin) otherwise. + */ + private subscribeMetadataRebind(ctx: PluginContext, protocol: unknown): void { + if (typeof (protocol as any)?.onMetadataMutation !== 'function') return; + const unsubscribe = (protocol as any).onMetadataMutation( + (evt: { type: string; name: string; state: string }) => { + 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); + } + init = async (ctx: PluginContext) => { if (!this.ql) { // Pass kernel logger to engine to avoid creating a separate logger instance @@ -217,6 +262,7 @@ export class ObjectQLPlugin implements Plugin { services: ['objectql', 'data', 'manifest'], }); + if (this.registerProtocol) { // Register the metadata-storage objects this engine's own protocol reads // and writes — `sys_metadata` (loadMetaFromDb / getMetaItems / saveMetaItem), // its history/audit siblings, and `sys_view_definition`. Doing it here @@ -267,31 +313,7 @@ export class ObjectQLPlugin implements Plugin { // 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?.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); - } + this.subscribeMetadataRebind(ctx, protocolShim); // Register an `analytics` service adapter that maps the dispatcher's // expected interface (query / getMeta / generateSql) onto the @@ -349,6 +371,9 @@ export class ObjectQLPlugin implements Plugin { message: 'Analytics SQL generation not implemented by ObjectQL adapter', }), }); + } else { + ctx.logger.info('registerProtocol=false — protocol assembly delegated to MetadataProtocolPlugin (ADR-0076 Step 2, #2462)'); + } // ADR-0057: the platform-owned LifecycleService. Registered from the // engine plugin (not an opt-in capability) so every kernel that has data @@ -375,6 +400,15 @@ export class ObjectQLPlugin implements Plugin { start = async (ctx: PluginContext) => { ctx.logger.info('ObjectQL engine starting...'); + // Delegated-assembly mode (ADR-0076 Step 2): the protocol was registered + // by MetadataProtocolPlugin during init — arm the authored hook/action + // rebind against it now that all inits ran. Graceful when absent. + if (!this.registerProtocol) { + try { + this.subscribeMetadataRebind(ctx, ctx.getService('protocol')); + } catch { /* no protocol registered — rebind not armed */ } + } + // Sync from external metadata service (e.g. MetadataPlugin) if available try { const metadataService = ctx.getService('metadata') as any;