diff --git a/.changeset/platform-always-on-capabilities.md b/.changeset/platform-always-on-capabilities.md new file mode 100644 index 0000000000..265d7bf5ae --- /dev/null +++ b/.changeset/platform-always-on-capabilities.md @@ -0,0 +1,43 @@ +--- +"@objectstack/spec": minor +"@objectstack/cli": patch +--- + +feat(spec,cli): publish the foundational capability slate so every runtime reads one list (#3786, cloud#925) + +`Serve.ALWAYS_ON_CAPABILITIES` — the capabilities auto-added to every app's +`requires` outside `--preset minimal` — was declared in the CLI, under a comment +noting that "cloud / multi-environment hosts (which live in a separate +distribution) mirror this list on their per-project kernels." + +Nothing made that true, and they had already diverged. Cloud's per-tenant slate +was missing **`sms`, `messaging` and `analytics`**, so an app that worked under +`objectstack serve` could lose `notify` deliveries and dataset previews once +hosted — silently, with no error anywhere. The framework's own comment on +`analytics` spells out the failure mode it was made always-on to prevent: +"Without it the dataset preview + dashboard/report analytics widgets silently +no-op." + +**New export: `PLATFORM_ALWAYS_ON_CAPABILITIES`** (`@objectstack/spec`, and +`@objectstack/spec/kernel`). The slate and its per-entry rationale now live +beside `PLATFORM_CAPABILITY_PROVIDERS` — the map published for exactly this +reason one release earlier, "so cloud's objectos-runtime and the framework CLI +classify a `requires` token identically". `Serve.ALWAYS_ON_CAPABILITIES` is now +a re-export of it, kept as a stable handle for existing callers rather than +deleted: one declaration, two readers. + +Four assertions make the single declaration trustworthy for both of them — the +slate is frozen, deduped and non-empty; its foundational prefix +(`queue, job, cache, settings, email, storage`) is pinned, because mount order +matters when services bind to each other during `kernel:ready`; every member is +a real `PLATFORM_CAPABILITY_TOKENS` entry; every member has a declared provider; +and every member is `edition: 'open'`, since a floor the open distribution +cannot mount is not a floor. Verified by mutation: an unknown token, an +enterprise-edition token, and a reordered prefix each turn the gate red. + +**No behaviour change.** The published slate is byte-identical to the list the +CLI already had, and `serve-defaults.test.ts` / `serve-capability-vocabulary.test.ts` +pass unchanged. What changes is that there is now something to derive from: +cloud's hosted runtime can drop its copy and read this instead, which is the +follow-up cloud#925 left open — it lands there once the `.objectstack-sha` pin +moves past this release. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 07d210fdee..d5276416f1 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -11,7 +11,7 @@ import { mergeBootConfig } from '../utils/merge-boot-config.js'; import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js'; import { resolveDriverType, resolveStorageDefinition, UnsupportedDriverError } from '../utils/storage-driver.js'; import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveTenancyPosture, resolveAllowDegradedTenancy, isMcpServerEnabled, stampSearchPinyinEnabled, isModuleNotFoundError } from '@objectstack/types'; -import { PLATFORM_CAPABILITY_TOKENS } from '@objectstack/spec/kernel'; +import { PLATFORM_CAPABILITY_TOKENS, PLATFORM_ALWAYS_ON_CAPABILITIES } from '@objectstack/spec/kernel'; import { missingProviderMessage } from '../utils/capability-preflight.js'; import { resolveObjectStackHome } from '@objectstack/runtime'; import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level.js'; @@ -200,20 +200,19 @@ export default class Serve extends Command { * * Opt out: `objectstack serve --preset minimal`. * - * Cloud / multi-environment hosts (which live in a separate distribution) - * mirror this list on their per-project kernels. + * DERIVED from `@objectstack/spec`'s `PLATFORM_ALWAYS_ON_CAPABILITIES`, where + * the slate and its per-entry rationale now live. This used to BE the + * declaration, under a comment noting that cloud / multi-environment hosts + * "mirror this list on their per-project kernels" — with nothing making that + * true. They had diverged: the hosted slate was missing `sms`, `messaging` and + * `analytics`, so an app that worked under `objectstack serve` silently lost + * dataset previews and `notify` deliveries once hosted (cloud#925, #3786). + * + * Kept as a re-export rather than deleted so `Serve.ALWAYS_ON_CAPABILITIES` + * stays a stable handle for existing callers and tests — one declaration, two + * readers. */ - static readonly ALWAYS_ON_CAPABILITIES: readonly string[] = Object.freeze([ - // The first six form the pinned foundational prefix (see - // serve-defaults.test.ts) — grow the slate AFTER them. - 'queue', 'job', 'cache', 'settings', 'email', 'storage', 'sms', 'sharing', 'messaging', - // `analytics` is foundational post-ADR-0021: the AnalyticsService backs the - // dataset/cube query endpoints (`/api/v1/analytics/*`). It must exist even - // when an app declares no `analyticsCubes`, because a `dataset` can be - // authored/previewed inline (Studio) and compiled on the fly. Without it the - // dataset preview + dashboard/report analytics widgets silently no-op. - 'analytics', - ]); + static readonly ALWAYS_ON_CAPABILITIES: readonly string[] = PLATFORM_ALWAYS_ON_CAPABILITIES; /** * Auto-registered plugin tiers. Plugins explicitly listed in diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 4f5ffbb369..8b7b7484c4 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -81,6 +81,7 @@ "ObjectStackDefinitionSchema (const)", "ObjectStackSchema (const)", "P (const)", + "PLATFORM_ALWAYS_ON_CAPABILITIES (const)", "PLATFORM_CAPABILITY_PROVIDERS (const)", "PLATFORM_CAPABILITY_TOKENS (const)", "PlatformCapabilityProvider (interface)", @@ -1622,6 +1623,7 @@ "OpsFilePathSchema (const)", "OpsPluginStructure (type)", "OpsPluginStructureSchema (const)", + "PLATFORM_ALWAYS_ON_CAPABILITIES (const)", "PLATFORM_CAPABILITY_PROVIDERS (const)", "PLATFORM_CAPABILITY_TOKENS (const)", "PROTOCOL_MAJOR (const)", diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index 1897da7f06..0ca5dd0347 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -135,6 +135,9 @@ export { PLATFORM_CAPABILITY_TOKENS, isKnownPlatformCapability, PLATFORM_CAPABILITY_PROVIDERS, + // The foundational slate every server-side runtime mounts (cloud#925, #3786) — + // one declaration for `objectstack serve` and cloud's per-tenant runtime alike. + PLATFORM_ALWAYS_ON_CAPABILITIES, classifyRequiredCapability, type CapabilityEdition, type PlatformCapabilityProvider, diff --git a/packages/spec/src/kernel/platform-capabilities.test.ts b/packages/spec/src/kernel/platform-capabilities.test.ts index a247e17306..16c3b71954 100644 --- a/packages/spec/src/kernel/platform-capabilities.test.ts +++ b/packages/spec/src/kernel/platform-capabilities.test.ts @@ -3,6 +3,7 @@ import { PLATFORM_CAPABILITY_TOKENS, isKnownPlatformCapability, PLATFORM_CAPABILITY_PROVIDERS, + PLATFORM_ALWAYS_ON_CAPABILITIES, classifyRequiredCapability, } from './platform-capabilities'; @@ -114,3 +115,57 @@ describe('classifyRequiredCapability (#3366)', () => { expect(seen).toEqual(['@objectstack/service-automation']); }); }); + +/** + * The foundational slate (cloud#925, #3786). + * + * It moved here from `Serve.ALWAYS_ON_CAPABILITIES` because two runtimes mount + * it — `objectstack serve` and cloud's per-tenant objectos-runtime — and the + * second kept a copy under a comment that only said hosts "mirror this list". + * They had already diverged by three entries. These assertions are what makes + * the single declaration trustworthy for both readers. + */ +describe('PLATFORM_ALWAYS_ON_CAPABILITIES', () => { + it('is frozen, non-empty and free of duplicates', () => { + expect(Object.isFrozen(PLATFORM_ALWAYS_ON_CAPABILITIES)).toBe(true); + expect(PLATFORM_ALWAYS_ON_CAPABILITIES.length).toBeGreaterThan(0); + expect(PLATFORM_ALWAYS_ON_CAPABILITIES).toHaveLength( + new Set(PLATFORM_ALWAYS_ON_CAPABILITIES).size, + ); + }); + + it('pins the foundational prefix — grow the slate AFTER these six', () => { + // Order matters at mount time: settings/queue/job must precede the services + // that bind to them during their own `kernel:ready` phase. + expect(PLATFORM_ALWAYS_ON_CAPABILITIES.slice(0, 6)).toEqual([ + 'queue', 'job', 'cache', 'settings', 'email', 'storage', + ]); + }); + + it('every member is a real platform capability token', () => { + // A slate entry outside the vocabulary could never be classified, and every + // runtime mounting the slate would force-add a token nothing provides. + const unknown = PLATFORM_ALWAYS_ON_CAPABILITIES.filter( + (c) => !(PLATFORM_CAPABILITY_TOKENS as readonly string[]).includes(c), + ); + expect(unknown).toEqual([]); + }); + + it('every member has a declared provider', () => { + // The slate is force-mounted, so a member with no provider entry would be + // added to every app's `requires` and then fail to resolve for all of them. + const providerless = PLATFORM_ALWAYS_ON_CAPABILITIES.filter( + (c) => !PLATFORM_CAPABILITY_PROVIDERS[c], + ); + expect(providerless).toEqual([]); + }); + + it('is open-edition only — the floor must be mountable without a licence', () => { + // A cloud/enterprise-edition entry in the FLOOR would make the open + // distribution unable to satisfy its own defaults. + const gated = PLATFORM_ALWAYS_ON_CAPABILITIES.filter( + (c) => PLATFORM_CAPABILITY_PROVIDERS[c]?.edition !== 'open', + ); + expect(gated).toEqual([]); + }); +}); diff --git a/packages/spec/src/kernel/platform-capabilities.ts b/packages/spec/src/kernel/platform-capabilities.ts index 9787579609..0e50ecbbc7 100644 --- a/packages/spec/src/kernel/platform-capabilities.ts +++ b/packages/spec/src/kernel/platform-capabilities.ts @@ -168,6 +168,48 @@ export const PLATFORM_CAPABILITY_PROVIDERS: Readonly