import { z } from "zod"; import { type PermissionId, permissionIdSchema } from "./identifiers.js"; /** A module-local channel name. Core supplies the owning module namespace; modules cannot publish * into another module's channel. The deliberately flat grammar has no hierarchy or wildcards. */ export const MODULE_REALTIME_CHANNEL_MAX_LENGTH = 48; export const moduleRealtimeChannelSchema = z .string() .min(1) .max(MODULE_REALTIME_CHANNEL_MAX_LENGTH) .regex( /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u, "Must be a lowercase kebab-case module realtime channel.", ) .brand<"ModuleRealtimeChannel">(); export type ModuleRealtimeChannel = z.infer; export function parseModuleRealtimeChannel(input: unknown): ModuleRealtimeChannel { return moduleRealtimeChannelSchema.parse(input); } export function safeParseModuleRealtimeChannel( input: unknown, ): z.ZodSafeParseResult { return moduleRealtimeChannelSchema.safeParse(input); } /** Registration and fan-out ceilings are platform policy, identical for every module. */ export const MODULE_REALTIME_MAX_CHANNELS_PER_MODULE = 16; export const MODULE_REALTIME_MAX_SUBSCRIBERS_PER_CHANNEL = 100; export const MODULE_REALTIME_MAX_PAYLOAD_BYTES = 64 * 1024; export type ModuleRealtimeJsonPrimitive = boolean | number | string | null; export type ModuleRealtimeJsonValue = | ModuleRealtimeJsonPrimitive | readonly ModuleRealtimeJsonValue[] | { readonly [key: string]: ModuleRealtimeJsonValue }; export interface ModuleRealtimeChannelDefinitionInput { readonly channel: string; readonly permission: string; } export interface ModuleRealtimeChannelDefinition { readonly channel: ModuleRealtimeChannel; readonly permission: PermissionId; } export interface ModuleRealtimeChannelRegistrar { channel(definition: ModuleRealtimeChannelDefinitionInput): void; } /** The runtime publisher is already scoped to its module. No module id, origin, transport, token, * room, or topic can be supplied by module code. */ export interface ModuleRealtimePublisher { /** `unknown` is deliberate: an ordinary named interface is valid input without having to add a * meaningless string index signature. TypeScript cannot prove recursive JSON compatibility for * every structural interface, so the host validates the exact runtime value authoritatively. */ publish(channel: string, payload: unknown): void; } export type ModuleRealtimeChannelDefinitionIssueCode = | "MODULE_REALTIME_CHANNEL_DEFINITION_INVALID" | "MODULE_REALTIME_CHANNEL_INVALID" | "MODULE_REALTIME_PERMISSION_INVALID"; export type ModuleRealtimeChannelDefinitionParseResult = | { readonly success: true; readonly data: ModuleRealtimeChannelDefinition } | { readonly success: false; readonly code: ModuleRealtimeChannelDefinitionIssueCode }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** Validates only the definition's transport-neutral shape. Core separately proves that the * permission belongs to the registering module. Rejected input is never reflected in the result. */ export function safeParseModuleRealtimeChannelDefinition( value: unknown, ): ModuleRealtimeChannelDefinitionParseResult { if (!isRecord(value)) { return { success: false, code: "MODULE_REALTIME_CHANNEL_DEFINITION_INVALID" }; } const keys = Object.keys(value); if (keys.length !== 2 || !keys.includes("channel") || !keys.includes("permission")) { return { success: false, code: "MODULE_REALTIME_CHANNEL_DEFINITION_INVALID" }; } const channel = moduleRealtimeChannelSchema.safeParse(value.channel); if (!channel.success) { return { success: false, code: "MODULE_REALTIME_CHANNEL_INVALID" }; } const permission = permissionIdSchema.safeParse(value.permission); if (!permission.success) { return { success: false, code: "MODULE_REALTIME_PERMISSION_INVALID" }; } return { success: true, data: Object.freeze({ channel: channel.data, permission: permission.data }), }; } export function parseModuleRealtimeChannelDefinition( value: unknown, ): ModuleRealtimeChannelDefinition { const result = safeParseModuleRealtimeChannelDefinition(value); if (!result.success) { throw new TypeError("Invalid Ekklesia module realtime channel definition."); } return result.data; } export const MODULE_REALTIME_ERROR_CODES = [ "REALTIME_CHANNEL_INVALID", "REALTIME_CHANNEL_UNDECLARED", "REALTIME_PAYLOAD_INVALID", "REALTIME_PAYLOAD_TOO_LARGE", "REALTIME_PUBLISHER_STOPPED", "REALTIME_SNAPSHOT_UNAVAILABLE", "REALTIME_SUBSCRIBER_LIMIT", "REALTIME_UNAVAILABLE", ] as const; export type ModuleRealtimeErrorCode = (typeof MODULE_REALTIME_ERROR_CODES)[number]; const realtimeErrorMessages: Readonly> = Object.freeze({ REALTIME_CHANNEL_INVALID: "The realtime channel is invalid.", REALTIME_CHANNEL_UNDECLARED: "This module did not declare the realtime channel.", REALTIME_PAYLOAD_INVALID: "The realtime payload is not JSON-compatible.", REALTIME_PAYLOAD_TOO_LARGE: "The realtime payload exceeds the platform limit.", REALTIME_PUBLISHER_STOPPED: "The realtime publisher is no longer running.", REALTIME_SNAPSHOT_UNAVAILABLE: "The realtime channel has no current snapshot.", REALTIME_SUBSCRIBER_LIMIT: "The realtime channel subscriber limit has been reached.", REALTIME_UNAVAILABLE: "Realtime delivery is not available.", }); /** A fixed-code, fixed-message failure with no cause, payload, URL, path, or transport detail. */ export class ModuleRealtimeError extends Error { readonly code: ModuleRealtimeErrorCode; constructor(code: ModuleRealtimeErrorCode) { super(realtimeErrorMessages[code]); this.name = "ModuleRealtimeError"; this.code = code; this.stack = `${this.name}: ${this.message}`; Object.freeze(this); } }