Skip to content

Commit 87cd458

Browse files
fix(objectql): the data door serves a residual top-level object under the stack's manifest id (#22701)
Fixes #22615 Clause-②: no ## What was wrong A stack that carries `packages[]` registers each package body under that package's id. A top-level object that no body declares is the stack's residual. The metadata door registered the residual under the stack's `manifest.id`, and the boot warned that "every door will report that id as their owner". The engine's `manifest` service registered the bodies only (`resolveArtifactPackageOrder` answers `packages[]` when the key is present). So `GET /api/v1/meta/object` listed the residual object `acme_note` under `manifest.id`, while `GET /api/v1/data/acme_note` answered `404`. Both boot doors behaved this way: the artifact boot and the config boot (`os serve objectstack.config.ts`). ## What changes - **`packages/objectql/src/plugin.ts`**: the `manifest` service now registers the residual's objects. It reads them from `unclaimedTopLevel` (`@objectstack/metadata`), which is the residual rule's own answer. The metadata door, the config door and `os validate` already read that same function, so there is no second rule and no second list. The objects are registered: - after the bodies, in the order the metadata door uses; - under the stack's `manifest.id`; - through `registry.registerObject`, the same call a body's objects go through in `registerApp`. No package record is created for that id, because the residual is not a package. When the id names a body (a composed stack keeps one member's manifest), that body's record is unchanged. When a late artifact (after `start()`) carries a residual, its objects are copied to the metadata service along with the bodies' objects. - **Two classes stay listed and are not served.** Registering either one would make a boot fail where the stack boots today. Both were measured failing on a kernel, at `39ef9df4`, before this handling existed: - **An object whose name another package already owns.** The registry refuses a second owner (`OBJECT_OWNERSHIP_CONFLICT`, 422). The data door keeps serving that other package's object. - **An object with a field that names a picklist no package body declares.** The boot's picklist audit at `kernel:ready` fails (`INVALID_METADATA`, 422), because the stack's top-level picklists are registered as metadata only. This class is decided from the stack's bodies, never from the registry, which is still filling at this point. The engine prints one warning naming each such object and the reason. The remedy it gives is the residual warning's own: declare the object inside the `packages[]` entry of the package that owns it, together with every picklist its fields name. To add fields to another package's object, use `objectExtensions`. Any other registry refusal, such as a field with no valid `type`, still fails the boot, as it does for a body. - **`packages/metadata/src/plugin.ts`**: comments only. The residual rule's header and docblocks now describe the engine half. The residual warning text is unchanged byte for byte. - **`.changeset/22615-objectql-residual-objects-served.md`**: `@objectstack/objectql` patch. ## Pins - **`packages/cli/test/serve-config-boot-residual.integration.test.ts`** (an existing two-boot harness, `domain:cli`, test file only). Every `acme_*` object that `/meta/object` lists is now read back on `/data/NAME`, through both boots. - Row 4: the residual object answers `200` on both boots. - Controls: the bodies' own objects answer `200` on every row, and so does a single-package stack. - The header paragraph saying this file does not pin the data door is replaced. - **`packages/objectql/src/manifest-service-residual-objects.test.ts`** (9 cases) covers: - the residual object resolves and is owned by `manifest.id`; - no package record is created for that id; - when `manifest.id` names a body, the residual takes that id and the body's record keeps its own objects; - a normally composed stack registers nothing extra; - `packages: []` with a top-level object; - a residual object whose picklist a body declares is served with that list's options; - the two unserved classes: the boot completes, the other package keeps the name, and the warning names each object; - a late artifact's residual reaches the metadata service. ## Measured - **Premise on `origin/main`.** Run at `77b8611b`, which is `cb3bb933` plus the pin commit only, through `--project integration`, rows 4 and control. Row 4's new case failed: `Expected ... "acme_note:200" / Received ... "acme_note:404"` on the artifact boot. The config-boot reading was equal to it (`toEqual(artifact)` passed), so both boots answered `404`. Result: 1 failed, 4 passed, 9 skipped. - **Fixed.** The full file at `577b0757`: `Tests 14 passed (14)`. After merging `main`, at `e7fba19b`: `Tests 14 passed (14)`. - **Ablation** (`node scripts/ablation-replace.mjs`). The anchor was `if (residual) this.registerUnclaimedTopLevelObjects(ctx, residual, ordered);` and it was replaced by a marker. The anchor count went 1 to 0 and the blob changed from `7010e04df780` to `6abbdd6b25e2`. Result: `Tests 7 failed | 2 passed (9)`. The 2 that passed are the two controls (no package record, normally composed). The file was then restored: blob equal to HEAD and `git diff HEAD` empty. The unit file imports `./plugin.js` from source, so this mutation needed no rebuild. - **Load closure.** `@objectstack/objectql`'s root entry now imports `@objectstack/metadata`'s root entry, which adds `chokidar`, `glob`, `js-yaml` and `readdirp` to what `import('@objectstack/objectql')` loads. Measured: 208 modules before, 214 after. `@objectstack/objectql/core` is unchanged: `pnpm check:lean-entry-closure` holds its admitted set of 15 packages, and its 6 denied names are absent. `unclaimedTopLevel` is published only from that root entry. A lean subpath for it would be a new entry export, so it is not part of this PR. ## Acceptance notes - `packages/runtime/src/app-plugin.ts` (`registerUnclaimedTopLevel`'s docblock, about lines 787 to 799) still says the `manifest` service "registers the package bodies and never the top level". After this PR it also registers the residual's objects. This is a comment in the `domain:cli` lane, left untouched here (carrier: none). - A residual object's owner id has no package record. So `GET /api/v1/packages` does not list a `manifest.id` that names no body, and the object routes to the default datasource rather than to a `defaultDatasource` on the stack manifest. Not measured on a boot. - Before this PR, `ingestReloadedObjects` already registered every reloaded object on `metadata:reloaded`, residual ones included. So a `--dev` boot answered `404` before its first reload and served the object after it. From boot onward the answer is now the same. This was read from the code, not measured on a reload. - Ordering: another package that registers an object with the same name LATER in the same boot (for example a marketplace rehydrate at `kernel:ready`) now meets the registry's ownership refusal, as it would against a body-declared object. Not measured. - The residual's other item types (views, docs, picklists, actions and the rest) are still metadata only in the engine registry, as they were before. Not measured on any door. ## Gates Everything below was read at `e7fba19b`, the head of this PR, unless another commit is named. - **Gate list.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 66 commands for this diff. That includes all 51 named in the dispatch, plus 15 more that the changeset and the test files bring in. All 66 were run and every one exited 0. The `--ran` reconciliation reports 66 derived, 66 run, 0 NOT-MEASURED and 0 UNRUN. - **Two first runs measured nothing and were re-run:** - `node scripts/check-engine-split-ratio.mjs --days 90` exited 2 because the clone was shallow. After `git fetch --shallow-since=2026-07-05`, it exited 0. - `pnpm check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET) because 9 packages were unbuilt. After `turbo run build --filter='./packages/**'`, it exited 0. - **Also run:** `pnpm check:startup-registry-verdict`, exit 0. - **Package checks:** - `@objectstack/objectql`: `typecheck` passed, and the full vitest run passed `Test Files 396 passed (396)`, `Tests 7767 passed (7767)`. - `@objectstack/metadata`: `typecheck` passed at `577b0757` (the merge did not touch it), and vitest passed `58 passed (58)` files, `871 passed (871)` tests. - `@objectstack/cli` integration: the file above, `14 passed (14)`. The `packages/cli` change is an integration-tier test file, so only that tier was run here. - **ESLint.** `eslint --no-inline-config --format json` over the 4 changed TypeScript files counted 4 files, 0 errors and 0 warnings. `eslint.config.mjs` never turns on type-aware linting (no `parserOptions.project`), so this diff cannot change the result for any file it does not touch. The repo-wide `pnpm lint` is left to CI. - **NUL bytes.** `pnpm check:nul-bytes` exited 0. --- _Generated by [Claude Code](https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 26aa999 commit 87cd458

