Skip to content
18 changes: 18 additions & 0 deletions .changeset/22521-config-boot-residual.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/runtime': patch
'@objectstack/metadata': minor
'@objectstack/cli': patch
---

`os serve <config>`: a multi-package config now serves the top-level metadata no package declares, under the stack's `manifest.id`, and the boot says so, as an artifact boot of the same project already did

Clause-②: yes

A config boot (`os serve objectstack.config.ts`, with or without `--dev`, and no compiled artifact) of a stack that carries `packages[]` registers each package body and never the stack's top level. A top-level item that no package body declares was served by no door, and the boot said nothing. Three shapes reached it: flat `src/docs/*.md` pages when the stack's `manifest.id` names no `packages[]` entry, inline `docs` spread onto a composed stack's top level, and any other top-level collection (objects, views, …) that no package carries. `os build` of the same project, booted as an artifact, registered those items under the stack's `manifest.id` and warned.

- **What changes.** The config boot now runs the same residual rule as the artifact boot, `MetadataPlugin.registerUnclaimedTopLevel`. Every top-level item no package body declares is registered under the stack's `manifest.id`, and the boot prints one `warn` line with the count: `config stack '<id>' carries N top-level metadata item(s) that none of its M package bodies declare`, followed by the remedy. `GET /api/v1/meta/<type>` lists those items under that id, as on an artifact boot of the same project. This includes the flat `src/docs/` pages of a stack whose `manifest.id` names no package, which a config boot did not list before. This entry supersedes, in the same release, the note in the `os serve <config>` flat `src/docs/` entry that "A config boot of such a stack still does not list them": it now does, under the stack's `manifest.id`, with the warning.
- **A new refusal, in one corner.** A residual view container is judged like any other: a top-level `defineView` container that no package declares, and whose own `name` is not the object it binds to, now refuses the config boot with ``Invalid `views:` container from manifest '<manifest id>': the container's own `name` is …``. On the previous release that config booted, because its top level was never read; an artifact boot of the same project already refused it. `os validate` and `os build` refuse the same container first, in the same words, so the refusal is never a boot-time surprise. A divergent container that a package body declares and the top level repeats is refused once, as that package's. **The one-line fix:** drop the container's `name`, or set it to the object it binds to.
- **The remedy the line names.** Declare each such item inside the `packages[]` entry of the package that owns it. A flat `src/docs/` page moves under `src/<package>/docs/`, where `<package>` is the package id or its last dot-separated segment.
- **What widens, in `@objectstack/metadata`.** Its root entry now exports `unclaimedTopLevel(stack)`, the pure residual decision both boot doors and `os validate` read, with its two types `UnclaimedTopLevel` and `UnclaimedTopLevelItem`, and `MetadataPlugin` gains the public static `registerUnclaimedTopLevel(sink, stack, source)`, the one residual rule both boot doors call.
- **What stays the same.** An artifact boot registers the same items and prints the same line, byte for byte. A single-package config (no `packages[]`) has no residual and is unchanged. A composed stack whose top level repeats its package bodies, as `composeStacks([…], { manifest: 'preserve' })` writes it, registers nothing extra and prints nothing.
- **What the residual is, on both doors.** It is metadata: a residual object is listed on `GET /api/v1/meta/object` under the stack's `manifest.id`, and `GET /api/v1/data/<object>` still answers `404`. That was already true on an artifact boot, and the config boot now gives the same answer.
19 changes: 13 additions & 6 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2556,12 +2556,19 @@ export default class Serve extends Command {
//
// [#22405] WHERE the flat set lands is `placeCollectedDocs`'s decision,
// the one `os build` makes: on a multi-package config it rides the body
// of the package whose id is the stack's `manifest.id`, because this boot
// registers package bodies and never the top level of such a config, so a
// flat doc left there is served by nothing and warned about by nothing.
// With no owner (no `packages[]`, or a `manifest.id` naming no single
// entry) it stays on the top level, exactly as before. Inline top-level
// docs never move; the name dedupe below runs only over what stays.
// of the package whose id is the stack's `manifest.id`. With no owner (no
// `packages[]`, or a `manifest.id` naming no single entry) it stays on the
// top level, exactly as `os build` keeps it. Inline top-level docs never
// move; the name dedupe below runs only over what stays.
//
// [#22521] This is PLACEMENT, not the residual rule, and it stays: what
// remains on the top level of a multi-package config is registered under
// `manifest.id` with a warning by the metadata door's one residual rule
// (`AppPlugin` → `MetadataPlugin.registerUnclaimedTopLevel`). Measured
// with this placement withheld, the flat pages of a stack whose
// `manifest.id` names one package are still served under that id, but as
// a residual, so the config boot warns where the artifact boot of the same
// project — whose `os build` placed them on the body — does not.
if (!useArtifactFallback) {
try {
const { collectDocsFromSrc, placeCollectedDocs } = await import('../utils/collect-docs.js');
Expand Down
43 changes: 37 additions & 6 deletions packages/cli/src/utils/view-container-names.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
* that judge and under which package id — the load path's answer:
*
* - no `packages[]` → the top-level `views`, owned by `manifest.id`;
* - `packages[]` → each body's own `views`, owned by that package, and the
* top level NOT at all (the load path does not register from it).
* - `packages[]` → each body's own `views`, owned by that package, then
* the top-level entries NO body declares — the residual the config boot
* registers (and refuses) under the stack's manifest id — and never a
* top-level entry a body already declares.
*
* Every row's message is asserted EQUAL to the judge's answer for the same
* entry, source label and id — never re-spelled — so this file cannot drift
Expand Down Expand Up @@ -81,15 +83,44 @@ describe('#20331 — findViewContainerNameRefusals walks what the load path regi
expect(rows[0].message).toBe(viewContainerNameRefusal(divergent, 'manifest', 'com.example.orders')!.message);
});

it('a `packages[]` stack: the top-level `views` is NOT judged, because the load path does not register it', () => {
// `resolveArtifactPackageOrder` returns the package bodies alone once
// `packages` is present, so a top-level copy never reaches the registrar.
// Judging it here would refuse a stack the server loads.
it('a `packages[]` stack: a top-level container a body already declares is NOT judged', () => {
// The residual rule skips a top-level entry whose slot a body registers —
// `composeStacks(…, { manifest: 'preserve' })` repeats every body at the
// top level — so no boot ever judges this copy. Judging it here would
// refuse a stack the server loads.
const rows = findViewContainerNameRefusals({
manifest: manifest(ID),
views: [divergent],
packages: [{ manifest: { ...manifest('com.example.core'), views: [view({ name: 'vcn_order_line' })] } }],
});
expect(rows).toEqual([]);
});

it('[#22521] a `packages[]` stack: an UNCLAIMED top-level container is judged, under the stack\'s manifest id', () => {
// No body declares `vcn_order_line`, so the config boot registers this
// container as the stack's residual — and refuses it, in these words.
const RELEASE = 'com.example.vcn.release';
const rows = findViewContainerNameRefusals({
manifest: manifest(RELEASE),
views: [view({ name: 'vcn_order_line', object: 'vcn_other' }), divergent],
packages: [{ manifest: { ...manifest('com.example.core'), views: [view({ object: 'vcn_other', name: 'vcn_other' })] } }],
});
expect(rows).toHaveLength(1);
expect(rows[0]).toEqual({
path: 'views[1]',
code: 'VALIDATION_ERROR',
httpStatus: 400,
message: viewContainerNameRefusal(divergent, 'manifest', RELEASE)!.message,
});
});

it('[#22521] a divergent container a body declares AND the top level repeats is judged ONCE, as the body\'s', () => {
const rows = findViewContainerNameRefusals({
manifest: manifest('com.example.vcn.release'),
views: [divergent],
packages: [{ manifest: { ...manifest('com.example.orders'), views: [divergent] } }],
});
expect(rows.map((r) => r.path)).toEqual(['packages[0].manifest.views[0]']);
expect(rows[0].message).toBe(viewContainerNameRefusal(divergent, 'manifest', 'com.example.orders')!.message);
});
});
38 changes: 31 additions & 7 deletions packages/cli/src/utils/view-container-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,31 @@
* `resolveArtifactPackageOrder` returns that payload itself when it carries no
* `packages` key and each `packages[i].manifest` body otherwise (ADR-0130 D4);
* `ObjectQL.registerApp(body)` then runs `registerMetadataCollections(body,
* body.id || body.name, 'manifest')`. So:
* body.id || body.name, 'manifest')`. On a stack that carries `packages[]`,
* `AppPlugin` then registers the RESIDUAL — the top-level items no package
* body declares — through the metadata package's one residual rule
* (`MetadataPlugin.registerUnclaimedTopLevel`), which refuses a divergent
* residual container in the same words, from `manifest` and under the stack's
* own manifest id. So:
*
* - no `packages[]` → the top-level `views`, owned by the manifest's id;
* - `packages[]` → each body's own `views`, owned by that package's id,
* and ⛔ NOT the top level, which the load path does not register from.
* THEN the top-level `views` the residual rule registers, owned by the
* stack's manifest id. ⛔ Never a top-level container a body already
* declares: the residual rule skips it, so judging it here would refuse
* the same document twice.
*
* WHICH top-level entries are residual, and their owner id, is
* `@objectstack/metadata`'s `unclaimedTopLevel` — the answer the boot
* registers from. ⛔ Never re-derived here: a second spelling of "which top
* level does boot read" is how this walk and the boot came to disagree once.
*
* The package id is read through the owners that already exist for it: the
* runtime's `artifactPackageId` (`@objectstack/core` — "every seam that has to
* name a package reads the id through THIS function") for the one-package
* payload, and this package's `artifactPackages` for `packages[]`, the reader
* both `os build` and `os validate` already walk with.
* payload, this package's `artifactPackages` for `packages[]`, the reader
* both `os build` and `os validate` already walk with, and `unclaimedTopLevel`
* for the residual.
*
* ⚠️ Bound, stated rather than hidden: a nested `plugins[]` entry's `views` is
* registered by boot too (label `nested plugin`), and is NOT walked here. The
Expand All @@ -52,6 +66,7 @@
*/

import { artifactPackageId } from '@objectstack/core';
import { unclaimedTopLevel } from '@objectstack/metadata';
import { viewContainerNameRefusal } from '@objectstack/objectql';

import { artifactPackages } from './artifact-packages.js';
Expand Down Expand Up @@ -80,28 +95,37 @@ const asRec = (v: unknown): AnyRec | undefined =>
* Returns `[]` for a stack the boot registrar accepts on this axis.
*/
export function findViewContainerNameRefusals(parsed: AnyRec): ViewContainerNameRefusalRow[] {
const bodies: Array<{ at: string; ownerId: string | undefined; views: unknown }> = [];
// `located`: `at` already names the one entry `views` holds.
const bodies: Array<{ at: string; ownerId: string | undefined; views: unknown; located?: boolean }> = [];
// The resolver's own branch test (`declared === undefined`). On the PARSED
// stack a present `packages` is an array — `ArtifactPackageSchema[]`,
// `.optional()`, so `null` and every non-array were refused at the parse.
if (parsed.packages !== undefined) {
for (const pkg of artifactPackages(parsed)) {
bodies.push({ at: `packages[${pkg.index}].manifest.views`, ownerId: pkg.id, views: pkg.body.views });
}
// The residual, after the bodies — the order boot registers them in. Each
// unclaimed `view` entry is located at its own top-level index; the judge
// refuses only a container, exactly as the residual registration does.
const residual = unclaimedTopLevel(parsed);
for (const { field, index, type, item } of residual?.items ?? []) {
if (type !== 'view') continue;
bodies.push({ at: `${field}[${index}]`, ownerId: residual?.ownerId, views: [item], located: true });
}
} else {
const manifest = asRec(parsed.manifest);
const payload = manifest ? { ...manifest, ...parsed } : parsed;
bodies.push({ at: 'views', ownerId: artifactPackageId(payload), views: parsed.views });
}

const rows: ViewContainerNameRefusalRow[] = [];
for (const { at, ownerId, views } of bodies) {
for (const { at, ownerId, views, located } of bodies) {
if (!Array.isArray(views)) continue;
views.forEach((entry, index) => {
const refusal = viewContainerNameRefusal(entry, 'manifest', ownerId);
if (!refusal) return;
rows.push({
path: `${at}[${index}]`,
path: located ? at : `${at}[${index}]`,
code: refusal.code,
httpStatus: refusal.httpStatus,
message: refusal.message,
Expand Down
Loading
Loading