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
43 changes: 43 additions & 0 deletions .changeset/platform-always-on-capabilities.md
Original file line numberDiff line numberDiff line change
@@ -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.
27 changes: 13 additions & 14 deletions packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/api-surface.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)",
Expand DownExpand Up@@ -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)",
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
55 changes: 55 additions & 0 deletions packages/spec/src/kernel/platform-capabilities.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ import {
PLATFORM_CAPABILITY_TOKENS,
isKnownPlatformCapability,
PLATFORM_CAPABILITY_PROVIDERS,
PLATFORM_ALWAYS_ON_CAPABILITIES,
classifyRequiredCapability,
} from './platform-capabilities';

Expand DownExpand Up@@ -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([]);
});
});
42 changes: 42 additions & 0 deletions packages/spec/src/kernel/platform-capabilities.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -168,6 +168,48 @@ export const PLATFORM_CAPABILITY_PROVIDERS: Readonly<Record<string, PlatformCapa
governance: { package: null, edition: 'cloud', note: 'cloud governance tier' },
});

/**
* The foundational capability slate: what every server-side runtime is expected
* to mount whether or not an app names it in `requires`.
*
* These are the services the platform assumes exist — background work, settings
* persistence, transactional mail, file uploads, notifications, analytics — so
* an app that never declares them still behaves the way its authors (and the
* Studio surfaces) expect.
*
* Published here rather than left on `Serve.ALWAYS_ON_CAPABILITIES` for the same
* reason {@link PLATFORM_CAPABILITY_PROVIDERS} was: **more than one runtime
* mounts this slate.** The CLI's `serve` builds one kernel per process; cloud's
* objectos-runtime builds one per tenant environment and carried its own copy,
* under a CLI comment that merely said such hosts "mirror this list". They had
* already diverged — the hosted slate was missing `sms`, `messaging` and
* `analytics` — so an app that worked under `objectstack serve` could lose
* dataset previews and `notify` deliveries once hosted, silently, with no error
* anywhere (cloud#925, framework#3786). A second list nobody checks is how that
* happens; one exported list is how it stops.
*
* This is the FLOOR, not the ceiling: a host may mount more (cloud adds
* `observability`), and `objectstack serve --preset minimal` opts out entirely.
*/
export const PLATFORM_ALWAYS_ON_CAPABILITIES: readonly string[] = Object.freeze([
// The first six are the pinned foundational prefix — grow the slate AFTER them.
'queue', 'job', 'cache', 'settings', 'email', 'storage',
'sms',
'sharing',
// `messaging` is foundational post-ADR-0030: notifications flow through a
// single ingress (`NotificationService.emit`) — collaboration `@mention` /
// assignment (plugin-audit) and the `notify` flow node both deliver through
// the messaging pipeline, and the Console bell reads its materialization
// (`sys_inbox_message`). Without it those notifications silently no-op.
'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',
]);

/**
* Outcome of classifying one `requires` token against the installed providers:
* - `ok` — provider resolvable (installed); nothing to do.
Expand Down
Loading