5 files changed

Lines changed: 455 additions & 15 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
A top-level object that no package body of a multi-package stack declares is now served by the data door, under the stack's `manifest.id`, the id the metadata door already lists it under
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** A stack that carries `packages[]` registers each package body under that package's id. A top-level object that no body declares is the stack's residual. The metadata door registered it under the stack's `manifest.id` and the boot warned that every door would report that id as its owner. The engine's `manifest` service registered the bodies only. So `GET /api/v1/meta/object` listed the object while `GET /api/v1/data/<name>` answered `404`, on the artifact boot (`os serve` of a built artifact) and on the config boot (`os serve objectstack.config.ts`) alike.
10+
- **What changes.** After the bodies, the `manifest` service registers the residual's objects under the stack's `manifest.id`. It reads which objects are residual from `unclaimedTopLevel` (`@objectstack/metadata`), the same answer the metadata door and `os validate` read. No package record is created for that id: the residual is not a package. When the id names one of the bodies (a composed stack keeps one member's manifest), that body's record is unchanged. A residual object that arrives after boot, with a late artifact, is copied to the metadata service as a body's objects are.
11+
- **Listed and not served, by name.** Registering two classes of residual object would refuse a boot that the stack passes today, so they are not registered. They stay listed by the metadata door and unserved by the data door, and the boot prints one warning naming each object and why:
12+
- an object whose name another package already owns: the data door keeps serving that package's object;
13+
- an object with a field that names a picklist no package body declares: the stack's top-level picklists are metadata only, so the boot's picklist check would fail.
14+
15+
The remedy in the warning is the residual warning's own: declare the object inside the `packages[]` entry of the package that owns it, with every picklist its fields name. To add fields to an object another package owns, use `objectExtensions`.
16+
- **Unchanged.** A stack without `packages[]`, and a normally composed one whose top level repeats its bodies, has no residual and registers exactly as before.

‎packages/cli/test/serve-config-boot-residual.integration.test.ts‎

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,14 @@
4040
* - CONTROL: a single-package config (no `packages[]`) is unchanged and has
4141
* no residual on either door.
4242
*
43-
* ⚠️ What this file does NOT pin: the data door for a residual OBJECT. Both
44-
* doors list it on `/meta/object` and answer `404` on `/data/<name>` — the
45-
* residual rule registers metadata, and no engine registration follows. That
46-
* is the artifact door's answer this card makes the config boot match, not a
47-
* behaviour to freeze.
43+
* The DATA door is read beside the metadata door (#22615): every `acme_*`
44+
* object `/meta/object` lists is read back on `/data/<name>`. A residual object
45+
* is listed under the stack's `manifest.id` AND served under it — the engine's
46+
* `manifest` service registers the residual rule's own objects
47+
* (`unclaimedTopLevel`), so both doors give one answer through both boots.
48+
* Before that, both boots listed `acme_note` and answered `404` on
49+
* `/data/acme_note`. The bodies' own objects are the control: `200` before and
50+
* after, on every row.
4851
*
4952
* ⚠️ Pedigree, not counts: every doc page carries a marker written into exactly
5053
* one source, read back from the served doc.
@@ -325,6 +328,8 @@ interface Reading {
325328
/** `name@owner`, sorted. */
326329
objects: string[];
327330
views: string[];
331+
/** `name:status` of `GET /data/<name>` for every object `objects` lists, sorted. */
332+
data: string[];
328333
/** The count each residual line reports — one entry per line printed. */
329334
residual: number[];
330335
}
@@ -364,14 +369,23 @@ async function read(dir: string, args: string[]): Promise<Reading> {
364369
marker: /MARKER-22521-[\w-]+/.exec(JSON.stringify(one.body))?.[0],
365370
};
366371
}
367-
const objects = owned(await list('object'));
372+
const listedObjects = await list('object');
373+
const objects = owned(listedObjects);
368374
const views = owned(await list('view'));
375+
// The data door, for exactly the objects the metadata door listed: one
376+
// answer per object across the two doors (#22615).
377+
const data: string[] = [];
378+
for (const item of listedObjects) {
379+
const rows = await request(serve, `the data read of ${item.name}`, `${base}/data/${item.name}`, { headers });
380+
data.push(`${item.name}:${rows.status}`);
381+
}
382+
data.sort();
369383
const residual = serve.output()
370384
.split('\n')
371385
.map((line) => RESIDUAL_LINE.exec(line)?.[1])
372386
.filter((count): count is string => count !== undefined)
373387
.map(Number);
374-
return { docs, objects, views, residual };
388+
return { docs, objects, views, data, residual };
375389
} finally {
376390
await stop(serve.child);
377391
}
@@ -455,6 +469,8 @@ describe('#22521 row 2: flat src/docs with a manifest.id naming no package', ()
455469
acme_faq: { packageId: RELEASE_ID, marker: 'MARKER-22521-flat-faq' },
456470
});
457471
expect(artifact.residual).toEqual([2]);
472+
// The control for #22615's data-door reading: the bodies' own objects.
473+
expect(artifact.data, JSON.stringify(artifact)).toEqual(['acme_account:200', 'acme_case:200']);
458474
});
459475

