Skip to content

Commit 20cb232

Browse files
os-zhuangclaude
andauthored
feat(metadata-protocol,objectql): MetadataProtocolPlugin + registerProtocol opt-out — ADR-0076 Step 2 PR-A (#2462) (#3578)
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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e295ad1 commit 20cb232

5 files changed

Lines changed: 287 additions & 25 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
"@objectstack/objectql": minor
4+
---
5+
6+
feat(metadata-protocol,objectql): MetadataProtocolPlugin + `registerProtocol` opt-out — ADR-0076 Step 2 PR-A (#2462)
7+
8+
`createMetadataProtocolPlugin()` now owns what `ObjectQLPlugin` historically
9+
assembled inline: the `ObjectStackProtocolImplementation` construction +
10+
`protocol` registration, the metadata-storage platform objects, and the D12
11+
`degraded` analytics fallback (pattern: plugin-security — named plugin,
12+
`dependencies` on the engine, `ctx.getService('objectql')`). `ObjectQLPlugin`
13+
grows `registerProtocol?: boolean` (default `true`, fully backward
14+
compatible): pass `false` when mounting the new plugin. Protocol CONSUMERS
15+
stay on the engine plugin either way — DB hydration and the authored
16+
hook/action rebind resolve `protocol` lazily (the rebind arms from `start()`
17+
in delegated mode) and degrade gracefully. Mixing both assemblies fails fast
18+
with the fix in the message. This is the additive first leg of the
19+
cross-repo sequence; cloud's 3 boot sites flip in PR-B, the built-in
20+
assembly + re-exports retire in PR-C.

packages/metadata-protocol/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
export { ObjectStackProtocolImplementation, ConcurrentUpdateError, normalizeViewMetadata } from './protocol.js';
4+
export { createMetadataProtocolPlugin } from './plugin.js';
5+
export type { MetadataProtocolPluginOptions } from './plugin.js';
46
export type { UninstallCleanup, UninstallCleanupOutcome } from './protocol.js';
57
export type { MetadataMutationEvent, MetadataMutationProjector, MutationProjectionOutcome } from './protocol.js';
68
export type { MetadataAuthoringGate, MetadataAuthoringGateContext } from './protocol.js';
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* MetadataProtocolPlugin — ADR-0076 Step 2 (#2462, cross-repo window).
5+
*
6+
* Owns what `ObjectQLPlugin` historically assembled inline: the
7+
* `ObjectStackProtocolImplementation` construction + `protocol` service
8+
* registration, the metadata-storage platform objects, and the lightweight
9+
* `analytics` fallback. Registering it NEXT TO `ObjectQLPlugin` (with
10+
* `registerProtocol: false` on the engine plugin) makes `@objectstack/objectql`
11+
* effectively protocol-free at boot-assembly level — the engine plugin keeps
12+
* only protocol CONSUMERS (DB hydration + authored hook/action rebind, both of
13+
* which resolve `getService('protocol')` lazily and degrade gracefully).
14+
*
15+
* Pattern follows plugin-security: named plugin + `dependencies` on the engine
16+
* + `ctx.getService('objectql')`.
17+
*
18+
* Assembly contract: exactly ONE of {this plugin, ObjectQLPlugin's built-in
19+
* assembly} may register `protocol` per kernel. `registerService` throws on
20+
* duplicates by design (see the kernel contract); this plugin turns that into
21+
* an actionable configuration message.
22+
*/
23+
24+
import type { Plugin, PluginContext } from '@objectstack/core';
25+
import { SERVICE_SELF_INFO_KEY, type ServiceSelfInfo } from '@objectstack/spec/api';
26+
import {
27+
SysMetadataObject,
28+
SysMetadataHistoryObject,
29+
SysMetadataCommitObject,
30+
SysMetadataAuditObject,
31+
SysViewDefinitionObject,
32+
} from '@objectstack/metadata-core';
33+
import { ObjectStackProtocolImplementation } from './protocol.js';
34+
35+
export interface MetadataProtocolPluginOptions {
36+
/**
37+
* Per-project scope (cloud per-env kernels). When set, `saveMetaItem`
38+
* stamps `environment_id` on new sys_metadata rows, `loadMetaFromDb`
39+
* filters by it, and the metadata-storage objects are NOT provisioned
40+
* locally (per-project kernels source metadata from the control plane).
41+
* Mirrors `ObjectQLPluginOptions.environmentId` — pass the same value.
42+
*/
43+
environmentId?: string;
44+
}
45+
46+
export function createMetadataProtocolPlugin(options: MetadataProtocolPluginOptions = {}): Plugin {
47+
const { environmentId } = options;
48+
return {
49+
name: 'com.objectstack.metadata.protocol',
50+
version: '1.0.0',
51+
dependencies: ['com.objectstack.engine.objectql'],
52+
53+
init: async (ctx: PluginContext) => {
54+
const ql: any = ctx.getService('objectql');
55+
56+
// Assembly-conflict guard: the engine plugin's built-in assembly
57+
// (registerProtocol !== false) already registered `protocol`.
58+
// Fail with the fix instead of the kernel's generic duplicate
59+
// throw so the boot author knows which knob to turn.
60+
let already: unknown;
61+
try { already = ctx.getService('protocol'); } catch { /* not registered — good */ }
62+
if (already) {
63+
throw new Error(
64+
'[MetadataProtocolPlugin] a `protocol` service is already registered — ' +
65+
'pass `registerProtocol: false` to ObjectQLPlugin when mounting MetadataProtocolPlugin (ADR-0076 Step 2).',
66+
);
67+
}
68+
69+
// Metadata-storage platform objects (sys_metadata + history/audit
70+
// siblings + sys_view_definition). Same `environmentId === undefined`
71+
// gate as the historical assembly: platform / standalone kernels own
72+
// their local sys_metadata; per-project (cloud) kernels source
73+
// metadata from the control plane and must NOT provision these
74+
// tables locally. registerApp is idempotent — a MetadataPlugin that
75+
// also registers them is harmless.
76+
if (environmentId === undefined) {
77+
ql.registerApp({
78+
id: 'com.objectstack.metadata-objects',
79+
name: 'Metadata Platform Objects',
80+
version: '1.0.0',
81+
type: 'plugin',
82+
scope: 'system',
83+
objects: [
84+
SysMetadataObject,
85+
SysMetadataHistoryObject,
86+
SysMetadataCommitObject,
87+
SysMetadataAuditObject,
88+
SysViewDefinitionObject,
89+
],
90+
});
91+
}
92+
93+
const protocolShim = new ObjectStackProtocolImplementation(
94+
ql,
95+
() => (ctx.getServices ? ctx.getServices() : new Map()),
96+
environmentId,
97+
);
98+
ctx.registerService('protocol', protocolShim);
99+
ctx.logger.info('Protocol service registered (MetadataProtocolPlugin)');
100+
101+
// Lightweight `analytics` fallback mapped onto the protocol shim's
102+
// `analyticsQuery` — kept with the protocol assembly so `/analytics`
103+
// keeps answering on installs without service-analytics. Honest
104+
// capabilities (ADR-0076 D12): self-identifies as `degraded`;
105+
// AnalyticsServicePlugin replaces it (ctx.replaceService) with the
106+
// real engine.
107+
ctx.registerService('analytics', {
108+
[SERVICE_SELF_INFO_KEY]: {
109+
status: 'degraded',
110+
handlerReady: true,
111+
message: 'Lightweight ObjectQL analytics fallback — install @objectstack/service-analytics for the full engine',
112+
} satisfies ServiceSelfInfo,
113+
// HttpDispatcher passes the raw POST body (AnalyticsQuery
114+
// shape); the shim's `analyticsQuery` expects the wrapped
115+
// `{ cube, query }` envelope and returns its own `{ success,
116+
// data }` — reshape in, unwrap out (one level), exactly as the
117+
// historical inline adapter did.
118+
query: async (body: any) => {
119+
const envelope = body && typeof body === 'object' && 'query' in body && 'cube' in body
120+
? body
121+
: { cube: body?.cube, query: body };
122+
const result = await protocolShim.analyticsQuery(envelope);
123+
if (result && typeof result === 'object' && 'success' in result && 'data' in result) {
124+
return (result as any).data;
125+
}
126+
return result;
127+
},
128+
getMeta: async () => ({
129+
cubes: [],
130+
message: 'Analytics meta endpoint not implemented by ObjectQL adapter',
131+
}),
132+
generateSql: async (_body: any) => ({
133+
sql: null,
134+
message: 'Analytics SQL generation not implemented by ObjectQL adapter',
135+
}),
136+
});
137+
},
138+
};
139+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0076 Step 2 (#2462) — delegated protocol assembly.
5+
*
6+
* `ObjectQLPlugin({ registerProtocol: false })` + `createMetadataProtocolPlugin()`
7+
* must produce the same service surface the built-in assembly does; mixing the
8+
* built-in assembly WITH the plugin must fail with the actionable message.
9+
* (This test lives in the objectql package: metadata-protocol must not depend
10+
* on objectql, even as a devDependency — turbo flags the cycle.)
11+
*/
12+
13+
import { describe, it, expect, beforeEach } from 'vitest';
14+
import { ObjectKernel } from '@objectstack/core';
15+
import { SERVICE_SELF_INFO_KEY } from '@objectstack/spec/api';
16+
import { createMetadataProtocolPlugin, ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
17+
import { ObjectQLPlugin } from './plugin';
18+
19+
describe('ADR-0076 Step 2 — delegated protocol assembly', () => {
20+
let kernel: ObjectKernel;
21+
22+
beforeEach(() => {
23+
kernel = new ObjectKernel({ logLevel: 'silent' });
24+
});
25+
26+
it('registerProtocol:false + MetadataProtocolPlugin reproduces the built-in service surface', async () => {
27+
await kernel.use(new ObjectQLPlugin({ registerProtocol: false }));
28+
await kernel.use(createMetadataProtocolPlugin());
29+
await kernel.bootstrap();
30+
31+
const protocol = kernel.getService('protocol');
32+
expect(protocol).toBeInstanceOf(ObjectStackProtocolImplementation);
33+
// Hydration compatibility: the engine plugin's start() consumes this via
34+
// getService('protocol') + the loadMetaFromDb type guard.
35+
expect(typeof (protocol as any).loadMetaFromDb).toBe('function');
36+
37+
// The lightweight analytics fallback rides with the protocol assembly and
38+
// keeps its D12 honest-capabilities self-descriptor.
39+
const analytics: any = kernel.getService('analytics');
40+
expect(analytics).toBeDefined();
41+
expect(analytics[SERVICE_SELF_INFO_KEY]?.status).toBe('degraded');
42+
expect(typeof analytics.query).toBe('function');
43+
});
44+
45+
it('registerProtocol:false WITHOUT the plugin boots protocol-free (consumers degrade)', async () => {
46+
await kernel.use(new ObjectQLPlugin({ registerProtocol: false }));
47+
await kernel.bootstrap();
48+
49+
expect(() => kernel.getService('protocol')).toThrow();
50+
expect(() => kernel.getService('analytics')).toThrow();
51+
// The engine itself is fully alive.
52+
expect(kernel.getService('objectql')).toBeDefined();
53+
});
54+
55+
it('built-in assembly + MetadataProtocolPlugin fails with the actionable configuration message', async () => {
56+
await kernel.use(new ObjectQLPlugin());
57+
await kernel.use(createMetadataProtocolPlugin());
58+
await expect(kernel.bootstrap()).rejects.toThrow(/registerProtocol: false/);
59+
});
60+
61+
it('default assembly is unchanged (backward compatibility)', async () => {
62+
await kernel.use(new ObjectQLPlugin());
63+
await kernel.bootstrap();
64+
expect(kernel.getService('protocol')).toBeInstanceOf(ObjectStackProtocolImplementation);
65+
expect((kernel.getService('analytics') as any)[SERVICE_SELF_INFO_KEY]?.status).toBe('degraded');
66+
});
67+
});

packages/objectql/src/plugin.ts

Lines changed: 59 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,16 @@ export interface ObjectQLPluginOptions {
101101
* Safe to leave unset: hydration tolerates a missing table.
102102
*/
103103
hydrateMetadataFromDb?: boolean;
104+
/**
105+
* ADR-0076 Step 2 (#2462): when `false`, this plugin SKIPS its built-in
106+
* protocol assembly (the `protocol` service, the metadata-storage platform
107+
* objects, and the lightweight `analytics` fallback) — mount
108+
* `createMetadataProtocolPlugin()` from `@objectstack/metadata-protocol`
109+
* alongside to own them instead. Protocol CONSUMERS stay here either way
110+
* (DB hydration + authored hook/action rebind resolve `protocol` lazily).
111+
* Defaults to `true` (built-in assembly, fully backward compatible).
112+
*/
113+
registerProtocol?: boolean;
104114
/**
105115
* ADR-0057 LifecycleService tuning. Lifecycle enforcement is a platform
106116
* primitive and defaults ON — objects without a `lifecycle` declaration are
@@ -134,6 +144,7 @@ export class ObjectQLPlugin implements Plugin {
134144
/** Serializes reload-time schema syncs so overlapping reloads can't race DDL. */
135145
private reloadSchemaSync: Promise<void> = Promise.resolve();
136146
private hydrateMetadataFromDb = false;
147+
private registerProtocol = true;
137148
/**
138149
* Armed at the end of `start()` (AFTER the one-shot
139150
* {@link bridgeObjectsToMetadataService}, where that runs). From that
@@ -176,9 +187,43 @@ export class ObjectQLPlugin implements Plugin {
176187
? opts.skipSchemaSync
177188
: process.env.OS_SKIP_SCHEMA_SYNC === '1';
178189
this.hydrateMetadataFromDb = opts.hydrateMetadataFromDb === true;
190+
this.registerProtocol = opts.registerProtocol !== false;
179191
this.lifecycleOptions = opts.lifecycle;
180192
}
181193

194+
/**
195+
* Arm the authored hook/action rebind on protocol metadata mutations
196+
* (#2588, #2605). Shared by both assembly modes: called with the in-house
197+
* shim when `registerProtocol` is on, and lazily from `start()` against
198+
* whatever registered `protocol` (MetadataProtocolPlugin) otherwise.
199+
*/
200+
private subscribeMetadataRebind(ctx: PluginContext, protocol: unknown): void {
201+
if (typeof (protocol as any)?.onMetadataMutation !== 'function') return;
202+
const unsubscribe = (protocol as any).onMetadataMutation(
203+
(evt: { type: string; name: string; state: string }) => {
204+
if (evt?.state === 'draft') return;
205+
if (evt?.type === 'hook') {
206+
void this.resyncAuthoredHooks(ctx).catch((e: any) => {
207+
ctx.logger.warn('[ObjectQLPlugin] authored-hook rebind after mutation failed', {
208+
hook: evt.name,
209+
error: e?.message,
210+
});
211+
});
212+
} else if (evt?.type === 'action' || evt?.type === 'object') {
213+
// `object` rows carry embedded `actions[]`, so an object edit can
214+
// add/remove an authored action too — re-sync on both.
215+
void this.resyncAuthoredActions(ctx).catch((e: any) => {
216+
ctx.logger.warn('[ObjectQLPlugin] authored-action rebind after mutation failed', {
217+
item: evt.name,
218+
error: e?.message,
219+
});
220+
});
221+
}
222+
},
223+
);
224+
this.metadataUnsubscribes.push(unsubscribe);
225+
}
226+
182227
init = async (ctx: PluginContext) => {
183228
if (!this.ql) {
184229
// Pass kernel logger to engine to avoid creating a separate logger instance
@@ -217,6 +262,7 @@ export class ObjectQLPlugin implements Plugin {
217262
services: ['objectql', 'data', 'manifest'],
218263
});
219264

265+
if (this.registerProtocol) {
220266
// Register the metadata-storage objects this engine's own protocol reads
221267
// and writes — `sys_metadata` (loadMetaFromDb / getMetaItems / saveMetaItem),
222268
// its history/audit siblings, and `sys_view_definition`. Doing it here
@@ -267,31 +313,7 @@ export class ObjectQLPlugin implements Plugin {
267313
// without a restart. Draft saves are skipped — drafts are not live by
268314
// design. Fire-and-forget: a resync failure is logged, never fails the
269315
// write.
270-
if (typeof (protocolShim as any).onMetadataMutation === 'function') {
271-
const unsubscribe = (protocolShim as any).onMetadataMutation(
272-
(evt: { type: string; name: string; state: string }) => {
273-
if (evt?.state === 'draft') return;
274-
if (evt?.type === 'hook') {
275-
void this.resyncAuthoredHooks(ctx).catch((e: any) => {
276-
ctx.logger.warn('[ObjectQLPlugin] authored-hook rebind after mutation failed', {
277-
hook: evt.name,
278-
error: e?.message,
279-
});
280-
});
281-
} else if (evt?.type === 'action' || evt?.type === 'object') {
282-
// `object` rows carry embedded `actions[]`, so an object edit can
283-
// add/remove an authored action too — re-sync on both.
284-
void this.resyncAuthoredActions(ctx).catch((e: any) => {
285-
ctx.logger.warn('[ObjectQLPlugin] authored-action rebind after mutation failed', {
286-
item: evt.name,
287-
error: e?.message,
288-
});
289-
});
290-
}
291-
},
292-
);
293-
this.metadataUnsubscribes.push(unsubscribe);
294-
}
316+
this.subscribeMetadataRebind(ctx, protocolShim);
295317

296318
// Register an `analytics` service adapter that maps the dispatcher's
297319
// expected interface (query / getMeta / generateSql) onto the
@@ -349,6 +371,9 @@ export class ObjectQLPlugin implements Plugin {
349371
message: 'Analytics SQL generation not implemented by ObjectQL adapter',
350372
}),
351373
});
374+
} else {
375+
ctx.logger.info('registerProtocol=false — protocol assembly delegated to MetadataProtocolPlugin (ADR-0076 Step 2, #2462)');
376+
}
352377

353378
// ADR-0057: the platform-owned LifecycleService. Registered from the
354379
// engine plugin (not an opt-in capability) so every kernel that has data
@@ -375,6 +400,15 @@ export class ObjectQLPlugin implements Plugin {
375400
start = async (ctx: PluginContext) => {
376401
ctx.logger.info('ObjectQL engine starting...');
377402

403+
// Delegated-assembly mode (ADR-0076 Step 2): the protocol was registered
404+
// by MetadataProtocolPlugin during init — arm the authored hook/action
405+
// rebind against it now that all inits ran. Graceful when absent.
406+
if (!this.registerProtocol) {
407+
try {
408+
this.subscribeMetadataRebind(ctx, ctx.getService('protocol'));
409+
} catch { /* no protocol registered — rebind not armed */ }
410+
}
411+
378412
// Sync from external metadata service (e.g. MetadataPlugin) if available
379413
try {
380414
const metadataService = ctx.getService('metadata') as any;

0 commit comments

Comments
 (0)