Skip to content

Commit 577b075

Browse files
committed
fix(objectql): list-but-not-serve the residual objects whose registration would refuse the boot
Registering a residual object as a body's object inherits two boot refusals the residual rule never raised: a name another package already owns (the registry's ownership refusal) and a field naming a picklist only the residual declares (the kernel:ready picklist audit; the residual's lists are metadata only). Both were measured refusing the boot. Such an object is not registered; the boot names it once, with the reason and the remedy. The residual rule's comments in @objectstack/metadata now name the engine half. Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
1 parent 39ef9df commit 577b075

3 files changed

Lines changed: 225 additions & 72 deletions

File tree

‎packages/metadata/src/plugin.ts‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -247,15 +247,24 @@ import type { IHttpServer } from '@objectstack/spec/contracts';
247247
// `objectstack.json` (`_parseAndRegisterArtifact` below);
248248
// - the CONFIG door — `os serve objectstack.config.ts`, whose `AppPlugin`
249249
// hands the stack to the `manifest` service, which registers the package
250-
// bodies and never the top level (`resolveArtifactPackageOrder` answers
251-
// `packages[]` alone when the key is present). Before this rule had a
250+
// bodies (`resolveArtifactPackageOrder` answers `packages[]` alone when
251+
// the key is present) and, of the top level, only the residual's objects
252+
// (the engine half, below). Before this rule had a
252253
// second caller, a config boot served such an item through no door and
253254
// said nothing, while an artifact boot of the same project served it
254255
// under `manifest.id` and warned.
255256
//
256257
// Both call {@link MetadataPlugin.registerUnclaimedTopLevel}, so which items
257258
// are residual, which id they take, how each is registered and what the boot
258259
// says are decided here and nowhere else. ⛔ Never a second copy at a door.
260+
//
261+
// The ENGINE half reads the same answer. Both doors hand the whole stack to
262+
// the `manifest` service (`@objectstack/objectql`), which registers the bodies
263+
// and then, from {@link unclaimedTopLevel}, the residual's OBJECTS under the
264+
// same id, so the data door serves the objects this rule lists. An object it
265+
// cannot register without refusing the boot (a name another package already
266+
// owns, a field naming a picklist no body declares) stays listed here and
267+
// unserved there, and the engine names it at boot.
259268

260269
/**
261270
* Where one door's registrations land. The artifact door writes its in-memory
@@ -366,7 +375,8 @@ export interface UnclaimedTopLevel {
366375
* residual rule, with nothing registered and nothing thrown.
367376
*
368377
* {@link MetadataPlugin.registerUnclaimedTopLevel} registers exactly these, in
369-
* this order, through either boot door. `os validate` / `os build`
378+
* this order, through either boot door, and the engine's `manifest` service
379+
* registers the objects among them under the same id. `os validate` / `os build`
370380
* (`@objectstack/cli`'s view-container walk) read the same answer to judge what
371381
* that registration will judge, so an author-time door and a boot door cannot
372382
* disagree about which items are residual. ⛔ Never re-derived by a caller.
@@ -1410,8 +1420,9 @@ export class MetadataPlugin implements Plugin {
14101420
*
14111421
* Called by this plugin's artifact door on a compiled `objectstack.json`,
14121422
* and by `AppPlugin` (`@objectstack/runtime`) on a config boot, whose
1413-
* `manifest` service registers the package bodies and never the top level
1414-
* of a stack that carries `packages[]`. The two doors differ only in where
1423+
* `manifest` service registers the package bodies and, of the top level of
1424+
* a stack that carries `packages[]`, only the residual's objects (from
1425+
* {@link unclaimedTopLevel}, this rule's own answer). The two doors differ only in where
14151426
* their `sink` stores and in the words of the line; the decision — which
14161427
* items, keyed how, owned by whom — is this method's.
14171428
*

‎packages/objectql/src/manifest-service-residual-objects.test.ts‎

Lines changed: 139 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,26 @@
1717
* two-boot reading is `packages/cli/test/serve-config-boot-residual.integration.test.ts`
1818
* (row 4); these pins hold the engine half on a kernel.
1919
*
20-
* ## What the shape of each pin is for
20+
* ## What each pin is for
2121
*
2222
* - The residual object is RESOLVED by the registry and OWNED by the stack's
2323
* manifest id — what the data door reads.
2424
* - CONTROL: the bodies' objects keep their own owners, and no package record
2525
* appears for the residual's id. The residual is not a package; when its id
2626
* names a body (a composed stack keeps one member's manifest) that body's
2727
* record must stay the body's.
28-
* - A late artifact (after `start()`) reaches the metadata service too, and is
29-
* judged against the sealed picklist vocabulary like a body.
28+
* - A late artifact (after `start()`) reaches the metadata service too.
29+
* - The two classes whose registration would REFUSE a boot the residual rule
30+
* accepts — a name another package owns, a field naming a picklist no body
31+
* declares — are listed and not served, and the boot says so, naming each.
32+
* Both were measured refusing the boot when registered.
3033
*
3134
* Payloads are handed over the way `AppPlugin` hands them:
3235
* `{ ...bundle.manifest, ...bundle }`.
3336
*/
3437

3538
import { describe, it, expect, afterEach } from 'vitest';
36-
import { ObjectKernel } from '@objectstack/core';
39+
import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core';
3740
import type { IMetadataService } from '@objectstack/spec/contracts';
3841
import { ObjectQLPlugin } from './plugin.js';
3942
import type { ObjectQL } from './engine.js';
@@ -45,23 +48,34 @@ const engineOf = (kernel: ObjectKernel): ObjectQL => kernel.getService<ObjectQL>
4548
const APP_ID = 'com.example.acme';
4649
const SERVICE_ID = 'com.example.acme.service';
4750
const RELEASE_ID = 'com.example.acme.release';
51+
const OTHER_ID = 'com.example.other';
4852

4953
const caseObj = () => ({ name: 'acme_case', label: 'Case', sharingModel: 'private', fields: { subject: { type: 'text', label: 'Subject' } } });
5054
const accountObj = () => ({ name: 'acme_account', label: 'Account', sharingModel: 'private', fields: { name: { type: 'text', label: 'Name' } } });
5155
const noteObj = (fields: Record<string, unknown> = { title: { type: 'text', label: 'Title' } }) =>
5256
({ name: 'acme_note', label: 'Note', sharingModel: 'private', fields });
57+
const statusList = () => ({ name: 'acme_status', label: 'Status', options: [{ label: 'Open', value: 'open' }] });
58+
const statusField = { status: { name: 'status', type: 'select', label: 'Status', picklist: 'acme_status' } };
5359
const svc = { id: SERVICE_ID, name: 'service', namespace: 'acme', version: '2.4.0', type: 'module' };
5460
const app = { id: APP_ID, name: 'acme', namespace: 'acme', version: '1.0.0', type: 'app' };
5561

5662
/** What `AppPlugin.init` hands the `manifest` service. */
5763
const payload = (bundle: Record<string, unknown>) => ({ ...(bundle.manifest as object), ...bundle });
5864

59-
/** Two packages, a `manifest.id` naming neither, and a top-level `acme_note` no body declares. */
60-
const residualStack = (note = noteObj()) => payload({
65+
/**
66+
* Two packages, a `manifest.id` naming neither, and a top-level `acme_note` no
67+
* body declares. `extra` lands on the top level; `serviceExtra` on the service body.
68+
*/
69+
const residualStack = (
70+
note = noteObj(),
71+
extra: Record<string, unknown> = {},
72+
serviceExtra: Record<string, unknown> = {},
73+
) => payload({
6174
manifest: { ...app, id: RELEASE_ID },
6275
objects: [caseObj(), accountObj(), note],
76+
...extra,
6377
packages: [
64-
{ manifest: { ...svc, objects: [caseObj()] } },
78+
{ manifest: { ...svc, objects: [caseObj()], ...serviceExtra } },
6579
{ manifest: { ...app, objects: [accountObj()] } },
6680
],
6781
});
@@ -75,27 +89,68 @@ const acmePackageIds = (ql: ObjectQL): string[] =>
7589
ql.registry
7690
.getAllPackages()
7791
.map((p) => p.manifest?.id)
78-
.filter((id: unknown): id is string => typeof id === 'string' && id.startsWith('com.example.'));
92+
.filter((id: unknown): id is string => typeof id === 'string' && id.startsWith('com.example.acme'));
7993

80-
let kernel: ObjectKernel | undefined;
94+
/**
95+
* The kernel hands every plugin ONE logger, so a plugin that inits first can
96+
* read what the engine says on it. Wrapping in place rather than replacing:
97+
* the engine's own calls still reach the real (silent) logger.
98+
*/
99+
class WarnRecorder implements Plugin {
100+
name = 'test.residual-objects.warn-recorder';
101+
version = '1.0.0';
102+
readonly warnings: string[] = [];
103+
init = async (ctx: PluginContext): Promise<void> => {
104+
const logger = ctx.logger as { warn: (message: string, ...rest: unknown[]) => void };
105+
const real = logger.warn.bind(logger);
106+
logger.warn = (message: string, ...rest: unknown[]) => {
107+
this.warnings.push(String(message));
108+
real(message, ...rest);
109+
};
110+
};
111+
}
81112

82-
async function bootKernel(): Promise<ObjectKernel> {
83-
kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
113+
/** Registers `artifacts` through the `manifest` service in `init()` — a boot-time registration, as `AppPlugin` makes. */
114+
function registering(name: string, artifacts: unknown[], after: string[] = []): Plugin {
115+
return {
116+
name,
117+
dependencies: ['com.objectstack.engine.objectql', ...after],
118+
init: async (ctx: PluginContext) => {
119+
const manifest = ctx.getService<ManifestService>('manifest');
120+
for (const artifact of artifacts) await manifest.register(artifact);
121+
},
122+
} as Plugin;
123+
}
124+
125+
const kernels: ObjectKernel[] = [];
126+
127+
/** Boot a kernel whose plugins register `plugins`' artifacts at boot; `recorder` sees every warning. */
128+
async function boot(...plugins: Plugin[]): Promise<{ kernel: ObjectKernel; recorder: WarnRecorder }> {
129+
const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
130+
kernels.push(kernel);
131+
const recorder = new WarnRecorder();
132+
await kernel.use(recorder);
84133
await kernel.use(new ObjectQLPlugin());
134+
for (const plugin of plugins) await kernel.use(plugin);
85135
await kernel.bootstrap();
86-
return kernel;
136+
return { kernel, recorder };
87137
}
88138

139+
/** The engine's line naming residual objects it lists and does not serve. */
140+
const unservedLines = (recorder: WarnRecorder): string[] =>
141+
recorder.warnings.filter((w) => w.includes('NOT served by the data door'));
142+
89143
afterEach(async () => {
90-
if (kernel && kernel.getState() === 'running') await kernel.shutdown();
91-
kernel = undefined;
144+
while (kernels.length) {
145+
const kernel = kernels.pop()!;
146+
if (kernel.getState() === 'running') await kernel.shutdown();
147+
}
92148
});
93149

94150
describe('ADR-0130 D4 — the manifest service registers the residual\'s objects', () => {
95151
it('registers a residual object under the stack\'s manifest id, beside the bodies\' own', async () => {
96-
const k = await bootKernel();
97-
await (k.getService('manifest') as ManifestService).register(residualStack());
98-
const ql = engineOf(k);
152+
const { kernel, recorder } = await boot(registering('app', [residualStack()]));
153+
const ql = engineOf(kernel);
99154

100155
// Before: `acme_note` resolved to nothing — the data door's 404.
101156
expect(ql.registry.resolveObject('acme_note')?.fields?.title).toBeDefined();
@@ -105,25 +160,24 @@ describe('ADR-0130 D4 — the manifest service registers the residual\'s objects
105160
acme_note: RELEASE_ID,
106161
});
107162
expect((ql.registry.resolveObject('acme_note') as { _packageId?: string } | undefined)?._packageId).toBe(RELEASE_ID);
163+
expect(unservedLines(recorder)).toEqual([]);
108164
});
109165

110166
it('installs no package record for the residual\'s id — the residual is not a package', async () => {
111-
const k = await bootKernel();
112-
await (k.getService('manifest') as ManifestService).register(residualStack());
113-
expect(acmePackageIds(engineOf(k)).sort()).toEqual([APP_ID, SERVICE_ID]);
167+
const { kernel } = await boot(registering('app', [residualStack()]));
168+
expect(acmePackageIds(engineOf(kernel)).sort()).toEqual([APP_ID, SERVICE_ID]);
114169
});
115170

116171
it('a composed stack whose manifest names a body: the residual takes that id, and the body keeps its own record', async () => {
117-
const k = await bootKernel();
118-
await (k.getService('manifest') as ManifestService).register(payload({
172+
const { kernel } = await boot(registering('app', [payload({
119173
manifest: app,
120174
objects: [caseObj(), accountObj(), noteObj()],
121175
packages: [
122176
{ manifest: { ...svc, objects: [caseObj()] } },
123177
{ manifest: { ...app, objects: [accountObj()] } },
124178
],
125-
}));
126-
const ql = engineOf(k);
179+
})]));
180+
const ql = engineOf(kernel);
127181

128182
expect(owners(ql)).toEqual({ acme_account: APP_ID, acme_case: SERVICE_ID, acme_note: APP_ID });
129183
// The app body's record is the app body, not the residual: its own
@@ -134,58 +188,90 @@ describe('ADR-0130 D4 — the manifest service registers the residual\'s objects
134188
});
135189

136190
it('CONTROL: a normally composed stack (the top level repeats every body) registers nothing beyond the bodies', async () => {
137-
const k = await bootKernel();
138-
await (k.getService('manifest') as ManifestService).register(payload({
191+
const { kernel } = await boot(registering('app', [payload({
139192
manifest: { ...app, id: RELEASE_ID },
140193
objects: [caseObj(), accountObj()],
141194
packages: [
142195
{ manifest: { ...svc, objects: [caseObj()] } },
143196
{ manifest: { ...app, objects: [accountObj()] } },
144197
],
145-
}));
146-
const ql = engineOf(k);
198+
})]));
199+
const ql = engineOf(kernel);
147200
expect(owners(ql)).toEqual({ acme_account: APP_ID, acme_case: SERVICE_ID, acme_note: undefined });
148201
expect(acmePackageIds(ql).sort()).toEqual([APP_ID, SERVICE_ID]);
149202
});
150203

151204
it('`packages: []` beside a top-level object registers the object under the manifest id', async () => {
152-
const k = await bootKernel();
153-
await (k.getService('manifest') as ManifestService).register(payload({
205+
const { kernel } = await boot(registering('app', [payload({
154206
manifest: { ...app, id: RELEASE_ID },
155207
objects: [noteObj()],
156208
packages: [],
157-
}));
158-
const ql = engineOf(k);
209+
})]));
210+
const ql = engineOf(kernel);
159211
expect(ql.registry.getObjectOwner('acme_note')?.packageId).toBe(RELEASE_ID);
160212
expect(acmePackageIds(ql)).toEqual([]);
161213
});
214+
215+
it('a residual object whose field names a picklist a BODY declares is served with that list', async () => {
216+
const { kernel } = await boot(registering('app', [
217+
residualStack(noteObj(statusField), {}, { picklists: [statusList()] }),
218+
]));
219+
const ql = engineOf(kernel);
220+
expect(ql.registry.getObjectOwner('acme_note')?.packageId).toBe(RELEASE_ID);
221+
const options = (ql.registry.getObject('acme_note')?.fields?.status as { options?: Array<{ value: unknown }> } | undefined)?.options;
222+
expect(options?.map((o) => o.value)).toEqual(['open']);
223+
});
224+
});
225+
226+
describe('ADR-0130 D4 — the residual objects the engine lists and does not serve', () => {
227+
it('an object whose name another package owns: the boot still boots, that package keeps it, and the boot names it', async () => {
228+
const other = {
229+
id: OTHER_ID, name: 'other', version: '1.0.0', type: 'app',
230+
objects: [{ name: 'acme_note', label: 'Other note', fields: { body: { type: 'text', label: 'Body' } } }],
231+
};
232+
// Registered as an object this stack does not own would be: by another
233+
// package, earlier in the same boot.
234+
const { kernel, recorder } = await boot(
235+
registering('other', [other]),
236+
registering('app', [residualStack()], ['other']),
237+
);
238+
const ql = engineOf(kernel);
239+
240+
// Registering it would have refused the boot with OBJECT_OWNERSHIP_CONFLICT.
241+
expect(owners(ql)).toEqual({ acme_account: APP_ID, acme_case: SERVICE_ID, acme_note: OTHER_ID });
242+
expect(ql.registry.resolveObject('acme_note')?.fields?.body).toBeDefined();
243+
const lines = unservedLines(recorder);
244+
expect(lines).toHaveLength(1);
245+
expect(lines[0]).toContain(`stack '${RELEASE_ID}'`);
246+
expect(lines[0]).toContain(`'acme_note' (package '${OTHER_ID}' already owns that name)`);
247+
});
248+
249+
it('an object whose field names a picklist only the residual declares: the boot still boots, and the boot names it', async () => {
250+
const { kernel, recorder } = await boot(registering('app', [
251+
residualStack(noteObj(statusField), { picklists: [statusList()] }),
252+
]));
253+
const ql = engineOf(kernel);
254+
255+
// Registering it would have refused the boot at `kernel:ready` (INVALID_METADATA,
256+
// the picklist audit): the residual's own list is metadata only.
257+
expect(kernel.getState()).toBe('running');
258+
expect(owners(ql)).toEqual({ acme_account: APP_ID, acme_case: SERVICE_ID, acme_note: undefined });
259+
const lines = unservedLines(recorder);
260+
expect(lines).toHaveLength(1);
261+
expect(lines[0]).toContain(
262+
"'acme_note' (field 'status' names picklist 'acme_status', which no package body declares)",
263+
);
264+
});
162265
});
163266

164267
describe('ADR-0130 D4 — a residual arriving after start()', () => {
165268
it('reaches the metadata service under the stack\'s manifest id, as a body\'s objects do', async () => {
166-
const k = await bootKernel();
167-
await (k.getService('manifest') as ManifestService).register(residualStack());
269+
const { kernel } = await boot();
270+
await (kernel.getService('manifest') as ManifestService).register(residualStack());
168271

169-
const metadata = k.getService<IMetadataService>('metadata');
272+
expect(engineOf(kernel).registry.getObjectOwner('acme_note')?.packageId).toBe(RELEASE_ID);
273+
const metadata = kernel.getService<IMetadataService>('metadata');
170274
const bridged = await metadata.getObject('acme_note') as { _packageId?: string } | undefined;
171275
expect(bridged?._packageId).toBe(RELEASE_ID);
172276
});
173-
174-
it('is judged against the sealed picklist vocabulary like a body, and a refusal registers nothing', async () => {
175-
const k = await bootKernel();
176-
const note = noteObj({ status: { type: 'select', label: 'Status', picklist: 'acme_no_such_list' } });
177-
178-
let caught: (Error & { code?: string; status?: number }) | undefined;
179-
try {
180-
await (k.getService('manifest') as ManifestService).register(residualStack(note));
181-
} catch (e) {
182-
caught = e as Error & { code?: string; status?: number };
183-
}
184-
185-
expect(caught?.code).toBe('INVALID_METADATA');
186-
expect(caught?.status).toBe(422);
187-
expect(caught?.message).toContain('acme_no_such_list');
188-
const ql = engineOf(k);
189-
expect(owners(ql)).toEqual({ acme_account: undefined, acme_case: undefined, acme_note: undefined });
190-
});
191277
});

0 commit comments

Comments
 (0)