460476
it('the config boot gives the artifact boot\'s answer, without --dev', () => {
@@ -493,6 +509,12 @@ describe('#22521 row 4: a top-level object and view no package owns', () => {
493509
// Before the fix: neither acme_note nor its view listed, no residual line.
494510
expect(configDev).toEqual(artifact);
495511
});
512+
513+
it('the data door serves the residual object the metadata door lists, through both boots (#22615)', () => {
514+
// Before #22615, on both boots: `acme_note:404` beside the listing above.
515+
expect(artifact.data, JSON.stringify(artifact)).toEqual(['acme_account:200', 'acme_case:200', 'acme_note:200']);
516+
expect(configDev.data, JSON.stringify(configDev)).toEqual(artifact.data);
517+
});
496518
});
497519

498520
describe('#22521 row 3: inline docs spread on a composed stack\'s top level', () => {
@@ -519,6 +541,7 @@ describe('#22521 row 3: inline docs spread on a composed stack\'s top level', ()
519541
acme_inline: { packageId: APP_ID, marker: 'MARKER-22521-inline' },
520542
});
521543
expect(configDev.residual).toEqual([1]);
544+
expect(configDev.data, JSON.stringify(configDev)).toEqual(['acme_account:200', 'acme_case:200']);
522545
});
523546
});
524547

@@ -540,6 +563,7 @@ describe('#22521 control: a single-package config has no residual on either door
540563
});
541564
expect(artifact.objects).toEqual([`acme_account@${APP_ID}`, `acme_note@${APP_ID}`]);
542565
expect(artifact.views).toEqual([`acme_note.default@${APP_ID}`]);
566+
expect(artifact.data).toEqual(['acme_account:200', 'acme_note:200']);
543567
expect(artifact.residual).toEqual([]);
544568
});
545569

‎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
*

0 commit comments

Comments
 (0)