Skip to content

Commit 70e5d61

Browse files
committed
fix(runtime): register the package record without the bundle's runtime instances so the package read doors can serialize it (#14442)
The payload AppPlugin hands to `manifest.register()` is stored raw as the package record's manifest, and a booted kernel plugin instance in the stack's `plugins[]` holds the engine — a cycle. Every door that serializes a package row (both `GET /packages` doors, `getMetaItems({type:'package'})`, the `sys_packages` persist) answered 500 for stacks that declare plugin instances. Drop only the instantiated kernel plugins from the registration payload, keeping every declarative member; return the payload by reference when there is nothing to strip. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
1 parent e5812fa commit 70e5d61

3 files changed

Lines changed: 365 additions & 3 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
fix(runtime): register the package record without the bundle's runtime instances so the package read doors can serialize it (#14442)
6+
7+
`AppPlugin.init` flattens its bundle into `{ ...bundle.manifest, ...bundle }`
8+
and hands that to `manifest.register()`; `SchemaRegistry.installPackage` stores
9+
it RAW as the package record's `manifest`. A code-defined stack's `plugins:
10+
[new ConnectorOpenApiPlugin(), …]` are instantiated kernel plugins, and a
11+
booted one holds the engine — which refers back to itself. The stored record
12+
therefore contained a cycle, and every door that serves a package row runs it
13+
through `JSON.stringify`, so all four answered
14+
`Converting circular structure to JSON`:
15+
16+
- `GET /api/v1/packages` (dispatcher list) — HTTP 500
17+
- `GET /api/v1/packages/:id` (dispatcher detail) — HTTP 500
18+
- `GET /api/v1/meta/package` — HTTP 500
19+
- `POST /api/v1/packages/:id/duplicate` — its `sys_packages` persist threw
20+
21+
Studio's package picker reads the list door, so it was wholly unavailable on
22+
any app whose stack declares plugin instances (`examples/app-showcase`). Purely
23+
declarative apps (`examples/app-todo`, `hotcrm`) were never affected.
24+
25+
The registration payload now drops the instantiated kernel plugins from
26+
`plugins[]` and nothing else. The discriminator is the kernel's own —
27+
`Plugin.init` is a required member of the `Plugin` interface, the same
28+
predicate `@objectstack/cli`'s `isHostConfig` already uses to answer this
29+
question about this array. Manifest-shaped nested plugins are kept, so the
30+
metadata they contribute (`ObjectQL.registerApp` → `registerPlugin`) still
31+
registers under the parent package's ownership; `datasources` are declarative
32+
(`DatasourceSchema`) and are untouched, so the external-datasource index and
33+
the ADR-0015 write gate are unchanged. A payload with nothing to strip is
34+
returned by reference, so every stack without plugin instances reaches the
35+
registry as the exact same object as before and the ADR-0130 D7 single-manifest
36+
bit-identity contract is untouched.
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #14442 — a stack that declares plugin INSTANCES still produces a package
5+
* record every read door can serialize.
6+
*
7+
* ## The defect
8+
*
9+
* `AppPlugin.init` flattens its bundle into `{ ...bundle.manifest, ...bundle }`
10+
* and hands that to `manifest.register()`; `SchemaRegistry.installPackage`
11+
* stores it RAW as the package record's `manifest`. A code-defined stack's
12+
* `plugins: [new ConnectorOpenApiPlugin(), …]` are instantiated kernel plugins,
13+
* and a booted one holds the engine (`ctx.getService('objectql')`), which
14+
* refers back to itself — so the stored record contained a CYCLE. Every door
15+
* that serves a package row runs it through `JSON.stringify`:
16+
*
17+
* GET /api/v1/packages (dispatcher list) → HTTP 500
18+
* GET /api/v1/packages/:id (dispatcher detail) → HTTP 500
19+
* GET /api/v1/meta/package (getMetaItems) → HTTP 500
20+
* POST /api/v1/packages/:id/duplicate → sys_packages persist FAILED
21+
*
22+
* all answering `Converting circular structure to JSON`. Studio's package
23+
* picker reads the list door, so it was wholly unavailable on any app with
24+
* plugin instances (`examples/app-showcase`), while a purely declarative one
25+
* (`examples/app-todo`) was unaffected.
26+
*
27+
* ## What these pins are shaped to catch
28+
*
29+
* The whole risk of the fix is over-removal, so the discriminating assertion is
30+
* a COMPARISON against a control kernel booted with the same bundle minus the
31+
* instances. Everything else about the record — including a manifest-shaped
32+
* nested plugin, whose objects the engine registers under the parent package's
33+
* ownership (`ObjectQL.registerApp` step 7 → `registerPlugin`) — must be
34+
* byte-identical between the two. A fix that dropped `plugins` wholesale would
35+
* pass "it serializes" and fail here, which is exactly the point: that fix
36+
* would silently retire nested-plugin registration.
37+
*
38+
* The registry is REAL (`ObjectQLPlugin` on a real kernel) and so is the door
39+
* body (`handlePackagesRequest` via `HttpDispatcher.handlePackages`, and
40+
* `ObjectStackProtocolImplementation.getMetaItems`) — no `vi.fn` stands in for
41+
* either. A double for the registry cannot see this defect at all: the defect
42+
* is what the real registry stores.
43+
*
44+
* ## Ablation, direction predicted before running
45+
*
46+
* Restore the raw spread in `app-plugin.ts`
47+
* (`const servicePayload = this.bundle.manifest ? { ...this.bundle.manifest,
48+
* ...this.bundle } : this.bundle;`) and the serialization pins go RED with
49+
* `Converting circular structure to JSON` — the production envelope verbatim.
50+
* The control-kernel pins that assert the DECLARATIVE half stay green: the raw
51+
* spread never touched those keys.
52+
*/
53+
54+
import { describe, it, expect } from 'vitest';
55+
import { LiteKernel } from '@objectstack/core';
56+
import type { Plugin, PluginContext } from '@objectstack/core';
57+
import { ObjectQLPlugin } from '@objectstack/objectql';
58+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
59+
60+
import { AppPlugin, withoutRuntimePluginInstances } from './app-plugin.js';
61+
import { HttpDispatcher } from './http-dispatcher.js';
62+
63+
const PACKAGE_ID = 'com.example.instances';
64+
65+
/**
66+
* A kernel plugin that does at boot what every real connector plugin does:
67+
* resolve the engine and keep it. That reference IS the cycle — the engine
68+
* refers back to itself (`_ObjectQL.actionActivation.store.engine`, the
69+
* constructor chain the production envelope named).
70+
*/
71+
class EngineHoldingPlugin implements Plugin {
72+
name = 'com.example.engine-holding';
73+
/** Set at init — the reference the record must not end up carrying. */
74+
engine?: unknown;
75+
init(ctx: PluginContext): void {
76+
this.engine = ctx.getService('objectql');
77+
}
78+
}
79+
80+
/**
81+
* A manifest-shaped nested plugin — NOT an instance. The engine reads this one
82+
* as metadata and registers its objects under the parent package, so it must
83+
* survive into the record untouched.
84+
*/
85+
const nestedDeclarativePlugin = () => ({
86+
name: 'com.example.nested',
87+
namespace: 'pins',
88+
objects: [
89+
{
90+
name: 'pins_nested_note',
91+
label: 'Nested Note',
92+
fields: { title: { type: 'text', label: 'Title' } },
93+
},
94+
],
95+
});
96+
97+
/** The declarative half of the stack — identical on both kernels. */
98+
const declarativeStack = () => ({
99+
manifest: {
100+
id: PACKAGE_ID,
101+
name: 'instances_pin',
102+
version: '1.0.0',
103+
type: 'app',
104+
namespace: 'pins',
105+
},
106+
objects: [
107+
{
108+
name: 'pins_account',
109+
label: 'Account',
110+
fields: { name: { type: 'text', label: 'Name' } },
111+
},
112+
],
113+
});
114+
115+
interface PackageRecord {
116+
manifest?: Record<string, unknown>;
117+
status?: string;
118+
enabled?: boolean;
119+
}
120+
interface TestRegistry {
121+
getPackage(id: string): PackageRecord | undefined;
122+
getAllPackages(): PackageRecord[];
123+
getAllObjects(): Array<{ name: string }>;
124+
listItems(type: string): unknown[];
125+
}
126+
type QlService = { registry: TestRegistry };
127+
128+
/**
129+
* Boot the composition production runs: the engine, the runtime plugin
130+
* instances the CLI wires with `kernel.use()`, and an AppPlugin holding the
131+
* SAME instances inside its bundle — which is how they reached the registry.
132+
*/
133+
async function boot(pluginEntries: unknown[]): Promise<{ kernel: LiteKernel; ql: QlService }> {
134+
const kernel = new LiteKernel({ logger: { level: 'error' } });
135+
kernel.use(new ObjectQLPlugin({}));
136+
for (const entry of pluginEntries) {
137+
if (typeof (entry as { init?: unknown })?.init === 'function') kernel.use(entry as Plugin);
138+
}
139+
kernel.use(new AppPlugin({ ...declarativeStack(), plugins: pluginEntries }));
140+
await kernel.bootstrap();
141+
return { kernel, ql: kernel.getService<QlService>('objectql') };
142+
}
143+
144+
/** A kernel façade over the REAL objectql service, as `HttpDispatcher` reads it. */
145+
const dispatcherOver = (ql: QlService): HttpDispatcher =>
146+
new HttpDispatcher({
147+
context: { getService: (name: string) => (name === 'objectql' ? ql : null) },
148+
} as never);
149+
150+
/** Engine self-invocation — passes the domain's anonymous floor and read gate. */
151+
const systemCaller = () => ({ request: {}, environmentId: 'platform', executionContext: { isSystem: true } }) as never;
152+
153+
describe('#14442 — the package record of an instance-bearing stack is serializable', () => {
154+
it('stores a record `JSON.stringify` accepts, and serves it through both read doors', async () => {
155+
const instance = new EngineHoldingPlugin();
156+
const { kernel, ql } = await boot([instance, nestedDeclarativePlugin()]);
157+
try {
158+
// The instance really did capture the engine — without this the
159+
// pins below would be vacuous (nothing cyclic to strip).
160+
expect(instance.engine).toBeDefined();
161+
expect(() => JSON.stringify(instance)).toThrow(/circular/i);
162+
163+
const record = ql.registry.getPackage(PACKAGE_ID);
164+
expect(record).toBeDefined();
165+
166+
// Pin 1 — the record itself.
167+
expect(() => JSON.stringify(record)).not.toThrow();
168+
expect(record?.manifest?.plugins).toEqual([nestedDeclarativePlugin()]);
169+
170+
// Pin 2a — the dispatcher list and detail doors.
171+
const dispatcher = dispatcherOver(ql);
172+
const list = await dispatcher.handlePackages('/', 'GET', undefined, {}, systemCaller());
173+
expect(list.response?.status).toBe(200);
174+
expect(() => JSON.stringify(list.response)).not.toThrow();
175+
const rows = (list.response?.body as { data?: { packages?: Array<{ manifest?: { id?: string } }> } })
176+
?.data?.packages ?? (list.response?.body as { packages?: Array<{ manifest?: { id?: string } }> })?.packages;
177+
expect(rows?.some((r) => r?.manifest?.id === PACKAGE_ID)).toBe(true);
178+
179+
const detail = await dispatcher.handlePackages(`/${PACKAGE_ID}`, 'GET', undefined, {}, systemCaller());
180+
expect(detail.response?.status).toBe(200);
181+
expect(() => JSON.stringify(detail.response)).not.toThrow();
182+
183+
// Pin 2b — the REST `/meta/package` door's primitive.
184+
const protocol = new ObjectStackProtocolImplementation(ql as never);
185+
const meta = await protocol.getMetaItems({ type: 'package' });
186+
expect(() => JSON.stringify(meta)).not.toThrow();
187+
const metaItems = (meta as { items?: Array<{ manifest?: { id?: string } }> })?.items
188+
?? (meta as unknown as Array<{ manifest?: { id?: string } }>);
189+
expect(metaItems.some((i) => i?.manifest?.id === PACKAGE_ID)).toBe(true);
190+
} finally {
191+
await kernel.shutdown?.();
192+
}
193+
});
194+
195+
it('leaves every declarative key byte-identical to a stack that never had the instances', async () => {
196+
const withInstances = await boot([new EngineHoldingPlugin(), nestedDeclarativePlugin()]);
197+
const control = await boot([nestedDeclarativePlugin()]);
198+
try {
199+
const a = withInstances.ql.registry.getPackage(PACKAGE_ID);
200+
const b = control.ql.registry.getPackage(PACKAGE_ID);
201+
202+
// The whole manifest, byte for byte — key order included. This is
203+
// what makes over-removal (dropping `plugins` outright, or dropping
204+
// a declarative sibling) a failure rather than a silent pass.
205+
expect(JSON.stringify(a?.manifest)).toBe(JSON.stringify(b?.manifest));
206+
expect(a?.status).toBe(b?.status);
207+
expect(a?.enabled).toBe(b?.enabled);
208+
209+
// …and the nested plugin's objects reached the registry on BOTH
210+
// sides: the registration the instances rode alongside is intact.
211+
const fqns = (k: typeof withInstances) => k.ql.registry.getAllObjects().map((o) => o.name).sort();
212+
expect(fqns(withInstances)).toEqual(fqns(control));
213+
expect(fqns(withInstances).some((n) => n.includes('nested_note'))).toBe(true);
214+
} finally {
215+
await withInstances.kernel.shutdown?.();
216+
await control.kernel.shutdown?.();
217+
}
218+
});
219+
});
220+
221+
describe('withoutRuntimePluginInstances — the predicate, stated directly', () => {
222+
it('returns the payload BY REFERENCE when there is nothing to strip', () => {
223+
const noPlugins = { id: 'a' };
224+
expect(withoutRuntimePluginInstances(noPlugins)).toBe(noPlugins);
225+
226+
const declarativeOnly = { id: 'a', plugins: [{ name: 'nested' }, 'pkg-by-name'] };
227+
expect(withoutRuntimePluginInstances(declarativeOnly)).toBe(declarativeOnly);
228+
229+
// Not an array (a map-shaped or malformed `plugins`) is left alone too
230+
// — this function narrows a record, it does not normalize one.
231+
const notAnArray = { id: 'a', plugins: { nested: {} } };
232+
expect(withoutRuntimePluginInstances(notAnArray)).toBe(notAnArray);
233+
});
234+
235+
it('keeps declarative members and their order while removing the instances', () => {
236+
const nested = { name: 'nested' };
237+
const payload = {
238+
id: 'a',
239+
plugins: [new EngineHoldingPlugin(), nested, 'pkg-by-name', new EngineHoldingPlugin()],
240+
objects: [],
241+
};
242+
const out = withoutRuntimePluginInstances(payload);
243+
expect(out).not.toBe(payload);
244+
expect(out.plugins).toEqual([nested, 'pkg-by-name']);
245+
// The authored key keeps its position, so the record's key order — and
246+
// therefore its serialization — is unchanged for every other key.
247+
expect(Object.keys(out)).toEqual(Object.keys(payload));
248+
// The input is not mutated: the caller's bundle is still the kernel's.
249+
expect(payload.plugins).toHaveLength(4);
250+
});
251+
});

