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
20 changes: 20 additions & 0 deletions .changeset/step2-metadata-protocol-plugin.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/metadata-protocol/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
139 changes: 139 additions & 0 deletions packages/metadata-protocol/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -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',
}),
});
},
};
}
67 changes: 67 additions & 0 deletions packages/objectql/src/plugin.step2.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
84 changes: 59 additions & 25 deletions packages/objectql/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -134,6 +144,7 @@ export class ObjectQLPlugin implements Plugin {
/** Serializes reload-time schema syncs so overlapping reloads can't race DDL. */
private reloadSchemaSync: Promise<void> = 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down