/** * HTTP-backed doc store (TASK-0092 slice 2 — production seam). * * The `DocStore` seam implemented over the API's **internal** block * endpoints (`/internal/blocks/*` — the internal twin of the public REST * block API, service-token auth, no permission re-check — the room's join * matrix already gated the edits). This is the documented seam for * multi-gateway deployments: the realtime gateway talks Postgres only * through the API (one persistence path, the API's permission/audit/event * layers are the single write funnel). * * - `hydrate` = `GET /internal/blocks/:pageId` (cursor-paginated by * `position` — the 100k-block hydration streams page by page, doc * §4.2) → blocks + `pages.version` (the flush's CAS base, in one * consistent read per the `DocStore` contract). * - `persist` = `POST /internal/blocks/flush` (the page's full block set * under the `pages.version` CAS). A stale `baseVersion` → the API * answers `conflict.externalBlocks` (the concurrent writer's state) and * the room rebases (the "flush wins" rule, doc §4.3) — the exact * `PersistResult` shape the in-process `PrismaDocStore` produces, so * the room logic is store-agnostic. * - `ping` = a probe read (`/internal/blocks/__ping__`): any HTTP answer * (even the 404) means the API is reachable; a network failure means it * is down (`/readyz`). A misconfigured token also reads "down" here — * the boot check below fails loud when the token is empty. * * Auth: the shared `INTERNAL_SERVICE_TOKEN` (the API's * `InternalServiceGuard` compares it constant-time and FAILS CLOSED when it * is unset there — a gateway with an empty token never opens the surface). */ import type { BlockLike } from "@factory/collaboration/codec"; import type { DocStore, HydrateResult, PersistResult } from "./rooms.js"; /** One page of the internal read (the API's `internalRead` response). */ interface ReadPage { pageId: string; version: number; blocks: WireBlock[]; nextCursor: string | null; hasMore: boolean; } /** The codec block wire shape (the API's `InternalBlock` response). */ interface WireBlock { id: string; parentId: string | null; type: string; position: string; content: Record; properties?: Record; } /** Safety cap on the hydration loop: 500/page × 200 pages = 100k blocks * (the doc §4.2 budget). A cursor that never advances would otherwise * loop forever. */ const MAX_HYDRATION_PAGES = 200; /** The internal read page size (the API caps at 500). */ const READ_PAGE_SIZE = 500; export interface HttpDocStoreOptions { /** The API base URL (`API_URL` — the internal endpoints live on the * API's HTTP port, unprefixed: `/internal/blocks/*`). */ readonly apiUrl: string; /** The shared service token (`INTERNAL_SERVICE_TOKEN`). */ readonly serviceToken: string; /** A per-request timeout (the flush is a full-set write; 30 s is ample * — a hung API must not hang the room's debounce forever). */ readonly timeoutMs?: number; } export class HttpDocStore implements DocStore { private readonly base: string; private readonly token: string; private readonly timeoutMs: number; constructor(opts: HttpDocStoreOptions) { this.base = opts.apiUrl.replace(/\/+$/, ""); this.token = opts.serviceToken; this.timeoutMs = opts.timeoutMs ?? 30_000; } async hydrate(pageId: string): Promise { let blocks: WireBlock[] = []; let version = 1; let cursor: string | undefined; let pages = 0; for (;;) { const qs = new URLSearchParams({ pageSize: String(READ_PAGE_SIZE) }); if (cursor !== undefined) qs.set("cursor", cursor); const page = await this.get(`/internal/blocks/${encodeURIComponent(pageId)}?${qs}`); blocks = blocks.concat(page.blocks); version = page.version; pages += 1; if (!page.hasMore || page.nextCursor === null) break; cursor = page.nextCursor; if (pages >= MAX_HYDRATION_PAGES) { throw new Error( `HttpDocStore.hydrate: page "${pageId}" exceeds the ${MAX_HYDRATION_PAGES * READ_PAGE_SIZE}-block hydration cap (cursor not advancing?)`, ); } } return { // The codec wire shape IS the BlockLike shape (lowercase types, // fractional positions) — the API translates at its own DB boundary. blocks: blocks.length === 0 ? null : (blocks as BlockLike[]), version, }; } async persist( pageId: string, blocks: readonly BlockLike[], baseVersion: number, ): Promise { const res = await this.post<{ version: number; conflict?: { externalBlocks: WireBlock[] }; }>("/internal/blocks/flush", { pageId, baseVersion, blocks: [...blocks] }); if (res.conflict !== undefined) { return { version: res.version, conflict: { externalBlocks: res.conflict.externalBlocks as BlockLike[] }, }; } return { version: res.version }; } async ping(): Promise { try { await this.getRaw("/internal/blocks/__ping__"); // Any HTTP answer (404 for the probe page) = the API is up. return true; } catch { return false; } } // --- transport --------------------------------------------------------- private headers(): Record { return { authorization: `Bearer ${this.token}` }; } /** A GET that parses the JSON body (throws on any non-2xx). */ private async get(path: string): Promise { const res = await this.getRaw(path); if (!res.ok) throw await this.httpError(res, "GET", path); return (await res.json()) as T; } /** A POST (JSON body) that parses the JSON response (throws on non-2xx). */ private async post(path: string, body: unknown): Promise { const res = await this.fetchWithTimeout(path, { method: "POST", headers: { "content-type": "application/json", ...this.headers() }, body: JSON.stringify(body), }); if (!res.ok) throw await this.httpError(res, "POST", path); return (await res.json()) as T; } private async getRaw(path: string): Promise { return this.fetchWithTimeout(path, { method: "GET", headers: this.headers() }); } private fetchWithTimeout(path: string, init: RequestInit): Promise { return new Promise((resolve, reject) => { const controller = new AbortController(); const timer = setTimeout(() => { controller.abort(); reject(new Error(`HttpDocStore: request to ${path} timed out after ${this.timeoutMs} ms`)); }, this.timeoutMs); fetch(`${this.base}${path}`, { ...init, signal: controller.signal }) .then((res) => { clearTimeout(timer); resolve(res); }) .catch((err) => { clearTimeout(timer); reject(err instanceof Error ? err : new Error(String(err))); }); }); } /** A readable error (the status + the API's canonical envelope, without * leaking the token — it is only in the request header). */ private async httpError(res: Response, method: string, path: string): Promise { let detail = ""; try { const body = (await res.json()) as { error?: { code?: string; message?: string } }; detail = body.error ? ` ${body.error.code ?? ""} ${body.error.message ?? ""}`.trim() : ""; } catch { /* non-JSON body — the status alone is the report */ } return new Error(`HttpDocStore: ${method} ${path} → ${res.status}${detail}`); } }