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
12 changes: 12 additions & 0 deletions .changeset/18053-package-registry-capability.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions packages/cli/test/serve-capability-vocabulary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 62 additions & 1 deletion packages/spec/src/kernel/platform-capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
});
});
40 changes: 40 additions & 0 deletions packages/spec/src/kernel/platform-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -155,6 +163,20 @@ export const PLATFORM_CAPABILITY_PROVIDERS: Readonly<Record<string, PlatformCapa
realtime: { package: '@objectstack/service-realtime', edition: 'open' },
mcp: { package: '@objectstack/mcp', edition: 'open' },
marketplace: { package: '@objectstack/service-package', edition: 'open' },
// ⚠️ The SAME package as `marketplace` above, and that is a measured fact
// about the distribution rather than a duplicate row: today
// `@objectstack/service-package` ships exactly one plugin
// (`PackageServicePlugin`), and everything it does is the persistence half
// this token names — it creates `sys_packages`, replays it at `start()`,
// and serves publish/get/list/delete over it. The catalogue / browsing
// surface `marketplace` is left naming ships elsewhere entirely
// (`MarketplaceProxyPlugin` / `MarketplaceInstallLocalPlugin` in
// `@objectstack/cloud-connection`, mounted off a resolved marketplace URL,
// ADR-0008). So the two rows agreeing on a package is what the carve-out
// INHERITED, not what it decided: repointing `marketplace` at the browse
// surface moves the runtime's own resolver with it and is #17676's
// engine-lane half, which this row deliberately does not pre-empt.
'package-registry': { package: '@objectstack/service-package', edition: 'open' },
email: { package: '@objectstack/plugin-email', edition: 'open' },
sms: { package: '@objectstack/service-sms', edition: 'open' },
sharing: { package: '@objectstack/plugin-sharing', edition: 'open' },
Expand Down Expand Up @@ -313,6 +335,24 @@ export const PLATFORM_ALWAYS_ON_CAPABILITIES: readonly string[] = Object.freeze(
// authored/previewed inline (Studio) and compiled on the fly. Without it the
// dataset preview + dashboard/report analytics widgets silently no-op.
'analytics',
// `package-registry` is foundational per #17676 ruling A' (decision batch
// #125 item 2): a package is a first-class persistent entity whether or not
// the deployment has a marketplace, so `sys_packages` and its boot
// hydration must exist on a stock boot. Without it `protocol.installPackage`
// / `updatePackage` fall back to their in-memory branches and an
// admin-created package does not survive a restart. It binds into nothing on
// this slate (its only hard requirement is the ObjectQL engine, which is not
// a capability token), so it joins the TAIL like any other reader.
//
// ⚠️ SCOPE, measured on `serve`'s capability resolver at c17ff70f3f: a slate
// entry is force-appended to every app's `requires`, and the CLI then mounts
// it only if `Serve.CAPABILITY_PROVIDERS` keys the token. That registry keys
// `marketplace`, not this token, so under `objectstack serve` this entry is
// inert until the runtime half of the same ruling lands (#17676 items 2/3/5,
// the engine lane). The declaration is deliberately first: it is the single
// list both runtimes read, and the thing the runtime half is written
// against.
'package-registry',
]);

/**
Expand Down
Loading