1
31
32import type { BlockLike } from "@factory/collaboration/codec";
33import type { DocStore, HydrateResult, PersistResult } from "./rooms.js";
34
35
36interface ReadPage {
37 pageId: string;
38 version: number;
39 blocks: WireBlock[];
40 nextCursor: string | null;
41 hasMore: boolean;
42}
43
44
45interface WireBlock {
46 id: string;
47 parentId: string | null;
48 type: string;
49 position: string;
50 content: Record<string, unknown>;
51 properties?: Record<string, unknown>;
52}
53
54
57const MAX_HYDRATION_PAGES = 200;
58
59const READ_PAGE_SIZE = 500;
60
61export interface HttpDocStoreOptions {
62
64 readonly apiUrl: string;
65
66 readonly serviceToken: string;
67
69 readonly timeoutMs?: number;
70}
71
72export class HttpDocStore implements DocStore {
73 private readonly base: string;
74 private readonly token: string;
75 private readonly timeoutMs: number;
76
77 constructor(opts: HttpDocStoreOptions) {
78 this.base = opts.apiUrl.replace(/\/+$/, "");
79 this.token = opts.serviceToken;
80 this.timeoutMs = opts.timeoutMs ?? 30_000;
81 }
82
83 async hydrate(pageId: string): Promise<HydrateResult> {
84 let blocks: WireBlock[] = [];
85 let version = 1;
86 let cursor: string | undefined;
87 let pages = 0;
88 for (;;) {
89 const qs = new URLSearchParams({ pageSize: String(READ_PAGE_SIZE) });
90 if (cursor !== undefined) qs.set("cursor", cursor);
91 const page = await this.get<ReadPage>(`/internal/blocks/${encodeURIComponent(pageId)}?${qs}`);
92 blocks = blocks.concat(page.blocks);
93 version = page.version;
94 pages += 1;
95 if (!page.hasMore || page.nextCursor === null) break;
96 cursor = page.nextCursor;
97 if (pages >= MAX_HYDRATION_PAGES) {
98 throw new Error(
99 `HttpDocStore.hydrate: page "${pageId}" exceeds the ${MAX_HYDRATION_PAGES * READ_PAGE_SIZE}-block hydration cap (cursor not advancing?)`,
100 );
101 }
102 }
103 return {
104
105
106 blocks: blocks.length === 0 ? null : (blocks as BlockLike[]),
107 version,
108 };
109 }
110
111 async persist(
112 pageId: string,
113 blocks: readonly BlockLike[],
114 baseVersion: number,
115 ): Promise<PersistResult> {
116 const res = await this.post<{
117 version: number;
118 conflict?: { externalBlocks: WireBlock[] };
119 }>("/internal/blocks/flush", { pageId, baseVersion, blocks: [...blocks] });
120 if (res.conflict !== undefined) {
121 return {
122 version: res.version,
123 conflict: { externalBlocks: res.conflict.externalBlocks as BlockLike[] },
124 };
125 }
126 return { version: res.version };
127 }
128
129 async ping(): Promise<boolean> {
130 try {
131 await this.getRaw("/internal/blocks/__ping__");
132
133 return true;
134 } catch {
135 return false;
136 }
137 }
138
139
140
141 private headers(): Record<string, string> {
142 return { authorization: `Bearer ${this.token}` };
143 }
144
145
146 private async get<T>(path: string): Promise<T> {
147 const res = await this.getRaw(path);
148 if (!res.ok) throw await this.httpError(res, "GET", path);
149 return (await res.json()) as T;
150 }
151
152
153 private async post<T>(path: string, body: unknown): Promise<T> {
154 const res = await this.fetchWithTimeout(path, {
155 method: "POST",
156 headers: { "content-type": "application/json", ...this.headers() },
157 body: JSON.stringify(body),
158 });
159 if (!res.ok) throw await this.httpError(res, "POST", path);
160 return (await res.json()) as T;
161 }
162
163 private async getRaw(path: string): Promise<Response> {
164 return this.fetchWithTimeout(path, { method: "GET", headers: this.headers() });
165 }
166
167 private fetchWithTimeout(path: string, init: RequestInit): Promise<Response> {
168 return new Promise((resolve, reject) => {
169 const controller = new AbortController();
170 const timer = setTimeout(() => {
171 controller.abort();
172 reject(new Error(`HttpDocStore: request to ${path} timed out after ${this.timeoutMs} ms`));
173 }, this.timeoutMs);
174 fetch(`${this.base}${path}`, { ...init, signal: controller.signal })
175 .then((res) => {
176 clearTimeout(timer);
177 resolve(res);
178 })
179 .catch((err) => {
180 clearTimeout(timer);
181 reject(err instanceof Error ? err : new Error(String(err)));
182 });
183 });
184 }
185
186
188 private async httpError(res: Response, method: string, path: string): Promise<Error> {
189 let detail = "";
190 try {
191 const body = (await res.json()) as { error?: { code?: string; message?: string } };
192 detail = body.error ? ` ${body.error.code ?? ""} ${body.error.message ?? ""}`.trim() : "";
193 } catch {
194
195 }
196 return new Error(`HttpDocStore: ${method} ${path} → ${res.status}${detail}`);
197 }
198}
199