Skip to content

Commit f1a1028

Browse files
hotlongclaude
andauthored
feat(runtime): every top-level collection read gains a packages[] path (#15005) (#15261)
* feat(runtime): every top-level collection read gains a `packages[]` path ADR-0130 D4 / option B, reader program 2/4 (#15005). Nothing here changes what any command emits — the artifact stays additive; each reader simply learns to find its collections under `packages[]` as well as at the top level. `resolveArtifactCollections` (`@objectstack/core`, beside `resolveArtifactPackageOrder`) is the one resolution: top level first and whole, then every package body's items the top level did not already claim, in `resolveArtifactPackageOrder`'s order. On a bundle without `packages[]` it returns the argument itself, so every single-package artifact and every `defineStack()` config is bit-identical. Readers taught: `AppPlugin` (datasources, datasourceMapping, objects, jobs, seed data, translations, the ADR-0057 security block, the job handler context's bundle), the three exported collectors, `mergeRuntimeModule`'s declaration half, `createStandaloneStack`'s surfaced keys, and `resolve-project-database`'s project-DB tier. #15004's acceptance pin shrinks from 24 ledgered losses to 1 — the from-source `appSecurityPluginOptions` row card #15007 owns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m * chore(changeset): the option-B reader half for @objectstack/core and @objectstack/runtime Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m * test(runtime): type the option-B job handler double's argument `check:test-typecheck` reads packages/runtime/tsconfig.test.json, which includes this file — the mock's inferred `() => Promise<undefined>` refused the `(jobCtx) => …` implementation `mockImplementation` supplies. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m * refactor(runtime): keep the option-B collection reader package-private, and refuse mixed spellings Contract review of PR #15261 rejected publishing this resolution from `@objectstack/core`: the "three consumers in packages that cannot import each other" premise is false (`cli -> runtime`, `cli -> plugin-security` and `runtime -> plugin-security` all exist today; only `plugin-security -> runtime` would cycle), and the two sibling reader cards landed their own private `packages[]` walks, so there is exactly one consumer. Maintainer decision 2026-09-04: those two land as they are and this card publishes nothing. - Move `artifact-collections.ts` and its tests to `@objectstack/runtime`, where every call site already lives. `packages/core/src/index.ts` is restored byte-for-byte, so `@objectstack/core` has no source change in this PR at all. The module is not named by `packages/runtime/src/index.ts`, so neither symbol reaches a published surface. - Drop the partly-flattened support claim and its test. #14512 ruled "Not D (a partly flattened artifact is a new permanent shape)", and on such an artifact the top level's name claims are applied to every package body, so a second package's same-named permission set or object extension is dropped. The module header now records that instead of promising the opposite. - Refuse a collection key spelled both ways rather than skipping one. `functions` is `z.union([z.record(...), z.array(...)])`, so two packages can each be schema-valid and disagree; the previous code let `contributions[0]` pick the shape and `continue`d past the rest, losing a whole package's collection in both directions with nothing thrown. New ADR-0112 envelope `MIXED_ARTIFACT_COLLECTION_SHAPE` (422), matching what `composeStacks` already does with the same mix, classified in `dispatcher-error-vocabulary.ts`. - Document the dependency-cycle throw in `@throws`: it is a bare `Error` from `resolvePluginOrder`, so a caller matching on `err.code` / `err.status` does not match it. - Changeset drops `@objectstack/core` entirely; `@objectstack/runtime` stays `patch` because no published surface widens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m * test(cli): record the option-B ledger reaching ZERO, and re-verify it there `OPTION_B_LOSSES` had 23 rows on `origin/main` after #15007 landed (it deleted the one row no artifact-side change could reach). All 23 belong to this card, so merging it empties the ledger — which is the state the pin's own header calls "the program is done". An empty ledger is also the state that could go vacuous, so it is re-verified rather than asserted: with `resolveArtifactCollections` neutered to the identity function and `@objectstack/runtime` REBUILT — the pin reaches that package through its `exports` map, so `dist/` is what it measures — the pin goes red naming exactly 23 rows, byte-for-byte the set the ledger carried before. Restored and rebuilt, 7 passed. ⛔ The set-equality assertion, the subsystem coverage and the anti-vacuity controls are untouched; the only edits are the ledger's own rows and the two docblocks that described them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m * test(runtime): pin `packages: []` returning by identity, which no test covered The three identity controls the design rests on are the additive artifact returning by reference, `packages: []` returning unchanged, and an artifact with no `packages` key short-circuiting. The first and third were pinned; the second was not, and it is the one that is not obvious: `[]` IS an array, so such an artifact walks the whole resolution — package order over zero entries, then every package-owned key merged against no contributions — and still has to come back as the SAME object. Any key that came back a fresh copy would trip `{ ...artifact }` and hand every reader downstream a different object than it was given. Asserted with and without collections present, so the identity is not an artifact of the one key that happened to be there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m * test(cli): re-anchor the BASELINE anti-vacuity floor, which the empty ledger made vacuous `expect(additive.rows.length).toBeGreaterThanOrEqual(OPTION_B_LOSSES.length)` was a real bound only while the ledger was non-empty. This PR emptied it, so the line became `rows.length >= 0` — true of every array, including an empty one. It was dead code wearing a control's comment, and it took with it the fourth direction this file's header claims: "the probe itself quietly measuring less ⇒ RED". Re-anchored to the probe's MEASURED row count rather than deleted, because none of the three controls that survive an empty ledger covers this one: the `registryObjectsFromArtifact` CONTROL asserts two object names, and the two coverage tests assert that the five boundaries and #15006's four sites are represented — none of them notices rows disappearing. 30 is measured, not remembered: with the line temporarily written `toBe(-1)` the run reports `expected 30 to be -1`. Verified live at the boundary in the same session — a floor of 31 goes RED on the same fixture with the new message, so the assertion is not satisfied by construction. Both legs proved on disk by hash and restored to a byte-identical file. `>=` rather than `toBe` keeps the shrink-only direction the ledger uses: a row added to the probe stays green, a row that stops being measured is red. ⛔ The set-equality assertion, the `registryObjectsFromArtifact` control, the five-boundary coverage test and the #15006 four-site coverage test are untouched, and the ledger is not touched to serve this line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c4d1354 commit f1a1028

10 files changed

Lines changed: 1233 additions & 71 deletions
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
fix(runtime): a multi-package artifact's collections are read from `packages[]`, not only from the flattened top level
6+
7+
A release artifact composed with `manifest: 'preserve'` carries every
8+
definition twice — flattened at its top level, and again under
9+
`packages[]` (ADR-0130 D4). Only two readers had ever learned the second
10+
half: `ObjectQLPlugin`'s manifest service and the metadata artifact door.
11+
Every other reader said `artifact.<collection>` and nothing else, so an
12+
artifact that carried a collection under `packages[]` alone reached them
13+
EMPTY — and nothing threw. The app booted clean having lost its
14+
declarative actions, its scheduled jobs, its seed data, its object routing
15+
or its default permission set.
16+
17+
`resolveArtifactCollections` — new, and PACKAGE-PRIVATE to
18+
`@objectstack/runtime` — is now the one way this package reads a top-level
19+
collection out of an artifact in either shape. It takes the artifact's own
20+
top-level value first and whole, then adds from each package body — in
21+
`resolveArtifactPackageOrder`'s dependency order — the items the top level
22+
did not already claim. A bundle that carries no `packages[]` is returned
23+
unchanged, by identity: every single-package artifact and every
24+
`defineStack()` config reads exactly as before. Nothing is added to any
25+
package's published surface: `@objectstack/core` is untouched by this
26+
change, and the new module is not named by
27+
`packages/runtime/src/index.ts`.
28+
29+
Where one collection key is spelled two ways inside one artifact —
30+
`functions` is `z.union([z.record(…), z.array(…)])`, so two packages can
31+
each be schema-valid and disagree — the read is REFUSED with an ADR-0112
32+
envelope (`MIXED_ARTIFACT_COLLECTION_SHAPE`, 422) rather than one spelling
33+
being skipped. `composeStacks` already refuses the same mix at compose
34+
time for the same reason.
35+
36+
Taught to use it, in `@objectstack/runtime`:
37+
38+
- `AppPlugin` — declared datasources and their auto-connect, the
39+
`datasourceMapping` object routing, the objects handed to the connection
40+
service and to the hot-reload seeder, scheduled jobs, seed datasets,
41+
translation bundles, and the ADR-0057 security collections
42+
(`positions` / `permissions` / `capabilities` / `sharingRules`). A job
43+
handler's `ctx.bundle` is now the resolved view too, so
44+
`ctx.bundle.objects` answers on a multi-package artifact.
45+
- `collectBundleActions`, `collectBundleHooks` and
46+
`collectBundleFunctionEntries` — including the object-EMBEDDED actions
47+
that ride on `objects[]` and disappeared with it.
48+
- `mergeRuntimeModule` — the declaration half. The sibling ESM module
49+
re-supplies every callable regardless of shape, so `functions` was not
50+
absent: a function declared `effect: 'writes'` simply came back as a bare
51+
callable and defaulted to `'pure'`. It registered, it ran, and its writes
52+
were counted as none.
53+
- `createStandaloneStack`'s surfaced `requires` / `objects` /
54+
`permissions` / `positions`, which drive the CLI's tier resolution, its
55+
engine and storage-driver auto-registration, and the ADR-0056 D7 default
56+
permission set.
57+
- `resolve-project-database`'s project-database tier, which opens the
58+
artifact itself and runs before any stack exists (`os dev`, `os start`,
59+
`os db clean`). Without this a multi-package project silently fell
60+
through to the unified default database instead of the datasource it
61+
declared.
62+
63+
Nothing about what the platform EMITS changes: `composeStacks` and the
64+
artifact format are untouched, and the flattened top level is still
65+
written. This is the reader half of the option-B program (#14512).

packages/cli/test/option-b-reader-acceptance.pin.test.ts

Lines changed: 64 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,18 @@
6666
*
6767
* Run with `OPTION_B_LOSSES` emptied on `origin/main` `33681eaef`, the pin
6868
* reports **24 subsystems** losing their collections, across all three packages
69-
* the program scopes — the full red output is recorded in this card's PR body.
69+
* the program scopes — the full red output is recorded in #15004's PR body.
7070
* In the SAME run the additive baseline and the `packages[]` control both pass,
7171
* which is what makes the red a discrimination rather than a broken fixture.
7272
*
73+
* Re-verified at the empty ledger, which is the state that could go vacuous:
74+
* with `@objectstack/runtime`'s `resolveArtifactCollections` neutered to the
75+
* identity function and that package REBUILT (the pin reaches it through its
76+
* `exports` map, so `dist/` is what it measures), this pin goes RED naming
77+
* exactly 23 rows — the same 23, byte for byte, that the ledger carried before
78+
* #15005. Restored and rebuilt, 7 passed. So the empty ledger is a measurement
79+
* of the readers, not of a probe that stopped looking.
80+
*
7381
* One row in that output is worth naming here, because it is a loss no
7482
* presence-check would have found: on the compiled path a function declared
7583
* `effect: 'writes'` comes back through `collectBundleFunctionEntries` as
@@ -129,36 +137,36 @@ import { measureShape, type ProbeRow, type ShapeMeasurement } from './fixtures/o
129137

130138
/**
131139
* The subsystems that silently lose their collection when the flattened top
132-
* level is gone — MEASURED on `origin/main` `33681eaef`, not curated.
140+
* level is gone — MEASURED, not curated. ⭐ EMPTY: the reader half of the
141+
* option-B program is done, and the emitter half (#14512) is unblocked.
142+
*
143+
* How it got here, in the order the rows actually went:
144+
*
145+
* - opened at **24 rows** on `origin/main` `33681eaef` (#15004);
146+
* - **23 → 23** when #15007 (`@objectstack/plugin-security`) landed, deleting
147+
* `B2 · plugin-security appSecurityPluginOptions over the from-source
148+
* config`, the one row no artifact-side change could reach;
149+
* - **23 → 0** here (#15005), when `@objectstack/runtime` learned to resolve
150+
* `packages[]`: the whole of boundaries B1 and B5, and every B2 row whose
151+
* reader ships in that package.
133152
*
134-
* ⛔ SHRINK-ONLY, audited in BOTH directions (see the header). Each line names
135-
* a boundary, a subsystem and the collection it reads.
153+
* ⚠️ The last row to go was a plugin-security one and it is NOT #15007's twin
154+
* arriving late: `B1 · plugin-security appSecurityPluginOptions over the
155+
* artifact-serve config` runs that same reader over `createStandaloneStack`'s
156+
* RESULT. Nothing inside `@objectstack/plugin-security` could move it — the
157+
* standalone result carried neither the permission sets nor a route to them —
158+
* and it goes green because that result now surfaces `permissions` resolved
159+
* across both shapes, which plugin-security's existing top-level branch then
160+
* answers. One reader, two boundaries, each owned by a different card: that
161+
* split is what the header's B1/B2 distinction is for.
162+
*
163+
* ⛔ SHRINK-ONLY, audited in BOTH directions (see the header). An empty ledger
164+
* is the STRONGEST state this pin has, not a disabled one: every row the probe
165+
* measures must now be `present` in BOTH shapes, so a reader that regresses —
166+
* or a new reader that arrives unresolved — is red on arrival with nothing left
167+
* to absorb it. Adding a line is never how that red is fixed.
136168
*/
137-
const OPTION_B_LOSSES: readonly string[] = [
138-
'B1 · AppPlugin declared-datasource auto-connect (compiled artifact) · datasources',
139-
'B1 · AppPlugin job scheduling (compiled artifact) · jobs',
140-
'B1 · AppPlugin objects handed to datasource connect (compiled artifact) · objects',
141-
'B1 · AppPlugin ql.setDatasourceMapping (object routing) (compiled artifact) · datasourceMapping',
142-
'B1 · AppPlugin seed datasets merged (compiled artifact) · data',
143-
'B1 · AppPlugin translation loading into the i18n service (compiled artifact) · translations',
144-
'B1 · createStandaloneStack surfaced objects (CLI tier resolution + engine/driver auto-registration) · objects',
145-
'B1 · createStandaloneStack surfaced permissions (ADR-0056 D7) · permissions',
146-
'B1 · createStandaloneStack surfaced positions · positions',
147-
'B1 · plugin-security appSecurityPluginOptions over the artifact-serve config (default permission set) · permissions',
148-
'B1 · runtime collectBundleActions (action dispatch registration) · actions + objects[].actions',
149-
'B1 · runtime collectBundleFunctionEntries (declared function effect) · functions',
150-
'B1 · runtime collectBundleHooks (declarative hook binding) · hooks',
151-
'B2 · AppPlugin declared-datasource auto-connect (from source) · datasources',
152-
'B2 · AppPlugin job scheduling (from source) · jobs',
153-
'B2 · AppPlugin objects handed to datasource connect (from source) · objects',
154-
'B2 · AppPlugin ql.setDatasourceMapping (object routing) (from source) · datasourceMapping',
155-
'B2 · AppPlugin seed datasets merged (from source) · data',
156-
'B2 · AppPlugin translation loading into the i18n service (from source) · translations',
157-
'B2 · runtime collectBundleActions over the from-source config · actions + objects[].actions',
158-
'B2 · runtime collectBundleFunctionEntries over the from-source config · functions',
159-
'B2 · runtime collectBundleHooks over the from-source config · hooks',
160-
'B5 · resolve-project-database readConfigDeclaredDefault (project database tier) · datasourceMapping + datasources',
161-
];
169+
const OPTION_B_LOSSES: readonly string[] = [];
162170

163171
const render = (rows: ProbeRow[], only?: (r: ProbeRow) => boolean): string =>
164172
rows
@@ -230,8 +238,33 @@ describe('#15004 — option-B acceptance pin: every subsystem must see its colle
230238
`platform emits TODAY. This is never an option-B finding — it means the fixture stopped ` +
231239
`carrying a collection, or a reader regressed on the additive path.\n${render(additive.rows)}`,
232240
).toEqual([]);
233-
// Anti-vacuity: a probe that measured no rows would satisfy the line above.
234-
expect(additive.rows.length).toBeGreaterThanOrEqual(OPTION_B_LOSSES.length);
241+
// Anti-vacuity: the assertion above is satisfied by an EMPTY row set, so a
242+
// probe that quietly stopped measuring has to be caught right here.
243+
//
244+
// ⚠️ RE-ANCHORED when the ledger reached zero. The floor used to be
245+
// `OPTION_B_LOSSES.length`, which was a real bound only while the ledger
246+
// was non-empty; at zero it reads `rows.length >= 0` — true of every
247+
// array, including an empty one. It was dead code wearing a control's
248+
// comment, and the fourth direction this file's header claims ("the probe
249+
// itself quietly measuring less ⇒ RED") had silently stopped existing.
250+
//
251+
// 30 is MEASURED, not remembered: with this line temporarily written
252+
// `expect(additive.rows.length).toBe(-1)`, the run reports
253+
// `expected 30 to be -1`. Verified live at the boundary in the same
254+
// session — a floor of 31 goes RED on the same fixture, so the assertion
255+
// is not satisfied by construction.
256+
//
257+
// `>=` rather than `toBe` on purpose, and it is the same shrink-only
258+
// direction the ledger uses: a row ADDED to the probe is welcome and stays
259+
// green, a row that stops being measured is red. Raise the floor when the
260+
// probe grows; ⛔ never lower it to make a red run green.
261+
expect(
262+
additive.rows.length,
263+
`The probe measured ${additive.rows.length} rows, fewer than the 30 it measured when ` +
264+
`this floor was set. A row that stops being measured stops being able to fail, which ` +
265+
`is the one direction this pin cannot detect anywhere else — fix the probe rather ` +
266+
`than the floor.`,
267+
).toBeGreaterThanOrEqual(30);
235268
});
236269

237270
// ── The pin ──────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)