Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/19387-package-registry-mount.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/cli': patch
'@objectstack/metadata-protocol': patch
---

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)

Clause-②: no

`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.

- **`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.
- **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'`.
- **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.
- `--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.
- **`@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.
44 changes: 41 additions & 3 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1960,6 +1960,21 @@ export default class Serve extends Command {
export: 'PackageServicePlugin',
identities: ['package-service', 'PackageServicePlugin'],
},
// The always-on persistence half of the `marketplace` / `package-registry`
// split (#17676 ruling A' items 1-2): `sys_packages` and its boot
// hydration, so `protocol.installPackage` / `updatePackage` find the
// `package` service on a stock boot. Keyed at the provider the spec's
// PLATFORM_CAPABILITY_PROVIDERS row declares for this token — the SAME
// package and plugin as `marketplace` above, because that is what the spec
// map says today. Repointing `marketplace` at the browse surface starts at
// that spec row, and this table follows it; until then an app declaring
// `marketplace` gets ONE PackageServicePlugin, not two — see
// `resolverMounted` in the capability resolver.
'package-registry': {
pkg: '@objectstack/service-package',
export: 'PackageServicePlugin',
identities: ['package-service', 'PackageServicePlugin'],
},
email: {
pkg: '@objectstack/plugin-email',
export: 'EmailServicePlugin',
Expand Down Expand Up @@ -4565,11 +4580,22 @@ export default class Serve extends Command {
// the static registry + its token in the spec vocabulary (#3265).
const CAPABILITY_PROVIDERS = Serve.CAPABILITY_PROVIDERS;

// Providers THIS resolver has already mounted, by instance. The app's
// own `plugins[]` alone stopped being the whole answer once two tokens
// named one provider: `marketplace` and `package-registry` both resolve
// to PackageServicePlugin, so an app declaring `marketplace` would have
// the always-on `package-registry` mount a second instance, which
// `kernel.use` answers by name with a `Plugin superseded` warn (#19387,
// measured). Pushed only after a successful `kernel.use`, so a provider
// that failed to load under one token is still attempted — with that
// token's own required/best-effort semantics — under the next.
const resolverMounted: unknown[] = [];

// Exact identity comparison, NOT substring containment — a consumer named
// after the capability it consumes must never be mistaken for its
// provider (#7652). See Serve.providesCapability.
const hasPluginMatching = (identities: readonly string[]) =>
Serve.providesCapability(plugins, identities);
Serve.providesCapability(plugins, identities) || Serve.providesCapability(resolverMounted, identities);

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

if (spec.extras) {
Expand All @@ -4660,7 +4696,9 @@ export default class Serve extends Command {
const exMod: any = await import(/* webpackIgnore: true */ ex.pkg);
const ExCtor = exMod[ex.export];
if (ExCtor) {
await kernel.use(new ExCtor());
const extra = new ExCtor();
await kernel.use(extra);
resolverMounted.push(extra);
trackPlugin(ex.export);
}
} catch {
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/test/serve-capability-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ const EXPECTED_PROVIDER_NAME: Record<string, string> = {
realtime: 'com.objectstack.service.realtime',
mcp: 'com.objectstack.mcp',
marketplace: 'package-service',
// Same provider as `marketplace` (#19387). The resolver's own-mount dedup
// relies on this: each row's identities contain the name the ONE constructed
// PackageServicePlugin registers, which the drift block below re-derives.
'package-registry': 'package-service',
email: 'com.objectstack.service.email',
sms: 'com.objectstack.service.sms',
sharing: 'com.objectstack.service.sharing',
Expand Down
47 changes: 37 additions & 10 deletions packages/cli/test/serve-capability-vocabulary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,8 @@ describe('serve capability registries vs spec vocabulary (#3265)', () => {
* `Serve.ALWAYS_ON_CAPABILITIES` is a re-export of the spec slate, so this is
* a SURFACE pin rather than a second copy of the spec-side one: it asserts
* the split survives the hop the CLI takes, and that hop is what decides
* which tokens land in an app's `requires`.
*
* ⚠️ Measured on `serve`'s resolver at c17ff70f3f and deliberately NOT
* asserted: `Serve.CAPABILITY_PROVIDERS` keys `marketplace` and does not yet
* key `package-registry`, so appending this token mounts nothing under
* `objectstack serve` until the runtime half of the same ruling lands
* (#17676 items 2/3/5, the engine lane). Pinning that ABSENCE here would
* turn the engine lane's own fix red for doing the ruled thing, so the gap
* is recorded in words and the pin states only what must hold either side of
* it.
* which tokens land in an app's `requires`. Whether an appended token then
* MOUNTS anything is the next pin's question.
*/
it("appends the package-registry persistence to every app, never the catalogue half (#17676 A')", () => {
expect(Serve.ALWAYS_ON_CAPABILITIES).toContain('package-registry');
Expand All @@ -70,6 +62,41 @@ describe('serve capability registries vs spec vocabulary (#3265)', () => {
expect(PLATFORM_CAPABILITY_TOKENS).toContain('package-registry');
expect(PLATFORM_CAPABILITY_TOKENS).toContain('marketplace');
});

/**
* #19387 — the absence direction, which this file used to record in words
* only.
*
* `serve` force-appends every slate token to an app's `requires`, and then
* mounts a token through exactly one of two paths: a CAPABILITY_PROVIDERS
* entry (the `requires` resolver) or a CAPABILITY_TO_TIER entry (the
* dedicated tier blocks — the named list whose own docblock says why those
* tokens carry no provider entry). A slate token on NEITHER path passes both
* conjuncts of the resolver's no-provider branch — it was not declared by
* the app, and it is inside the vocabulary — so it mounts nothing and says
* nothing. That is how `package-registry` sat on the slate inert after the
* #17676 A' carve-out landed its spec half.
*
* ⛔ So a slate entry with no mount goes red HERE, on the pull request that
* adds it, rather than being noticed as a missing service after release.
* The spec slate and this package ship in one fixed release group, so a
* green pin covers every published pairing; the runtime branch is not given
* a warning for a case this makes unreachable.
*/
it('every always-on slate token has a serve mount — a provider entry or a tier (#19387)', () => {
const providerTokens = new Set(Object.keys(Serve.CAPABILITY_PROVIDERS));
const tierTokens = new Set(Object.keys(Serve.CAPABILITY_TO_TIER));
// Non-vacuity: an empty slate would pass the filter below over nothing.
expect(Serve.ALWAYS_ON_CAPABILITIES.length).toBeGreaterThan(0);
const unmounted = Serve.ALWAYS_ON_CAPABILITIES.filter(
(token) => !providerTokens.has(token) && !tierTokens.has(token),
);
expect(
unmounted,
'always-on tokens that `serve` force-appends to every app and then mounts NOTHING for — ' +
'key each one in Serve.CAPABILITY_PROVIDERS at the provider PLATFORM_CAPABILITY_PROVIDERS declares',
).toEqual([]);
});
});

// framework#3366 — the installable-provider registry must classify EVERY
Expand Down
Loading
Loading