Skip to content

Commit 39ef9df

Browse files
committed
fix(objectql): the manifest service registers the residual's objects under the stack's manifest id
A multi-package stack's top-level objects that no package body declares are registered by the metadata door under manifest.id, while the manifest service read the bodies only, so /meta/object listed them and /data/<name> answered 404 through both boot doors. The service now registers the objects of the residual rule's own answer (unclaimedTopLevel) after the bodies, under that id, with no package record of their own. Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
1 parent 77b8611 commit 39ef9df

2 files changed

Lines changed: 253 additions & 6 deletions

File tree

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0130 D4 — the engine's `manifest` service registers the OBJECTS of a
5+
* multi-package stack's residual: top-level objects no package body declares,
6+
* under the id the residual rule names (`unclaimedTopLevel`,
7+
* `@objectstack/metadata`).
8+
*
9+
* ## The defect these pin
10+
*
11+
* The metadata door registers a stack's residual under the stack's own
12+
* `manifest.id` and the boot warns that every door will report that id as
13+
* the items' owner. The `manifest` service read the package BODIES only
14+
* (`resolveArtifactPackageOrder` answers `packages[]` when the key is
15+
* present), so a residual object never reached the SchemaRegistry: listed by
16+
* `GET /meta/object`, `404` on `/data/<name>`, through both boot doors. The
17+
* two-boot reading is `packages/cli/test/serve-config-boot-residual.integration.test.ts`
18+
* (row 4); these pins hold the engine half on a kernel.
19+
*
20+
* ## What the shape of each pin is for
21+
*
22+
* - The residual object is RESOLVED by the registry and OWNED by the stack's
23+
* manifest id — what the data door reads.
24+
* - CONTROL: the bodies' objects keep their own owners, and no package record
25+
* appears for the residual's id. The residual is not a package; when its id
26+
* names a body (a composed stack keeps one member's manifest) that body's
27+
* 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.
30+
*
31+
* Payloads are handed over the way `AppPlugin` hands them:
32+
* `{ ...bundle.manifest, ...bundle }`.
33+
*/
34+
35+
import { describe, it, expect, afterEach } from 'vitest';
36+
import { ObjectKernel } from '@objectstack/core';
37+
import type { IMetadataService } from '@objectstack/spec/contracts';
38+
import { ObjectQLPlugin } from './plugin.js';
39+
import type { ObjectQL } from './engine.js';
40+
41+
type ManifestService = { register(m: unknown): void | Promise<void> };
42+
43+
const engineOf = (kernel: ObjectKernel): ObjectQL => kernel.getService<ObjectQL>('objectql');
44+
45+
const APP_ID = 'com.example.acme';
46+
const SERVICE_ID = 'com.example.acme.service';
47+
const RELEASE_ID = 'com.example.acme.release';
48+
49+
const caseObj = () => ({ name: 'acme_case', label: 'Case', sharingModel: 'private', fields: { subject: { type: 'text', label: 'Subject' } } });
50+
const accountObj = () => ({ name: 'acme_account', label: 'Account', sharingModel: 'private', fields: { name: { type: 'text', label: 'Name' } } });
51+
const noteObj = (fields: Record<string, unknown> = { title: { type: 'text', label: 'Title' } }) =>
52+
({ name: 'acme_note', label: 'Note', sharingModel: 'private', fields });
53+
const svc = { id: SERVICE_ID, name: 'service', namespace: 'acme', version: '2.4.0', type: 'module' };
54+
const app = { id: APP_ID, name: 'acme', namespace: 'acme', version: '1.0.0', type: 'app' };
55+
56+
/** What `AppPlugin.init` hands the `manifest` service. */
57+
const payload = (bundle: Record<string, unknown>) => ({ ...(bundle.manifest as object), ...bundle });
58+
59+
/** Two packages, a `manifest.id` naming neither, and a top-level `acme_note` no body declares. */
60+
const residualStack = (note = noteObj()) => payload({
61+
manifest: { ...app, id: RELEASE_ID },
62+
objects: [caseObj(), accountObj(), note],
63+
packages: [
64+
{ manifest: { ...svc, objects: [caseObj()] } },
65+
{ manifest: { ...app, objects: [accountObj()] } },
66+
],
67+
});
68+
69+
const owners = (ql: ObjectQL): Record<string, string | undefined> =>
70+
Object.fromEntries(
71+
['acme_account', 'acme_case', 'acme_note'].map((name) => [name, ql.registry.getObjectOwner(name)?.packageId]),
72+
);
73+
74+
const acmePackageIds = (ql: ObjectQL): string[] =>
75+
ql.registry
76+
.getAllPackages()
77+
.map((p) => p.manifest?.id)
78+
.filter((id: unknown): id is string => typeof id === 'string' && id.startsWith('com.example.'));
79+
80+
let kernel: ObjectKernel | undefined;
81+
82+
async function bootKernel(): Promise<ObjectKernel> {
83+
kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
84+
await kernel.use(new ObjectQLPlugin());
85+
await kernel.bootstrap();
86+
return kernel;
87+
}
88+
89+
afterEach(async () => {
90+
if (kernel && kernel.getState() === 'running') await kernel.shutdown();
91+
kernel = undefined;
92+
});
93+
94+
describe('ADR-0130 D4 — the manifest service registers the residual\'s objects', () => {
95+
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);
99+
100+
// Before: `acme_note` resolved to nothing — the data door's 404.
101+
expect(ql.registry.resolveObject('acme_note')?.fields?.title).toBeDefined();
102+
expect(owners(ql)).toEqual({
103+
acme_account: APP_ID,
104+
acme_case: SERVICE_ID,
105+
acme_note: RELEASE_ID,
106+
});
107+
expect((ql.registry.resolveObject('acme_note') as { _packageId?: string } | undefined)?._packageId).toBe(RELEASE_ID);
108+
});
109+
110+
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]);
114+
});
115+
116+
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({
119+
manifest: app,
120+
objects: [caseObj(), accountObj(), noteObj()],
121+
packages: [
122+
{ manifest: { ...svc, objects: [caseObj()] } },
123+
{ manifest: { ...app, objects: [accountObj()] } },
124+
],
125+
}));
126+
const ql = engineOf(k);
127+
128+
expect(owners(ql)).toEqual({ acme_account: APP_ID, acme_case: SERVICE_ID, acme_note: APP_ID });
129+
// The app body's record is the app body, not the residual: its own
130+
// objects, still exactly one.
131+
const record = ql.registry.getAllPackages().find((p) => p.manifest?.id === APP_ID);
132+
expect((record?.manifest as { objects?: Array<{ name?: string }> } | undefined)?.objects?.map((o) => o.name))
133+
.toEqual(['acme_account']);
134+
});
135+
136+
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({
139+
manifest: { ...app, id: RELEASE_ID },
140+
objects: [caseObj(), accountObj()],
141+
packages: [
142+
{ manifest: { ...svc, objects: [caseObj()] } },
143+
{ manifest: { ...app, objects: [accountObj()] } },
144+
],
145+
}));
146+
const ql = engineOf(k);
147+
expect(owners(ql)).toEqual({ acme_account: APP_ID, acme_case: SERVICE_ID, acme_note: undefined });
148+
expect(acmePackageIds(ql).sort()).toEqual([APP_ID, SERVICE_ID]);
149+
});
150+
151+
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({
154+
manifest: { ...app, id: RELEASE_ID },
155+
objects: [noteObj()],
156+
packages: [],
157+
}));
158+
const ql = engineOf(k);
159+
expect(ql.registry.getObjectOwner('acme_note')?.packageId).toBe(RELEASE_ID);
160+
expect(acmePackageIds(ql)).toEqual([]);
161+
});
162+
});
163+
164+
describe('ADR-0130 D4 — a residual arriving after start()', () => {
165+
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());
168+
169+
const metadata = k.getService<IMetadataService>('metadata');
170+
const bridged = await metadata.getObject('acme_note') as { _packageId?: string } | undefined;
171+
expect(bridged?._packageId).toBe(RELEASE_ID);
172+
});
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+
});
191+
});

