Skip to content

Commit cdc1ae0

Browse files
fix(cli): mount the always-on package-registry capability at its spec-declared provider (#19387) (#19983)
Fixes #19387 Clause-②: no `package-registry` is on the spec's always-on slate, so `objectstack serve` appends it to every app's `requires`. But `Serve.CAPABILITY_PROVIDERS` did not key it, and the resolver's no-provider branch says nothing for a token the app did not declare itself. So a stock app got no `package` service, and a package created through the API was lost on restart. This PR is the runtime half of ruling A′ (decision batch #125 item 2): `protocol.installPackage` / `updatePackage` now find the `package` service on a stock boot. #17676 remains open. Its item 5 (three probes agreeing across a restart) is its own acceptance and is not claimed here. ## What changes - **`packages/cli/src/commands/serve.ts`, the mount table.** `CAPABILITY_PROVIDERS['package-registry']` now points at `@objectstack/service-package` / `PackageServicePlugin`. That is the provider the spec's `PLATFORM_CAPABILITY_PROVIDERS['package-registry']` row declares. The existing drift pin (`open-edition service tokens name the SAME package as serve CAPABILITY_PROVIDERS`) now covers this row too. - **`serve.ts`, the capability resolver.** `hasPluginMatching` used to read only the app's own `plugins[]`. Now it also reads `resolverMounted`, the provider instances this loop has already mounted, each added after its `kernel.use` succeeds. Without this, the table entry alone gives an app that declares `marketplace` a second PackageServicePlugin (measured, see below). - **`packages/cli/test/serve-capability-vocabulary.test.ts`.** The prose that deliberately declined to pin the absence is now the pin: every `ALWAYS_ON_CAPABILITIES` token must key a `CAPABILITY_PROVIDERS` entry or a `CAPABILITY_TO_TIER` tier. Those are the only two ways `serve` mounts a token, and `CAPABILITY_TO_TIER` is the named list whose docblock says why its tokens have no provider entry. A future slate entry with no mount goes red on the PR that adds it. - **`packages/cli/test/serve-capability-identity.test.ts`.** The new row is added to `EXPECTED_PROVIDER_NAME`, whose coverage assertion requires every table key. - **`packages/cli/test/serve-package-registry-always-on.e2e.test.ts`** (nightly tier by name, integration project by behaviour). It boots `serve` twice on one SQLite file, installs a package through `POST /api/v1/packages`, restarts, and reads it back. It also boots a `marketplace` declarer and asserts that exactly one PackageServicePlugin is mounted. - **`packages/metadata-protocol/src/protocol.ts`** (patch round, `b5b9b64c`, prose only). The `installPackage` docblock said the runtime half of the split had not landed and that a stock boot still took the in-memory-only branch. This diff makes that false, and the docblock ships in `dist`. It now says that `serve` mounts `PackageServicePlugin` for `package-registry`, and that the in-memory-only branch is for hosts that mount no provider. One hunk, comment lines only. - `.changeset/19387-package-registry-mount.md`: `@objectstack/cli` patch and `@objectstack/metadata-protocol` patch. ## Measured, before and after Fixture: an app that declares neither `marketplace` nor `package-registry` (the `examples/app-crm` shape in the one way that matters). Booted with `serve --dev` on a SQLite file. | probe | `origin/main` 2c1011b | this branch | |:--|:--|:--| | `POST /api/v1/packages` | 201, plus `[protocol.installPackage] no 'package' service — 'com.example.survivor' registered in-memory only (will not survive a restart)` | 201, no fallback line | | restart on the same DB, then `GET /api/v1/packages/com.example.survivor` | **404** | **200**, and the boot logs the rehydration of `com.example.survivor` from `sys_packages` | | banner plugin row | no PackageServicePlugin | PackageServicePlugin ×1 | Declarer fixture (`requires: ['marketplace']`), with three builds of `serve.ts`: | build | `Plugin superseded: 'package-service'` | PackageServicePlugin in the banner row | |:--|:--|:--| | `origin/main` | absent | ×1 | | table entry only (no `resolverMounted`) | **present** (WARN) | **×2** | | this branch | absent | ×1 | ## Declarer impact of this diff (acceptance 3) **Measured: unaffected.** An app that declares `marketplace` boots with the same single PackageServicePlugin as before. No supersede warning, no second instance, and `POST /api/v1/packages` persists as it did. The only in-repo declarer is `examples/app-showcase/objectstack.config.ts:107` (`git grep` over `examples/`, `packages/`, `apps/`, `content/`, `skills/` for `'marketplace'` in a `requires` array). This file is read-only for this PR and does not change. **The declarer migration moves with the repoint, not with this diff.** Today the spec's `PLATFORM_CAPABILITY_PROVIDERS` maps **both** tokens to `@objectstack/service-package`. The row's own comment says that sharing is what the carve-out INHERITED, and that repointing `marketplace` at the browse surface "moves the runtime's own resolver with it". This table follows the spec map, and the existing drift pin enforces that. So the repoint starts at that spec row (the spec seat's lane) and this mount table follows it. It is reported to the seat as a `Seam:` finding in the dispatch report, not done here. **ADR-0087 disposition.** For this diff: not applicable. Nothing an author writes changes shape, meaning or validity. `requires: ['marketplace']` and `requires: ['package-registry']` both stay valid and still mount the persistence plugin. For the repoint, the recommendation passed to the seat is: under 〈阶段姿态〉 (「速度优先于兼容。用的人少:退役立即生效,无过渡窗口、无别名双拼」) the meaning change of `marketplace` takes effect at once, with no alias window. A `marketplace` declaration made for the store becomes redundant, because the store is always on. The case the carrier must spell out is an out-of-repo declarer whose `requires: ['marketplace']` would start mounting the browse plugins. That is a runtime-meaning change of a `requires` token, not a stored or authored shape, so the recommendation is that no ADR-0087 conversion entry is owed. The carrier decides. ## Why no runtime warning in the no-provider branch That branch is reached only when a token has no `CAPABILITY_PROVIDERS` entry. Once the pin above holds, an always-on token cannot reach it: every slate token has a mount, and `@objectstack/spec` and `@objectstack/cli` release in one `fixed` changeset group (`.changeset/config.json`). A published CLI therefore never runs against a slate its pin did not see. A warning there would be unreachable code. A comment at the branch records this. ## Tests (at 6496714) - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: 222 files. On first run, 220 passed and 2 were refused with `packages/cli is not built` (`published-subpath-console.pin`, `published-subpath-hook-body.pin`), a build prerequisite. After `pnpm --filter @objectstack/cli build` those 2 files pass, 29 tests. Totals: 3116 passed, 29 skipped. - `pnpm --filter @objectstack/cli run typecheck`: exit 0. The `check:test-typecheck` debt holds at 3 files, 28 errors. `tsc -p tsconfig.test.json --listFilesOnly` includes all 3 touched test files. - The new e2e, `OS_TEST_TIERS=nightly … vitest run --project integration test/serve-package-registry-always-on.e2e.test.ts`: 5/5 passed. - Neighbouring nightly serve boots: every other `*.e2e.test.ts` in `packages/cli/test` that boots `serve` (17 files) passed with 75/75 tests. A boot-composition change reaches them, and they run only nightly. **Reverse checks** (each mutation went through `scripts/ablation-replace.mjs`; the tool confirmed the anchor moved from 1 to 0 and the blob changed, then restored the file and checked it matched HEAD with `git diff HEAD` empty): 1. Delete the `'package-registry'` table entry, then run the vocabulary and identity pins: **red**, as expected. The new pin reports `expected [ 'package-registry' ] to deeply equal []`, and the identity table's coverage assertion goes red too. 2. Same deletion, then run the e2e: **3 red** (banner row, in-memory fallback line, post-restart `404`). The declarer block stays green. 3. Delete `resolverMounted.push(provider);`, then run the e2e: **1 red** (`Plugin superseded: 'package-service'` on the declarer boot). The stock block stays green. No dist leg was needed. The unit pins import `../src/commands/serve.js` directly, and the e2e spawns `bin/run-dev.js` through tsx, so both read `src/`. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at 6496714 derived 64 commands. All were run with the exit code captured before any pipe, and `--ran` reconciled them: `64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN`. Three first answered exit 3 (PREREQUISITE NOT MET) and passed once the prerequisite was supplied: - `check-plugin-teardown-shape --self-test` needed the pinned fixture commit `621a4876`, which a shallow clone lacks. It passed after `git fetch --depth=1 origin 621a487…`. - `check:dual-build-cjs-loads` and `check:i18n-coverage` needed build output. They passed after `turbo run build --filter=!@objectstack/docs`. `pnpm lint` (the full `eslint . --no-inline-config` run): exit 0 at 6496714. ## Acceptance notes - **The dispatch premise that `hasPluginMatching` stops a double mount was false (measured).** That check reads only the app's own `plugins[]`, never what the resolver has mounted. The `resolverMounted` list is the in-scope repair. - **Prose that goes stale with this diff, left for the files' next editors** (those files are outside this PR's surface): - `packages/spec/src/kernel/platform-capabilities.ts`: the SCOPE note on the `package-registry` slate entry, and the comment on its provider row. Both say the entry is inert under `serve`. - ~~`packages/metadata-protocol/src/protocol.ts`: the `installPackage` docblock~~: corrected in this PR (patch round, `b5b9b64c`), because it ships in `dist`. The spec-side note above is carried on #17676 with the `marketplace` repoint seam. - **`os migrate plan`** reports, for information only, tables with a platform prefix that no object declares. By reading `packages/cli/src/utils/unmanaged-tables.ts`, `sys_packages` (raw DDL) should now appear there for any stock database that `serve` has booted, as it already would for `marketplace` declarers. NOT MEASURED. - **On the in-memory driver (`memory://`)** a stock boot now logs four more WARN lines (measured): three `Raw execution not supported in InMemory driver` for the `sys_packages` DDL and query, and `Package hydration from sys_packages SKIPPED`. `marketplace` declarers already got these. --- _Generated by [Claude Code](https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d42e06b commit cdc1ae0

6 files changed

Lines changed: 434 additions & 24 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
fix(cli): `objectstack serve` mounts the always-on `package-registry` capability, so a package created through the API survives a restart on a stock boot (#19387)
7+
8+
Clause-②: no
9+
10+
`package-registry` has been on the always-on slate (`PLATFORM_ALWAYS_ON_CAPABILITIES`) since the `marketplace` / `package-registry` split, and `serve` appended it to every app's `requires`. But `Serve.CAPABILITY_PROVIDERS` did not key it, and the resolver's no-provider branch says nothing about a token the app did not declare itself. So an app that did not declare `requires: ['marketplace']` got no `package` service. `POST /api/v1/packages` answered `201`, printed `no 'package' service — '…' registered in-memory only (will not survive a restart)`, and `GET /api/v1/packages/:id` answered `404` after a restart.
11+
12+
- **`package-registry` now mounts `PackageServicePlugin`** from `@objectstack/service-package`, the provider the spec's `PLATFORM_CAPABILITY_PROVIDERS` row declares for it. A stock boot creates `sys_packages` and replays it at start, so installs and manifest edits made through the API persist.
13+
- **Apps that declare `marketplace` boot as before, with one `PackageServicePlugin`.** `marketplace` resolves to the same provider. The capability resolver now remembers the providers it has mounted itself, so the always-on token does not mount a second copy. Without that change, a declarer's boot would print `Plugin superseded: 'package-service'`.
14+
- **A stock database gains one table, `sys_packages`.** `PackageServicePlugin` creates it with raw DDL, as it already did for `marketplace` declarers. On the in-memory driver (`memory://`), which has no raw SQL, the boot now logs that the DDL was not run and that package hydration was skipped. Packages there last only as long as the process, as before.
15+
- `--preset minimal` still opts out of the whole slate. `protocol.installPackage` keeps its in-memory-only branch as the documented degraded path for hosts that mount no provider.
16+
- **`@objectstack/metadata-protocol`: the `installPackage` docblock no longer says the runtime half is missing.** It used to say that a stock boot still took the in-memory-only branch. It now says that `objectstack serve` mounts `PackageServicePlugin` for `package-registry`, so a stock boot persists, and that the in-memory-only branch is for hosts that mount no provider. The docblock ships in `dist`. No behaviour changes.

‎packages/cli/src/commands/serve.ts‎

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1960,6 +1960,21 @@ export default class Serve extends Command {
19601960
export: 'PackageServicePlugin',
19611961
identities: ['package-service', 'PackageServicePlugin'],
19621962
},
1963+
// The always-on persistence half of the `marketplace` / `package-registry`
1964+
// split (#17676 ruling A' items 1-2): `sys_packages` and its boot
1965+
// hydration, so `protocol.installPackage` / `updatePackage` find the
1966+
// `package` service on a stock boot. Keyed at the provider the spec's
1967+
// PLATFORM_CAPABILITY_PROVIDERS row declares for this token — the SAME
1968+
// package and plugin as `marketplace` above, because that is what the spec
1969+
// map says today. Repointing `marketplace` at the browse surface starts at
1970+
// that spec row, and this table follows it; until then an app declaring
1971+
// `marketplace` gets ONE PackageServicePlugin, not two — see
1972+
// `resolverMounted` in the capability resolver.
1973+
'package-registry': {
1974+
pkg: '@objectstack/service-package',
1975+
export: 'PackageServicePlugin',
1976+
identities: ['package-service', 'PackageServicePlugin'],
1977+
},
19631978
email: {
19641979
pkg: '@objectstack/plugin-email',
19651980
export: 'EmailServicePlugin',
@@ -4565,11 +4580,22 @@ export default class Serve extends Command {
45654580
// the static registry + its token in the spec vocabulary (#3265).
45664581
const CAPABILITY_PROVIDERS = Serve.CAPABILITY_PROVIDERS;
45674582

4583+
// Providers THIS resolver has already mounted, by instance. The app's
4584+
// own `plugins[]` alone stopped being the whole answer once two tokens
4585+
// named one provider: `marketplace` and `package-registry` both resolve
4586+
// to PackageServicePlugin, so an app declaring `marketplace` would have
4587+
// the always-on `package-registry` mount a second instance, which
4588+
// `kernel.use` answers by name with a `Plugin superseded` warn (#19387,
4589+
// measured). Pushed only after a successful `kernel.use`, so a provider
4590+
// that failed to load under one token is still attempted — with that
4591+
// token's own required/best-effort semantics — under the next.
4592+
const resolverMounted: unknown[] = [];
4593+
45684594
// Exact identity comparison, NOT substring containment — a consumer named
45694595
// after the capability it consumes must never be mistaken for its
45704596
// provider (#7652). See Serve.providesCapability.
45714597
const hasPluginMatching = (identities: readonly string[]) =>
4572-
Serve.providesCapability(plugins, identities);
4598+
Serve.providesCapability(plugins, identities) || Serve.providesCapability(resolverMounted, identities);
45734599

45744600
for (const cap of requires) {
45754601
const spec = CAPABILITY_PROVIDERS[cap];
@@ -4582,6 +4608,14 @@ export default class Serve extends Command {
45824608
// declared token is a typo that was previously ignored SILENTLY
45834609
// (#3265) — warn loudly. Warn-first: intended to become a hard error
45844610
// once the vocabulary proves complete (Prime Directive #12).
4611+
//
4612+
// A force-appended ALWAYS_ON token must never land here, because it
4613+
// passes both conjuncts below and would mount nothing without a word
4614+
// (#19387: `package-registry` did exactly that). That is pinned before
4615+
// it ships rather than warned about after: every slate token keys a
4616+
// CAPABILITY_PROVIDERS entry or a CAPABILITY_TO_TIER tier
4617+
// (`serve-capability-vocabulary.test.ts`), and `@objectstack/spec` and
4618+
// this package release in one fixed version group.
45854619
if (declaredRequires.has(cap) && !PLATFORM_CAPABILITY_TOKENS.includes(cap)) {
45864620
console.warn(chalk.yellow(
45874621
` ⚠ requires: "${cap}" is not a known platform capability — check for a typo. It was ignored.`,
@@ -4650,7 +4684,9 @@ export default class Serve extends Command {
46504684
));
46514685
}
46524686
}
4653-
await kernel.use(arg !== undefined ? new Ctor(arg) : new Ctor());
4687+
const provider = arg !== undefined ? new Ctor(arg) : new Ctor();
4688+
await kernel.use(provider);
4689+
resolverMounted.push(provider);
46544690
trackPlugin(spec.export);
46554691

46564692
if (spec.extras) {
@@ -4660,7 +4696,9 @@ export default class Serve extends Command {
46604696
const exMod: any = await import(/* webpackIgnore: true */ ex.pkg);
46614697
const ExCtor = exMod[ex.export];
46624698
if (ExCtor) {
4663-
await kernel.use(new ExCtor());
4699+
const extra = new ExCtor();
4700+
await kernel.use(extra);
4701+
resolverMounted.push(extra);
46644702
trackPlugin(ex.export);
46654703
}
46664704
} catch {

‎packages/cli/test/serve-capability-identity.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ const EXPECTED_PROVIDER_NAME: Record<string, string> = {
105105
realtime: 'com.objectstack.service.realtime',
106106
mcp: 'com.objectstack.mcp',
107107
marketplace: 'package-service',
108+
// Same provider as `marketplace` (#19387). The resolver's own-mount dedup
109+
// relies on this: each row's identities contain the name the ONE constructed
110+
// PackageServicePlugin registers, which the drift block below re-derives.
111+
'package-registry': 'package-service',
108112
email: 'com.objectstack.service.email',
109113
sms: 'com.objectstack.service.sms',
110114
sharing: 'com.objectstack.service.sharing',

‎packages/cli/test/serve-capability-vocabulary.test.ts‎

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,16 +49,8 @@ describe('serve capability registries vs spec vocabulary (#3265)', () => {
4949
* `Serve.ALWAYS_ON_CAPABILITIES` is a re-export of the spec slate, so this is
5050
* a SURFACE pin rather than a second copy of the spec-side one: it asserts
5151
* the split survives the hop the CLI takes, and that hop is what decides
52-
* which tokens land in an app's `requires`.
53-
*
54-
* ⚠️ Measured on `serve`'s resolver at c17ff70f3f and deliberately NOT
55-
* asserted: `Serve.CAPABILITY_PROVIDERS` keys `marketplace` and does not yet
56-
* key `package-registry`, so appending this token mounts nothing under
57-
* `objectstack serve` until the runtime half of the same ruling lands
58-
* (#17676 items 2/3/5, the engine lane). Pinning that ABSENCE here would
59-
* turn the engine lane's own fix red for doing the ruled thing, so the gap
60-
* is recorded in words and the pin states only what must hold either side of
61-
* it.
52+
* which tokens land in an app's `requires`. Whether an appended token then
53+
* MOUNTS anything is the next pin's question.
6254
*/
6355
it("appends the package-registry persistence to every app, never the catalogue half (#17676 A')", () => {
6456
expect(Serve.ALWAYS_ON_CAPABILITIES).toContain('package-registry');
@@ -70,6 +62,41 @@ describe('serve capability registries vs spec vocabulary (#3265)', () => {
7062
expect(PLATFORM_CAPABILITY_TOKENS).toContain('package-registry');
7163
expect(PLATFORM_CAPABILITY_TOKENS).toContain('marketplace');
7264
});
65+
66+
/**
67+
* #19387 — the absence direction, which this file used to record in words
68+
* only.
69+
*
70+
* `serve` force-appends every slate token to an app's `requires`, and then
71+
* mounts a token through exactly one of two paths: a CAPABILITY_PROVIDERS
72+
* entry (the `requires` resolver) or a CAPABILITY_TO_TIER entry (the
73+
* dedicated tier blocks — the named list whose own docblock says why those
74+
* tokens carry no provider entry). A slate token on NEITHER path passes both
75+
* conjuncts of the resolver's no-provider branch — it was not declared by
76+
* the app, and it is inside the vocabulary — so it mounts nothing and says
77+
* nothing. That is how `package-registry` sat on the slate inert after the
78+
* #17676 A' carve-out landed its spec half.
79+
*
80+
* ⛔ So a slate entry with no mount goes red HERE, on the pull request that
81+
* adds it, rather than being noticed as a missing service after release.
82+
* The spec slate and this package ship in one fixed release group, so a
83+
* green pin covers every published pairing; the runtime branch is not given
84+
* a warning for a case this makes unreachable.
85+
*/
86+
it('every always-on slate token has a serve mount — a provider entry or a tier (#19387)', () => {
87+
const providerTokens = new Set(Object.keys(Serve.CAPABILITY_PROVIDERS));
88+
const tierTokens = new Set(Object.keys(Serve.CAPABILITY_TO_TIER));
89+
// Non-vacuity: an empty slate would pass the filter below over nothing.
90+
expect(Serve.ALWAYS_ON_CAPABILITIES.length).toBeGreaterThan(0);
91+
const unmounted = Serve.ALWAYS_ON_CAPABILITIES.filter(
92+
(token) => !providerTokens.has(token) && !tierTokens.has(token),
93+
);
94+
expect(
95+
unmounted,
96+
'always-on tokens that `serve` force-appends to every app and then mounts NOTHING for — ' +
97+
'key each one in Serve.CAPABILITY_PROVIDERS at the provider PLATFORM_CAPABILITY_PROVIDERS declares',
98+
).toEqual([]);
99+
});
73100
});
74101

75102
// framework#3366 — the installable-provider registry must classify EVERY

0 commit comments

Comments
 (0)