/** * @factory/collaboration — structural types for the realtime bridge. * * Wire protocol: `docs/architecture/realtime-architecture.md` §2 as * implemented by TASK-0091 (the phase-07 task sheet, R3). **The * authoritative frame schemas land in `packages/shared/src/realtime/frames.ts` * (TASK-0091 backend); this module is the client-side mirror and must stay in * lockstep** — the apps/realtime scaffold's earlier frame set * (sync / sync-reply / presence / ping-pong) is superseded per the task * header note 3 ("the scaffold is a starting point, not a contract"). * * Framing (recorded client-side inference, TASK-0091 finalizes in * shared/realtime/frames.ts): * - **Text frames**: one JSON object per frame, `type` discriminant. * - **Binary frames**: a 1-byte kind prefix + raw Yjs bytes. * `0x01` — doc update (`Y.applyUpdate`-compatible) * `0x02` — awareness update (`y-protocols` `applyAwarenessUpdate`) * * Yjs binary updates cross the wire base64-encoded **inside** the JSON * control frames (`sync.request.stateVector`, `sync.state.chunk`); the * `update` / `awareness` frames are binary (prefix + raw bytes). */ /** Base64 of a Yjs binary update / state vector (the JSON-frame encoding). */ export type Base64Yjs = string; /** Binary frame kind prefix (see the module doc). */ export const BINARY_KIND = { Update: 0x01, Awareness: 0x02, } as const; // --- client → server (JSON) -------------------------------------------------- /** Join-sync request. Empty `stateVector` = full doc; set = state-vector diff. */ export interface SyncRequestFrame { type: "sync.request"; /** Base64 state vector; omitted = the local doc is empty (full sync). */ stateVector?: Base64Yjs; /** Last acked server seq (the Redis update-log resume fast path, R5). */ lastSeq?: number; } /** * Application-level heartbeat. Carries the client's last-ack seq so a * reconnect can use the update-log resume even between pings (R5). */ export interface PingFrame { type: "ping"; ts: number; lastSeq?: number; } /** * Non-editor mutation count pending in the local outbox (sent after `hello` * on (re)connect so the server can estimate the drain load; client-side * inference — the final shape lands in shared/realtime/frames.ts, recorded). */ export interface OutboxPendingFrame { type: "outbox.pending"; count: number; } /** JSON client frames. Binary client frames: `update` / `awareness` (prefixed). */ export type ClientFrame = SyncRequestFrame | PingFrame | OutboxPendingFrame; // --- server → client (JSON) -------------------------------------------------- /** A roster peer (the `hello` payload — users only, no cursors; cursors * arrive as awareness frames within 50 ms, TASK-0093 R2). */ export interface RoomPeer { readonly user: { readonly id: string; readonly name: string; readonly color: string }; } /** * Emitted on a successful room join (R1): identity + capability + clock. * `canEdit` is the defense-in-depth flag the client also enforces locally * (a VIEW-only peer's local updates are dropped, mirroring the server). */ export interface HelloFrame { type: "hello"; connId: string; canEdit: boolean; serverTime: number; roomPeers: readonly RoomPeer[]; } /** * Paged doc state: one base64 Yjs update per chunk (≤ 64 KB server-side), * `done` marks the end of the transfer (R3; paged hydration, TASK-0092). */ export interface SyncStateFrame { type: "sync.state"; chunk: Base64Yjs; done: boolean; } /** * Server seq ack for a logged update (client-side inference, recorded — * the Redis update log is per-room; `doc.persisted.version` is the * persistence-bound ack, `ack.seq` the log-bound one). When the server * sends no acks the client falls back to pure state-vector resume * (always correct — the log is a speed optimization only, R5). */ export interface AckFrame { type: "ack"; seq: number; } /** A persisted doc state reached Postgres (the "Saved" indicator + the * offline outbox settle signal, TASK-0092 R3 / TASK-0095). */ export interface DocPersistedFrame { type: "doc.persisted"; pageId: string; version: number; } /** Comment fan-out over the page room (TASK-0094 R1). */ export interface CommentEventFrame { type: "comment.event"; kind: "created" | "replied" | "resolved" | "reopened" | "reacted" | "deleted"; commentId: string; pageId: string; actorId: string; /** First ~200 chars + render hint (the full comment is fetched on demand). */ preview: string; } /** Notification push over the `user:{userId}` control room (TASK-0094 R7). */ export interface NotificationPushFrame { type: "notification.push"; notificationId: string; /** The notification kind (comment.reply, mention, …) — `type` is the * frame discriminator, so the payload field is `kind`. */ kind: string; title: string; deepLink: { type: string; id: string }; actorName?: string; } /** Long-hydration notice (the client shows "Loading page…", TASK-0092 R2). */ export interface HydratingFrame { type: "hydrating"; pageId: string; } /** Protocol error (a close is NOT implied — the socket stays open). */ export interface ErrorFrame { type: "error"; code: string; message: string; } /** Heartbeat echo. */ export interface PingPongFrame { type: "ping-pong"; ts: number; } export type ServerFrame = | HelloFrame | SyncStateFrame | AckFrame | DocPersistedFrame | CommentEventFrame | NotificationPushFrame | HydratingFrame | ErrorFrame | PingPongFrame; /** One decoded wire frame (text = JSON object, binary = prefixed Yjs bytes). */ export type WireFrame = | { readonly kind: "text"; readonly frame: ClientFrame | ServerFrame } | { readonly kind: "binary"; readonly binary: "update" | "awareness"; readonly bytes: Uint8Array }; // --- close codes (TASK-0091 R6 — the scaffold's 44xx set is superseded) ------ export const WS_CLOSE = { /** Protocol error / heartbeat timeout (no pong in 90 s). */ ProtocolOrHeartbeat: 4000, /** Unauthorized: invalid/expired JWT (no room join happened). */ Unauthorized: 4001, /** Forbidden: wrong workspace / not the socket's own control room / no VIEW. */ Forbidden: 4002, /** Rate limit / policy violation (connect rate, msg rate, 10 strikes). */ RateOrPolicy: 4008, /** Frame larger than the 256 KB cap. */ PayloadTooLarge: 4009, /** Graceful server shutdown (SIGTERM drain — retry after 5 s). */ ServerShutdown: 4010, } as const; /** Close code → user-facing reconnect hint (the client toast text). */ export const CLOSE_HINTS: Readonly> = { [WS_CLOSE.ProtocolOrHeartbeat]: "Connection lost — reconnecting…", [WS_CLOSE.Unauthorized]: "Session expired — signing you back in…", [WS_CLOSE.Forbidden]: "You no longer have access to this page.", [WS_CLOSE.RateOrPolicy]: "Too many changes — giving the server a moment…", [WS_CLOSE.PayloadTooLarge]: "A change was too large to send — retrying…", [WS_CLOSE.ServerShutdown]: "The server is restarting — reconnecting in a moment…", }; /** Provider connection state (the top-bar StatusChip input). */ export type ConnectionState = | "connecting" | "syncing" | "connected" | "reconnecting" | "offline" | "closed"; /** * A minimal, injectable WebSocket (the browser `WebSocket`, a fake in * tests). Data is `string` (JSON frames) or `ArrayBuffer` / `Uint8Array` * (binary frames). */ export interface WSAdapter { readonly readyState: number; send(data: string | ArrayBuffer | Uint8Array): void; close(code?: number, reason?: string): void; onopen: (() => void) | null; onmessage: ((event: { data: string | ArrayBuffer | Uint8Array }) => void) | null; onclose: ((event: { code: number; reason: string }) => void) | null; onerror: ((event: unknown) => void) | null; } /** * The Yjs update source/sink the provider drives. The canvas implements it * with `y-prosemirror` (`syncWrapper` over the editor's view, the Phase-4 * node set registered per `mapping.ts`); tests inject a fake. */ export interface YjsDocBinding { /** The Y.Doc (state vectors, awareness, offline persistence). */ readonly doc: import("yjs").Doc; /** Apply a decoded binary update (network or offline store). */ applyUpdate(update: Uint8Array): void; /** Register a local-change listener; returns an unregister fn. */ onLocalUpdate(cb: (update: Uint8Array) => void): () => void; /** True when the local doc has no blocks yet (empty = full sync needed). */ hasLocalState(): boolean; /** Encode the current local state vector (for `sync.request`). */ stateVector(): Uint8Array; /** Encode the full local doc state as a binary update. */ encodeStateAsUpdate(): Uint8Array; } /** The awareness self-identity (color derived from the shared hash). */ export interface PresenceIdentity { readonly userId: string; readonly name: string; } /** Provider configuration (`createRealtimeProvider`, TASK-0092 R4). * * The task's `createRealtimeProvider({pageId, workspaceId, token, * pageBlocks, editor})` maps to: `token` → `getToken` (the Phase-1 * cookie-refresh BFF hands out access JWTs; a 15-min expiry mid-session * refreshes silently), `pageBlocks` → `initialBlocks`, `editor` → * `binding` (the canvas builds the binding from the editor via * y-prosemirror — the package itself stays tiptap-free). */ export interface RealtimeProviderConfig { readonly pageId: string; readonly workspaceId: string; /** The realtime origin (e.g. `ws://localhost:4001` — the room path is * appended by the provider: `/realtime?token=…&room=page:{pageId}`). */ readonly wsUrl: string; /** Current access token (JWT) for the WS handshake. */ readonly getToken: () => string; /** Refresh the access token (cookie-refresh BFF) on 4001. */ readonly refreshAccessToken: () => Promise; /** The Yjs doc binding (y-prosemirror on the canvas, a fake in tests). */ readonly binding: YjsDocBinding; /** The WebSocket factory (browser `new WebSocket`; fake in tests). */ readonly createSocket: (url: string) => WSAdapter; /** Initial blocks to seed the doc when neither an offline doc nor the * ProseMirror view provides state (the task's `pageBlocks`). */ readonly initialBlocks?: readonly Record[]; /** The awareness self-identity (absence = presence disabled). */ readonly presence?: PresenceIdentity; /** Cursor validity probe (the AwarenessStore's R3 rule; the canvas * implements it against the doc / ProseMirror view). */ readonly hasBlock?: (blockId: string) => boolean; /** Connectivity probe (defaults to `navigator.onLine`; Node = online). * The app's online/offline listeners drive {@link setOnline} directly. */ readonly isOnline?: () => boolean; /** The offline persistence factory (y-indexeddb by default; a no-op * where IndexedDB is unavailable, e.g. node — recorded). */ readonly offlineFactory?: OfflinePersistenceFactory; /** Reconnect backoff cap (the < 3 s reconnect budget, TASK-0095). */ readonly maxReconnectDelayMs?: number; /** Application-level heartbeat interval (server ping cadence: 30 s). */ readonly pingIntervalMs?: number; /** Injectable clock (defaults to Date.now) for tests. */ readonly now?: () => number; readonly setTimeout?: typeof globalThis.setTimeout; readonly clearTimeout?: typeof globalThis.clearTimeout; readonly onStateChange?: (state: ConnectionState) => void; readonly onPeerChange?: (peers: readonly PeerInfo[]) => void; /** A persisted doc state landed (the "Saved" indicator, version = pages.version). */ readonly onDocPersisted?: (info: { pageId: string; version: number }) => void; /** Comment fan-out (the panel subscribes — TASK-0094 R2). */ readonly onCommentEvent?: (event: CommentEventFrame) => void; /** Notification push (the bell subscribes — TASK-0094 R7). */ readonly onNotificationPush?: (event: NotificationPushFrame) => void; /** A local update was dropped because the room is VIEW-only (canEdit=false). */ readonly onLocalUpdateBlocked?: () => void; /** Pending outbox count, reported to the server after `hello` (TASK-0095 R4.2). */ readonly getOutboxPendingCount?: () => number; /** A protocol `error` frame arrived (code + message, toast material). */ readonly onProtocolError?: (info: { code: string; message: string }) => void; /** A terminal close (4002 forbidden) — the provider will not reconnect. */ readonly onClosed?: (code: number, hint: string) => void; /** An inbound awareness binary frame (the canvas applies it with * y-protocols `applyAwarenessUpdate` and feeds the decoded per-peer * state into the provider's `awareness` store). */ readonly onAwarenessUpdate?: (bytes: Uint8Array) => void; /** A long-hydration notice arrived (the canvas shows "Loading page…"). */ readonly onHydrating?: (pageId: string) => void; } /** A connected peer's presence (the awareness state, self excluded). */ export interface PeerInfo { readonly userId: string; readonly name: string; readonly color: string; readonly cursor: { blockId: string; offset: number; selection?: string | null } | null; /** The peer's current selection (a block id or null), from awareness. */ readonly selection: string | null; readonly viewing: boolean; /** Last frame time (ms clock) — the 90 s prune basis. */ readonly lastSeen: number; } // --- outbox (TASK-0095 R1) ---------------------------------------------------- /** One non-editor REST mutation in the offline outbox. */ export interface OutboxItem { /** Client-generated idempotency key (unpredictable — `key_` + 32 hex). */ readonly key: string; readonly method: "POST" | "PATCH" | "PUT" | "DELETE"; readonly path: string; readonly body?: Record; /** Queued at (ms clock) — the 24 h GC basis + the LWW timestamp. */ readonly queuedAt: number; /** The resource this mutation targets (per-resource ordering). */ readonly resource: { type: string; id: string }; /** Attempt count (backoff basis: 1 s → 30 s max). */ readonly attempts: number; /** Earliest retry time (ms clock) after a 5xx/network failure. */ readonly nextAttemptAt?: number; readonly lastError?: string; } /** Outbox execution result (the api client / middleware reports). */ export interface OutboxResult { readonly ok: boolean; readonly status?: number; /** The server honored a stored idempotent response (replay, not re-run). */ readonly idempotentHit?: boolean; readonly error?: string; } /** The outbox persistence backend (apps/web implements with `idb`). */ export interface OutboxStore { load(): Promise; save(items: readonly OutboxItem[]): Promise; } // --- offline (TASK-0092 R5) ---------------------------------------------------- /** A Y.Doc persistence handle (y-indexeddb or a fake). */ export interface OfflinePersistence { readonly docName: string; /** Flush the current doc state to storage. */ flush(): Promise; /** Flush (if pending) + release. */ dispose(): Promise; } /** Creates an `OfflinePersistence` (the y-indexeddb default). */ export interface OfflinePersistenceFactory { create( docName: string, doc: import("yjs").Doc, opts: { autoSaveInterval: number }, ): Promise; }