267 lines
8.7 KiB
TypeScript
267 lines
8.7 KiB
TypeScript
import type { InstalledOfflineCommandContribution } from "./offline-command.ts";
|
|
import type { InstalledServiceWorkerSelection } from "./service-worker.ts";
|
|
import type { InstalledWebWorkerContribution } from "./web-worker.ts";
|
|
|
|
/**
|
|
* §3.4–§3.6. Optional runtime capability selection and hosting.
|
|
*
|
|
* Source contribution decides what is installed. Runtime Config may only carry
|
|
* `DEFAULT | DISABLED`, so a configuration document can never switch on a
|
|
* capability whose source is absent.
|
|
*/
|
|
|
|
export type RuntimeCapabilityOverride = "DEFAULT" | "DISABLED";
|
|
|
|
export type RuntimeStopReason =
|
|
| "APPLICATION_SHUTDOWN"
|
|
| "SCOPE_FENCED"
|
|
| "FEATURE_DISABLED"
|
|
| "HIDDEN_POLICY"
|
|
| "INCIDENT_CONTAINMENT";
|
|
|
|
export interface RuntimeLifecycle {
|
|
start(): void | Promise<void>;
|
|
stop(reason: RuntimeStopReason): void | Promise<void>;
|
|
dispose(): void | Promise<void>;
|
|
}
|
|
|
|
/** §20.2. `FAILED -> STARTING` is never automatic. */
|
|
export type RuntimeLifecycleState =
|
|
| "NEW"
|
|
| "STARTING"
|
|
| "RUNNING"
|
|
| "STOPPING"
|
|
| "STOPPED"
|
|
| "FAILED"
|
|
| "DISPOSING"
|
|
| "DISPOSED";
|
|
|
|
/** §22.12. Health is per capability; there is no global `healthy` boolean. */
|
|
export type RuntimeHealth =
|
|
| "AVAILABLE"
|
|
| "DEGRADED"
|
|
| "UNAVAILABLE"
|
|
| "INCOMPATIBLE"
|
|
| "DISABLED";
|
|
|
|
// §13.2. Realtime product contribution.
|
|
export type RealtimeEffectKind =
|
|
| "INVALIDATE_TOPICS"
|
|
| "APPLY_AUTHORITATIVE_DELTA"
|
|
| "EPHEMERAL_NOTIFICATION";
|
|
|
|
export interface InstalledRealtimeEventEffect {
|
|
readonly eventType: string;
|
|
readonly mapperId: string;
|
|
readonly effect: RealtimeEffectKind;
|
|
readonly invalidationTopics: readonly string[];
|
|
}
|
|
|
|
export type InstalledRealtimeTransport =
|
|
| Readonly<{ kind: "SSE"; endpointId: string }>
|
|
| Readonly<{ kind: "WEBSOCKET"; endpointId: string }>
|
|
| Readonly<{ kind: "POLLING"; operationId: string; intervalMs: number }>;
|
|
|
|
export interface InstalledRealtimeContribution {
|
|
readonly contributionId: string;
|
|
readonly featureId: string;
|
|
readonly contractSourcePackageId: string;
|
|
readonly streamId: string;
|
|
readonly recoveryMode: "CURSOR" | "SNAPSHOT_ONLY" | "SESSION_REBUILD";
|
|
readonly eventEffects: readonly InstalledRealtimeEventEffect[];
|
|
readonly transport: InstalledRealtimeTransport;
|
|
}
|
|
|
|
export const REALTIME_CONTRIBUTION_BOUNDS = Object.freeze({
|
|
contributions: 64,
|
|
streams: 128,
|
|
eventTypes: 512,
|
|
effectsPerEvent: 8,
|
|
invalidationTopicsPerEvent: 32,
|
|
minimumPollingIntervalMs: 5_000,
|
|
maximumPollingIntervalMs: 300_000,
|
|
});
|
|
|
|
export interface InstalledRuntimeCapabilities {
|
|
readonly realtime: readonly InstalledRealtimeContribution[];
|
|
readonly webWorkers: readonly InstalledWebWorkerContribution[];
|
|
readonly serviceWorker: InstalledServiceWorkerSelection | null;
|
|
readonly offlineCommands: InstalledOfflineCommandContribution | null;
|
|
}
|
|
|
|
export type CapabilityOverrideMap = Readonly<{
|
|
REALTIME: RuntimeCapabilityOverride;
|
|
WEB_WORKER: RuntimeCapabilityOverride;
|
|
SERVICE_WORKER: RuntimeCapabilityOverride;
|
|
OFFLINE_COMMANDS: RuntimeCapabilityOverride;
|
|
}>;
|
|
|
|
/**
|
|
* The effective selection after applying runtime overrides. `serviceWorkerMode`
|
|
* keeps the §3.6 persistent-registration exception explicit: a statically
|
|
* `ACTIVE` worker that runtime config disables still performs exactly one
|
|
* owned-registration lookup and at most one unregister, and deletes no cache.
|
|
*/
|
|
export type ResolvedRuntimeCapabilities = Readonly<{
|
|
realtime: readonly InstalledRealtimeContribution[];
|
|
webWorkers: readonly InstalledWebWorkerContribution[];
|
|
serviceWorker: InstalledServiceWorkerSelection | null;
|
|
serviceWorkerDisabledCleanup: boolean;
|
|
offlineCommands: InstalledOfflineCommandContribution | null;
|
|
}>;
|
|
|
|
export function resolveRuntimeCapabilities(
|
|
installed: InstalledRuntimeCapabilities,
|
|
overrides: CapabilityOverrideMap,
|
|
): ResolvedRuntimeCapabilities {
|
|
const realtimeDisabled = overrides.REALTIME === "DISABLED";
|
|
const workersDisabled = overrides.WEB_WORKER === "DISABLED";
|
|
const serviceWorkerDisabled = overrides.SERVICE_WORKER === "DISABLED";
|
|
const offlineDisabled = overrides.OFFLINE_COMMANDS === "DISABLED";
|
|
|
|
return Object.freeze({
|
|
realtime: realtimeDisabled ? Object.freeze([]) : installed.realtime,
|
|
webWorkers: workersDisabled ? Object.freeze([]) : installed.webWorkers,
|
|
serviceWorker: serviceWorkerDisabled ? null : installed.serviceWorker,
|
|
serviceWorkerDisabledCleanup:
|
|
serviceWorkerDisabled && installed.serviceWorker?.mode === "ACTIVE",
|
|
offlineCommands: offlineDisabled ? null : installed.offlineCommands,
|
|
});
|
|
}
|
|
|
|
export function validateRealtimeContributions(
|
|
contributions: readonly InstalledRealtimeContribution[],
|
|
): readonly InstalledRealtimeContribution[] {
|
|
const bounds = REALTIME_CONTRIBUTION_BOUNDS;
|
|
if (contributions.length > bounds.contributions) {
|
|
throw new TypeError("Realtime contributions exceed their bound.");
|
|
}
|
|
const contributionIds = new Set<string>();
|
|
const streamIds = new Set<string>();
|
|
let eventTypeCount = 0;
|
|
|
|
for (const contribution of contributions) {
|
|
if (
|
|
!contribution.contributionId ||
|
|
contributionIds.has(contribution.contributionId)
|
|
) {
|
|
throw new TypeError("Duplicate realtime contribution identity.");
|
|
}
|
|
contributionIds.add(contribution.contributionId);
|
|
streamIds.add(contribution.streamId);
|
|
if (streamIds.size > bounds.streams) {
|
|
throw new TypeError("Realtime streams exceed their bound.");
|
|
}
|
|
|
|
const transport = contribution.transport;
|
|
if (transport.kind === "POLLING") {
|
|
if (
|
|
!Number.isSafeInteger(transport.intervalMs) ||
|
|
transport.intervalMs < bounds.minimumPollingIntervalMs ||
|
|
transport.intervalMs > bounds.maximumPollingIntervalMs
|
|
) {
|
|
throw new TypeError(
|
|
`Realtime polling interval is out of range: ${contribution.contributionId}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const seenEvents = new Map<string, number>();
|
|
for (const effect of contribution.eventEffects) {
|
|
eventTypeCount += 1;
|
|
if (eventTypeCount > bounds.eventTypes) {
|
|
throw new TypeError("Realtime event types exceed their bound.");
|
|
}
|
|
const count = (seenEvents.get(effect.eventType) ?? 0) + 1;
|
|
if (count > bounds.effectsPerEvent) {
|
|
throw new TypeError(
|
|
`Realtime effects per event exceeded: ${effect.eventType}`,
|
|
);
|
|
}
|
|
seenEvents.set(effect.eventType, count);
|
|
if (
|
|
effect.invalidationTopics.length > bounds.invalidationTopicsPerEvent
|
|
) {
|
|
throw new TypeError(
|
|
`Realtime invalidation topics exceeded: ${effect.eventType}`,
|
|
);
|
|
}
|
|
if (
|
|
effect.effect === "INVALIDATE_TOPICS" &&
|
|
effect.invalidationTopics.length === 0
|
|
) {
|
|
throw new TypeError(
|
|
`INVALIDATE_TOPICS effect declares no topic: ${effect.eventType}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return Object.freeze([...contributions]);
|
|
}
|
|
|
|
export type RuntimeCapabilityId =
|
|
| "REALTIME"
|
|
| "WEB_WORKER"
|
|
| "SERVICE_WORKER"
|
|
| "OFFLINE_COMMANDS";
|
|
|
|
/**
|
|
* §3.5. A bounded, serialisable view of one capability. `selected` is the
|
|
* static SSOT count and `active` is what survived the runtime override, so the
|
|
* difference between the two is exactly the operator's effect. An override can
|
|
* only subtract, which is why a never-selected capability stays at zero.
|
|
*/
|
|
export type RuntimeCapabilityStatus = Readonly<{
|
|
capabilityId: RuntimeCapabilityId;
|
|
selected: number;
|
|
active: number;
|
|
override: RuntimeCapabilityOverride;
|
|
}>;
|
|
|
|
export type RuntimeCapabilitySnapshot = readonly RuntimeCapabilityStatus[];
|
|
|
|
const CAPABILITY_ORDER = Object.freeze([
|
|
"REALTIME",
|
|
"WEB_WORKER",
|
|
"SERVICE_WORKER",
|
|
"OFFLINE_COMMANDS",
|
|
] as const);
|
|
|
|
export function describeRuntimeCapabilities(
|
|
installed: InstalledRuntimeCapabilities,
|
|
overrides: CapabilityOverrideMap,
|
|
): RuntimeCapabilitySnapshot {
|
|
const resolved = resolveRuntimeCapabilities(installed, overrides);
|
|
const counts: Readonly<
|
|
Record<RuntimeCapabilityId, Readonly<{ selected: number; active: number }>>
|
|
> = Object.freeze({
|
|
REALTIME: Object.freeze({
|
|
selected: installed.realtime.length,
|
|
active: resolved.realtime.length,
|
|
}),
|
|
WEB_WORKER: Object.freeze({
|
|
selected: installed.webWorkers.length,
|
|
active: resolved.webWorkers.length,
|
|
}),
|
|
SERVICE_WORKER: Object.freeze({
|
|
selected: installed.serviceWorker === null ? 0 : 1,
|
|
active: resolved.serviceWorker === null ? 0 : 1,
|
|
}),
|
|
OFFLINE_COMMANDS: Object.freeze({
|
|
selected: installed.offlineCommands === null ? 0 : 1,
|
|
active: resolved.offlineCommands === null ? 0 : 1,
|
|
}),
|
|
});
|
|
|
|
return Object.freeze(
|
|
CAPABILITY_ORDER.map((capabilityId) =>
|
|
Object.freeze({
|
|
capabilityId,
|
|
selected: counts[capabilityId].selected,
|
|
active: counts[capabilityId].active,
|
|
override: overrides[capabilityId],
|
|
}),
|
|
),
|
|
);
|
|
}
|