‎packages/objectql/src/plugin.ts‎

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import { assembleMetadataProtocol } from '@objectstack/metadata-protocol';
55
import type { MetadataAuthoringChannel } from '@objectstack/metadata-protocol';
66
import { Plugin, PluginContext } from '@objectstack/core';
77
import { resolveArtifactPackageOrder, artifactPackageId, readDeploymentOrgScopingEntitlement } from '@objectstack/core';
8+
// [ADR-0130 D4] The residual rule's decision half — which top-level items of a
9+
// multi-package stack no body declares, and the id they take. See
10+
// `unclaimedTopLevelObjects` below.
11+
import { unclaimedTopLevel } from '@objectstack/metadata';
812
import { applyConversionsToStoredItem } from '@objectstack/spec';
913
import { StorageNameMapping } from '@objectstack/spec/system';
1014
// [#21777] The ONE "is this schema the remote's?" predicate, shared with `ObjectQL.syncSchemas`.
@@ -77,6 +81,35 @@ function hasLoadMetaFromDb(service: unknown): service is ProtocolWithDbRestore {
7781
);
7882
}
7983

84+
/**
85+
* The OBJECTS of a multi-package stack's residual, and the id they are owned
86+
* by — the residual rule's own answer (ADR-0130 D4), read through
87+
* `unclaimedTopLevel` (`@objectstack/metadata`), the one copy both boot doors
88+
* and `os validate` read. ⛔ Never re-derived here: which top-level items are
89+
* residual and which id they take is that function's decision, and a second
90+
* derivation would let this door and the metadata door disagree about it.
91+
*
92+
* Why the engine needs them: the `manifest` service registers the BODIES of a
93+
* stack that carries `packages[]` and never its top level, while the metadata
94+
* door registers the residual under the stack's `manifest.id` and the boot
95+
* says every door reports that id as their owner. Without this, a residual
96+
* object was listed by `GET /meta/object` and answered `404` on
97+
* `/data/<name>`, through both boot doors.
98+
*
99+
* @returns `undefined` when the stack has no residual object — every stack
100+
* without `packages[]`, and every normally composed one.
101+
*/
102+
function unclaimedTopLevelObjects(
103+
stack: unknown,
104+
): { ownerId: string | undefined; objects: ServiceObject[] } | undefined {
105+
const residual = unclaimedTopLevel(stack);
106+
if (!residual) return undefined;
107+
const objects = residual.items
108+
.filter((entry) => entry.type === 'object')
109+
.map((entry) => entry.item as ServiceObject);
110+
return objects.length > 0 ? { ownerId: residual.ownerId, objects } : undefined;
111+
}
112+
80113
/**
81114
* [ADR-0131 D6] A `sys_metadata` row stored in a legacy organization's layer:
82115
* no metadata read serves one, so the authored hook and action binders skip it
@@ -561,8 +594,12 @@ export class ObjectQLPlugin implements Plugin {
561594
// same one register followed by the same one bridge as before (D7).
562595
register: (artifact: any) => {
563596
const ordered = resolveArtifactPackageOrder(artifact) as any[];
597+
// [ADR-0130 D4] The objects of the stack's RESIDUAL — top-level objects
598+
// no package body declares — as the residual rule answers it. See
599+
// {@link unclaimedTopLevelObjects}.
600+
const residual = unclaimedTopLevelObjects(artifact);
564601

565-
if (ordered.length === 0) {
602+
if (ordered.length === 0 && residual === undefined) {
566603
// An artifact that declared `packages: []` registers nothing. Said out
567604
// loud rather than returning quietly: "the install did nothing" is not
568605
// a state anyone should have to infer from an absence.
@@ -599,9 +636,11 @@ export class ObjectQLPlugin implements Plugin {
599636
const declared = new Set<string>();
600637
for (const manifest of ordered) for (const name of collectManifestPicklistNames(manifest)) declared.add(name);
601638
const known = (name: string) => declared.has(name) || ql.registry.resolvePicklistOptions(name) !== undefined;
602-
const unresolved = ordered
603-
.flatMap((manifest) => collectManifestPicklistReferences(manifest, artifactPackageId(manifest)))
604-
.filter((ref) => !known(ref.picklist));
639+
const unresolved = [
640+
...ordered.flatMap((manifest) => collectManifestPicklistReferences(manifest, artifactPackageId(manifest))),
641+
// The residual's objects register below, so they are judged here too.
642+
...(residual ? collectManifestPicklistReferences({ objects: residual.objects }, residual.ownerId) : []),
643+
].filter((ref) => !known(ref.picklist));
605644
const orphans = ordered
606645
.flatMap((manifest) => collectManifestPicklistExtensions(manifest, artifactPackageId(manifest)))
607646
.filter((ext) => !known(ext.picklist));
@@ -615,15 +654,32 @@ export class ObjectQLPlugin implements Plugin {
615654
id: manifest.id || manifest.name
616655
});
617656
}
657+
// [ADR-0130 D4] The residual's objects, AFTER every body — the order
658+
// the metadata door registers the same stack in — under the id the
659+
// residual rule names, through the registry verb a body's own objects
660+
// take (`registerApp` step 2), so its refusals are a body's refusals.
661+
// There is no package record for that id: the residual is not a
662+
// package, and when the id names one of the bodies (a composed stack
663+
// keeps one member's manifest) that body's record must not be replaced.
664+
if (residual) {
665+
for (const object of residual.objects) {
666+
ql.registry.registerObject(object, residual.ownerId, undefined, 'own');
667+
}
668+
}
618669
// Manifests registered AFTER start() (marketplace install / ledger
619670
// rehydrate arrive on `kernel:ready` or an HTTP request) land in the
620671
// SchemaRegistry only — the one-shot startup bridge already ran — so
621672
// bridge every registered package's objects into the metadata service now.
622673
// No-op until start() arms it, so boot-time registrations keep the
623674
// single startup bridge. The promise never rejects; async callers
624675
// (marketplace install) await it so metadata reads right after
625-
// install are deterministic, sync callers may ignore it.
626-
return this.bridgeArtifactObjectsToMetadataService(ctx, ordered);
676+
// install are deterministic, sync callers may ignore it. The residual's
677+
// owner is bridged with them, so a late artifact's residual objects
678+
// reach the metadata door as well as the data door.
679+
const bridged = residual?.ownerId !== undefined && !scope.packageIds.includes(residual.ownerId)
680+
? [...ordered, { id: residual.ownerId }]
681+
: ordered;
682+
return this.bridgeArtifactObjectsToMetadataService(ctx, bridged);
627683
}
628684
});
629685

0 commit comments

Comments
 (0)