‎packages/runtime/src/app-plugin.ts‎

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,73 @@ export interface AppPluginProjectContext {
4949
source?: 'package' | 'user';
5050
}
5151

52+
/**
53+
* Is this `plugins[]` entry an instantiated kernel Plugin rather than
54+
* declarative metadata?
55+
*
56+
* `plugins:` is a deliberately mixed collection — its own schema documents it
57+
* as "a Manifest object, a package name string, or a Runtime Plugin instance"
58+
* (`ObjectStackDefinitionSchema.plugins`, `z.array(z.unknown())`). The two
59+
* kinds are consumed by different halves of the platform: the CLI hands the
60+
* INSTANCES to `kernel.use()` as runtime wiring, while the engine walks the
61+
* manifest-shaped entries as nested metadata (`ObjectQL.registerApp` step 7 →
62+
* `registerPlugin`).
63+
*
64+
* The discriminator is the kernel's own: `Plugin.init` is a REQUIRED member of
65+
* the `Plugin` interface (`@objectstack/core`), so every instantiated plugin
66+
* has it and no declarative manifest does. It is also the exact predicate the
67+
* CLI already uses to answer this same question about the same array
68+
* (`isHostConfig`, `@objectstack/cli`) — one spelling, so the two cannot come
69+
* to different conclusions about one entry.
70+
*/
71+
function isRuntimePluginInstance(entry: unknown): boolean {
72+
return typeof (entry as { init?: unknown } | null | undefined)?.init === 'function';
73+
}
74+
75+
/**
76+
* Drop the instantiated kernel plugins from a registration payload's
77+
* `plugins[]`, leaving every declarative member untouched.
78+
*
79+
* ## The failure this closes (#14442)
80+
*
81+
* The payload handed to `manifest.register()` is stored RAW as the package
82+
* record's `manifest` (`SchemaRegistry.installPackage`), and every door that
83+
* serves a package row runs it through `JSON.stringify`: the two
84+
* `GET /api/v1/packages` doors (list and detail), `getMetaItems({type:
85+
* 'package'})`, and `service-package`'s `sys_packages` persist. A booted
86+
* runtime plugin holds the engine (`ctx.getService('objectql')`), the engine
87+
* refers back to itself, and the record therefore contained a CYCLE — so all
88+
* four doors answered HTTP 500 "Converting circular structure to JSON" for any
89+
* stack that declares plugin instances (`examples/app-showcase`), while a
90+
* purely declarative stack (`examples/app-todo`) was unaffected. Studio's
91+
* package picker reads the list door, so its package list was wholly
92+
* unavailable on those apps.
93+
*
94+
* ## Why filtering rather than dropping the key
95+
*
96+
* A runtime instance contributes NOTHING to the record: it is not metadata, it
97+
* is the wiring the kernel already holds. A manifest-shaped entry does — the
98+
* engine registers its objects, views and the rest under the parent package's
99+
* ownership — so dropping `plugins` wholesale would silently retire that
100+
* registration for every package that ships metadata through a nested plugin.
101+
* Only the members the record cannot carry are removed.
102+
*
103+
* ## Identity when there is nothing to strip
104+
*
105+
* A payload with no `plugins[]`, or one whose entries are all declarative, is
106+
* returned BY REFERENCE — not a copy. Every stack without plugin instances
107+
* (`app-todo`, `hotcrm`, every JSON artifact) therefore reaches the registry as
108+
* the exact same object it reached it as before, which is what keeps the
109+
* ADR-0130 D7 single-manifest bit-identity contract untouched by this change.
110+
*/
111+
export function withoutRuntimePluginInstances<T>(payload: T): T {
112+
const plugins = (payload as { plugins?: unknown } | null | undefined)?.plugins;
113+
if (!Array.isArray(plugins)) return payload;
114+
const declarative = plugins.filter((entry) => !isRuntimePluginInstance(entry));
115+
if (declarative.length === plugins.length) return payload;
116+
return { ...(payload as object), plugins: declarative } as T;
117+
}
118+
52119
/**
53120
* AppPlugin
54121
*
@@ -245,9 +312,17 @@ export class AppPlugin implements Plugin {
245312

246313
// Register the app manifest directly via the manifest service.
247314
// This immediately decomposes the manifest into SchemaRegistry entries.
248-
const servicePayload = this.bundle.manifest
249-
? { ...this.bundle.manifest, ...this.bundle }
250-
: this.bundle;
315+
//
316+
// `withoutRuntimePluginInstances` is what keeps the resulting package
317+
// record SERIALIZABLE — see its own doc comment for the failure it
318+
// closes. It is applied to the flattened payload rather than to the
319+
// bundle so both envelope shapes (nested `manifest`, bare manifest)
320+
// go through it.
321+
const servicePayload = withoutRuntimePluginInstances(
322+
this.bundle.manifest
323+
? { ...this.bundle.manifest, ...this.bundle }
324+
: this.bundle,
325+
);
251326

252327
ctx.getService<{ register(m: any): void }>('manifest').register(servicePayload);
253328
}

0 commit comments

Comments
 (0)