diff --git a/.changeset/18053-package-registry-capability.md b/.changeset/18053-package-registry-capability.md new file mode 100644 index 00000000000..9fe516aa53b --- /dev/null +++ b/.changeset/18053-package-registry-capability.md @@ -0,0 +1,12 @@ +--- +"@objectstack/spec": minor +--- + +`package-registry` is a platform capability of its own, and an always-on one: the `sys_packages` container and the boot hydration that replays it no longer hide behind the `marketplace` token, which is left naming only the optional catalogue / browsing half (#18053, director ruling A′ on #17676). + +A package is a first-class persistent entity whether or not a deployment has a store — an admin-created package does not depend on the marketplace existing. Until now the only way to get the persistence was `requires: ['marketplace']`, so a stock boot had no `sys_packages` at all and `protocol.installPackage` / `updatePackage` fell back to their in-memory branches: an admin-created package did not survive a restart, under a token advertising a store that was not there. + +- **`PLATFORM_CAPABILITY_TOKENS` gains `package-registry`** — one new token, none removed, so `marketplace` keeps working exactly as before for anyone who declares it. The vocabulary is a closed set validated by `defineStack`, so this widens what an app may write, and nothing it already writes stops parsing. +- **`PLATFORM_ALWAYS_ON_CAPABILITIES` gains `package-registry` at the tail.** The slate's ordering contract is a role, not a count: the entry binds into nothing on the slate (its one hard requirement is the ObjectQL engine, which is not a capability token), so it joins after every bind target like any other reader. `--preset minimal` still opts out of the whole slate. +- **`PLATFORM_CAPABILITY_PROVIDERS` gains a row naming `@objectstack/service-package`, `open` edition** — the same package `marketplace` names today, because that package ships exactly one plugin and everything it does is the persistence half. The catalogue surface `marketplace` is left naming ships in `@objectstack/cloud-connection` and is mounted off a resolved marketplace URL, never through the token; repointing the `marketplace` row at it moves the runtime's own resolver with it and is the engine-lane half of the same ruling (#17676 items 2/3/5). +- ⚠️ **Declaration first, runtime second — measured, not assumed.** `objectstack serve` mounts a slate entry only when `Serve.CAPABILITY_PROVIDERS` keys the token, and that registry keys `marketplace`. Until the engine-lane half lands, appending `package-registry` mounts nothing under the standalone CLI: a stock boot is exactly as capable as before, no more and no less. This package is the single list both the CLI and cloud's per-tenant runtime read, which is why the declaration is the half that goes first. diff --git a/packages/cli/test/serve-capability-vocabulary.test.ts b/packages/cli/test/serve-capability-vocabulary.test.ts index 859c69711ec..48a73884ea0 100644 --- a/packages/cli/test/serve-capability-vocabulary.test.ts +++ b/packages/cli/test/serve-capability-vocabulary.test.ts @@ -42,6 +42,34 @@ describe('serve capability registries vs spec vocabulary (#3265)', () => { expect(PLATFORM_CAPABILITY_TOKENS).toContain(token); } }); + + /** + * #17676 ruling A' item 1, read through the array `serve` actually appends. + * + * `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. + */ + it("appends the package-registry persistence to every app, never the catalogue half (#17676 A')", () => { + expect(Serve.ALWAYS_ON_CAPABILITIES).toContain('package-registry'); + // The other half of the ruling: browsing stays optional, so an app that + // wants a store still declares it. + expect(Serve.ALWAYS_ON_CAPABILITIES).not.toContain('marketplace'); + // …and the split ADDED a token rather than moving one out — both halves + // stay resolvable spellings for `requires`. + expect(PLATFORM_CAPABILITY_TOKENS).toContain('package-registry'); + expect(PLATFORM_CAPABILITY_TOKENS).toContain('marketplace'); + }); }); // framework#3366 — the installable-provider registry must classify EVERY diff --git a/packages/spec/src/kernel/platform-capabilities.test.ts b/packages/spec/src/kernel/platform-capabilities.test.ts index 7118bace3e3..b2027bedf5e 100644 --- a/packages/spec/src/kernel/platform-capabilities.test.ts +++ b/packages/spec/src/kernel/platform-capabilities.test.ts @@ -269,7 +269,7 @@ describe('PLATFORM_ALWAYS_ON_CAPABILITIES', () => { [...PLATFORM_ALWAYS_ON_CAPABILITIES, 'secrets'], [...BIND_TARGETS, 'secrets'], ), - ).toEqual(['email', 'storage', 'sms', 'sharing', 'messaging', 'analytics']); + ).toEqual(['email', 'storage', 'sms', 'sharing', 'messaging', 'analytics', 'package-registry']); }); it('every member is a real platform capability token', () => { @@ -299,3 +299,64 @@ describe('PLATFORM_ALWAYS_ON_CAPABILITIES', () => { expect(gated).toEqual([]); }); }); + +/** + * #17676 ruling A' item 1 (decision batch #125 item 2, maintainer verbatim + * 「同意」): the package-registry PERSISTENCE — the `sys_packages` container and + * the boot hydration that replays it — is carved out of `marketplace` into an + * always-on core capability named for what it is; `marketplace` is left naming + * only the optional catalogue / browsing half. + * + * BOTH halves are asserted here, because only the pair states the ruling. A + * case that checked the new token alone would stay green on a slate that + * force-mounted `marketplace` as well — which is the outcome the ruling refused + * ("a token advertising a store that is not there"), and the reason the split + * exists rather than a rename. + */ +describe("package-registry carve-out (#17676 ruling A')", () => { + it('is its own vocabulary token — the persistence is named, not spelled `marketplace`', () => { + expect(PLATFORM_CAPABILITY_TOKENS).toContain('package-registry'); + expect(isKnownPlatformCapability('package-registry')).toBe(true); + // The catalogue half keeps its token: this is a SPLIT, so the vocabulary + // must carry two tokens afterwards, not one renamed one. + expect(PLATFORM_CAPABILITY_TOKENS).toContain('marketplace'); + }); + + it('has exactly ONE spelling — no second dialect for the same capability', () => { + // The single-list rule this file already enforces against the removed + // camelCase aliases, applied to the new token while its spelling is still + // young: the near-misses a later author could reach for must stay unknown, + // or `requires` grows two ways to ask for one service and the runtimes are + // free to resolve different ones. + for (const nearMiss of ['packages', 'package', 'sys-packages', 'package-store', 'packageRegistry']) { + expect(PLATFORM_CAPABILITY_TOKENS, `'${nearMiss}' must not be a second spelling`).not.toContain( + nearMiss, + ); + expect(isKnownPlatformCapability(nearMiss)).toBe(false); + } + }); + + it('is mounted ALWAYS, and the catalogue half is NOT — the ruling, both ways round', () => { + expect(PLATFORM_ALWAYS_ON_CAPABILITIES).toContain('package-registry'); + // Browsing stays optional: an app that wants a store still declares it. + expect(PLATFORM_ALWAYS_ON_CAPABILITIES).not.toContain('marketplace'); + }); + + it('resolves through an open-edition provider — a floor entry must mount without a licence', () => { + const provider = PLATFORM_CAPABILITY_PROVIDERS['package-registry']; + expect(provider, 'the carved-out token needs its own provider row').toBeTruthy(); + expect(provider.edition).toBe('open'); + expect(provider.package).toBe('@objectstack/service-package'); + }); + + it('classifies like any other open-edition service — never as a typo', () => { + // The authoring-time half: `defineStack` rejects a token the vocabulary + // does not carry, and the preflight reads the classifier. A carve-out that + // added the slate entry without the provider row would surface HERE, as an + // `unknown` on a token every app now force-declares. + expect(classifyRequiredCapability('package-registry', () => true).status).toBe('ok'); + const absent = classifyRequiredCapability('package-registry', () => false); + expect(absent.status).toBe('installable'); + expect(absent.provider?.package).toBe('@objectstack/service-package'); + }); +}); diff --git a/packages/spec/src/kernel/platform-capabilities.ts b/packages/spec/src/kernel/platform-capabilities.ts index 70219897a7b..b6c0292c5d2 100644 --- a/packages/spec/src/kernel/platform-capabilities.ts +++ b/packages/spec/src/kernel/platform-capabilities.ts @@ -47,7 +47,15 @@ export const PLATFORM_CAPABILITY_TOKENS: readonly string[] = Object.freeze([ 'triggers', 'realtime', 'mcp', + // `marketplace` and `package-registry` are two capabilities, not one token + // spelled twice (#17676 ruling A' item 1). A package is a first-class + // persistent entity whether or not the deployment has a store, so the + // PERSISTENCE half — the `sys_packages` container and the boot hydration + // that replays it — is a core capability named for what it is and mounted + // always (see {@link PLATFORM_ALWAYS_ON_CAPABILITIES}); `marketplace` is + // left naming only the optional catalogue / browsing half. 'marketplace', + 'package-registry', 'email', 'sms', 'sharing', @@ -155,6 +163,20 @@ export const PLATFORM_CAPABILITY_PROVIDERS: Readonly