1
24
25import * as Y from "yjs";
26import {
27 type BlockLike,
28 blocksToYDoc,
29 yDocToBlockList,
30 applyBlockOperationsToYDoc,
31 diffBlockSets,
32} from "@factory/collaboration/codec";
33import type { BlockOperation } from "@factory/shared";
34import { presenceColor } from "@factory/shared/presence";
35import type { RoomPeer } from "@factory/shared/realtime/frames";
36
37
42function opIdOf(op: BlockOperation): string | undefined {
43 return op.op === "insert" ? op.block.id : op.id;
44}
45
46
47
48
59export interface HydrateResult {
60
61 readonly blocks: readonly BlockLike[] | null;
62
63 readonly version: number;
64}
65
66export interface PersistResult {
67
68 readonly version: number;
69
77 readonly conflict?: {
78 readonly externalBlocks: readonly BlockLike[];
79 };
80}
81
82export interface DocStore {
83
84 hydrate(pageId: string): Promise<HydrateResult>;
85
93 persist(
94 pageId: string,
95 blocks: readonly BlockLike[],
96 baseVersion: number,
97 ): Promise<PersistResult>;
98
99 ping(): Promise<boolean>;
100}
101
102
103export class InMemoryDocStore implements DocStore {
104 private readonly pages = new Map<string, BlockLike[]>();
105 private readonly versions = new Map<string, number>();
106 private pingOk = true;
107
108 setPingOk(ok: boolean): void {
109 this.pingOk = ok;
110 }
111
112 seed(pageId: string, blocks: readonly BlockLike[]): void {
113 this.pages.set(pageId, blocks.map((b) => ({ ...b })));
114
115
116
117 if (!this.versions.has(pageId)) this.versions.set(pageId, 1);
118 }
119
120
126 writeExternal(pageId: string, blocks: readonly BlockLike[]): number {
127 this.pages.set(pageId, blocks.map((b) => ({ ...b })));
128 const v = (this.versions.get(pageId) ?? 1) + 1;
129 this.versions.set(pageId, v);
130 return v;
131 }
132
133
134 versionOf(pageId: string): number {
135 return this.versions.get(pageId) ?? 1;
136 }
137
138 async hydrate(pageId: string): Promise<HydrateResult> {
139 const b = this.pages.get(pageId);
140 return {
141 blocks: b === undefined ? null : b.map((x) => ({ ...x })),
142 version: this.versions.get(pageId) ?? 1,
143 };
144 }
145
146 async persist(
147 pageId: string,
148 blocks: readonly BlockLike[],
149 baseVersion: number,
150 ): Promise<PersistResult> {
151 const current = this.versions.get(pageId) ?? 1;
152 if (current !== baseVersion) {
153
154
155 const ext = this.pages.get(pageId);
156 return { version: current, conflict: { externalBlocks: ext === undefined ? [] : ext.map((x) => ({ ...x })) } };
157 }
158 this.pages.set(pageId, blocks.map((b) => ({ ...b })));
159 const v = current + 1;
160 this.versions.set(pageId, v);
161 return { version: v };
162 }
163
164 async ping(): Promise<boolean> {
165 return this.pingOk;
166 }
167}
168
169
170
171
172export interface Peer {
173 readonly connId: string;
174 readonly userId: string;
175 readonly name: string;
176 readonly color: string;
177
178 canEdit: boolean;
179
180 roster: RoomPeer;
181}
182
183
184export interface RoomClock {
185 readonly now: () => number;
186 readonly setTimeout: (fn: () => void, ms: number) => unknown;
187 readonly clearTimeout: (h: unknown) => void;
188}
189
190export function realClock(): RoomClock {
191 return {
192 now: () => Date.now(),
193 setTimeout: (fn, ms) => setTimeout(fn, ms),
194 clearTimeout: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
195 };
196}
197
198export interface RoomManagerOpts {
199 readonly store: DocStore;
200 readonly clock?: RoomClock;
201
202 readonly persistDebounceMs?: number;
203
204 readonly persistHardCapMs?: number;
205
206 readonly roomGcMs?: number;
207
208 readonly hydrateBufferLimit?: number;
209
214 readonly flushSeqProvider?: (roomKey: string) => number;
215}
216
217
218export interface RoomManagerEvents {
219
224 onPersisted: (
225 roomKey: string,
226 resourceId: string,
227 version: number,
228 logSeqAtFlush: number,
229 blockDelta: readonly BlockOperation[],
230 ) => void;
231
233 onRebased?: (roomKey: string, resourceId: string, appliedExternalOps: number) => void;
234
235 onRoomEvicted: (roomKey: string) => void;
236}
237
238
242export class Room {
243 readonly key: string;
244 readonly kind: "page" | "database";
245 readonly resourceId: string;
246
247
249 doc: Y.Doc;
250
251 readonly peers = new Map<string, Peer>();
252
253
256 private hydrateBuffer: Uint8Array[] = [];
257 private hydrating = false;
258
259 private dirtySince: number | null = null;
260 private flushTimer: unknown = null;
261 private flushing: Promise<void> | null = null;
262
265 lastVersion = 0;
266
268 private lastPersistedBlocks: readonly BlockLike[] = [];
269
270 private gcTimer: unknown = null;
271 private _evicted = false;
272
273 constructor(
274 key: string,
275 kind: "page" | "database",
276 resourceId: string,
277 private readonly opts: Required<RoomManagerOpts>,
278 private readonly events: RoomManagerEvents,
279 seed: HydrateResult,
280 ) {
281 this.key = key;
282 this.kind = kind;
283 this.resourceId = resourceId;
284 this.doc = this.buildDoc(seed.blocks);
285 this.lastVersion = seed.version;
286
287
288
289 this.lastPersistedBlocks = seed.blocks === null ? [] : seed.blocks.map((b) => ({ ...b }));
290 }
291
292 get isDirty(): boolean {
293 return this.dirtySince !== null;
294 }
295
296 get isEmpty(): boolean {
297 return this.peers.size === 0;
298 }
299
300 get isEvicted(): boolean {
301 return this._evicted;
302 }
303
304
305 get bufferedUpdates(): number {
306 return this.hydrateBuffer.length;
307 }
308
309 private buildDoc(seed: readonly BlockLike[] | null): Y.Doc {
310 const doc =
311 seed === null || seed.length === 0
312 ? new Y.Doc()
313 : blocksToYDoc(seed, { pageId: this.kind === "page" ? this.resourceId : undefined });
314 if (this.kind === "page") {
315 doc.getMap("yMeta").set("pageId", this.resourceId);
316 }
317 return doc;
318 }
319
320
325 applyUpdate(bytes: Uint8Array): boolean {
326 if (this.hydrating) {
327 if (this.hydrateBuffer.length >= this.opts.hydrateBufferLimit) {
328 throw new Error("hydrate buffer overflow");
329 }
330 this.hydrateBuffer.push(bytes);
331 return false;
332 }
333 Y.applyUpdate(this.doc, bytes, "remote");
334 this.markDirty();
335 return true;
336 }
337
338
339 stateDelta(sinceVectorBytes: Uint8Array | null): Uint8Array {
340 if (sinceVectorBytes === null || sinceVectorBytes.byteLength === 0) {
341 return Y.encodeStateAsUpdate(this.doc);
342 }
343
344 return Y.encodeStateAsUpdate(this.doc, sinceVectorBytes);
345 }
346
347 private markDirty(): void {
348 if (this.dirtySince === null) {
349 this.dirtySince = this.opts.clock.now();
350 }
351 this.scheduleFlush();
352 }
353
354 private scheduleFlush(): void {
355 if (this.flushTimer !== null) {
356 this.opts.clock.clearTimeout(this.flushTimer);
357 }
358 const age = this.dirtySince === null ? 0 : this.opts.clock.now() - this.dirtySince;
359 const delay = Math.max(
360 0,
361 Math.min(this.opts.persistDebounceMs, this.opts.persistHardCapMs - age),
362 );
363 this.flushTimer = this.opts.clock.setTimeout(() => {
364 void this.flush();
365 }, delay);
366 }
367
368
385 flush(): Promise<void> {
386 if (this.flushing !== null) {
387 return this.flushing;
388 }
389 const p = (async () => {
390 if (this.flushTimer !== null) {
391 this.opts.clock.clearTimeout(this.flushTimer);
392 this.flushTimer = null;
393 }
394
395
396 const logSeqAtFlush = this.opts.flushSeqProvider?.(this.key) ?? 0;
397
398
399
400
401
402
403 let blocks = yDocToBlockList(this.doc);
404 const roomTouched = new Set(
405 diffBlockSets(this.lastPersistedBlocks, blocks).map(opIdOf),
406 );
407
408
409 let externalBase = this.lastPersistedBlocks;
410
411 let result = await this.opts.store.persist(this.resourceId, blocks, this.lastVersion);
412 let attempts = 1;
413
414
415
416
417
418 while (result.conflict !== undefined) {
419 const externalOps = diffBlockSets(externalBase, result.conflict.externalBlocks);
420 const absorb = externalOps.filter((op) => !roomTouched.has(opIdOf(op)));
421 if (absorb.length > 0) {
422 Y.transact(this.doc, () => {
423 applyBlockOperationsToYDoc(this.doc, absorb);
424 }, "realtime-flush-rebase");
425 }
426 this.events.onRebased?.(this.key, this.resourceId, absorb.length);
427 externalBase = result.conflict.externalBlocks;
428 blocks = yDocToBlockList(this.doc);
429
430 if (attempts >= 2) {
431
432
433
434
435
436
437 this.lastVersion = result.version;
438 this.scheduleFlush();
439 return;
440 }
441 attempts += 1;
442 result = await this.opts.store.persist(this.resourceId, blocks, result.version);
443 }
444
445 const blockDelta = diffBlockSets(this.lastPersistedBlocks, blocks);
446 this.lastVersion = result.version;
447 this.lastPersistedBlocks = blocks.map((b) => ({ ...b }));
448 this.dirtySince = null;
449 this.events.onPersisted(this.key, this.resourceId, result.version, logSeqAtFlush, blockDelta);
450 })();
451 this.flushing = p;
452 void p.finally(() => {
453 this.flushing = null;
454 });
455 return p;
456 }
457
458
459 async evict(): Promise<void> {
460 if (this._evicted) return;
461 this._evicted = true;
462 if (this.gcTimer !== null) {
463 this.opts.clock.clearTimeout(this.gcTimer);
464 this.gcTimer = null;
465 }
466 if (this.isDirty) {
467 await this.flush();
468 }
469 this.doc.destroy();
470 this.events.onRoomEvicted(this.key);
471 }
472
473
478 setHydrating(): void {
479 this.hydrating = true;
480 }
481
482
483 completeHydration(hydrated: Y.Doc): void {
484 if (!this.hydrating) return;
485 this.doc.destroy();
486 this.doc = hydrated;
487 for (const u of this.hydrateBuffer) {
488 Y.applyUpdate(this.doc, u, "remote");
489 }
490 this.hydrateBuffer = [];
491 this.hydrating = false;
492 }
493
494
495 notePeersChanged(): void {
496 if (this._evicted) return;
497 if (this.peers.size === 0) {
498 if (this.gcTimer === null) {
499 this.gcTimer = this.opts.clock.setTimeout(() => {
500 this.gcTimer = null;
501 void this.evict();
502 }, this.opts.roomGcMs);
503 }
504 } else if (this.gcTimer !== null) {
505 this.opts.clock.clearTimeout(this.gcTimer);
506 this.gcTimer = null;
507 }
508 }
509
510 roster(): readonly RoomPeer[] {
511 return [...this.peers.values()].map((p) => p.roster);
512 }
513
514
515 static colorFor(userId: string): string {
516 return presenceColor(userId);
517 }
518}
519
520
521
522export class RoomManager {
523 private readonly roomMap = new Map<string, Room>();
524 private readonly events: RoomManagerEvents;
525 private readonly opts: Required<RoomManagerOpts>;
526
527 constructor(opts: RoomManagerOpts, events: RoomManagerEvents) {
528 this.opts = {
529 store: opts.store,
530 clock: opts.clock ?? realClock(),
531 persistDebounceMs: opts.persistDebounceMs ?? 1000,
532 persistHardCapMs: opts.persistHardCapMs ?? 10_000,
533 roomGcMs: opts.roomGcMs ?? 5 * 60_000,
534 hydrateBufferLimit: opts.hydrateBufferLimit ?? 5_000,
535
536
537 flushSeqProvider: opts.flushSeqProvider ?? (() => 0),
538 };
539 this.events = events;
540 }
541
542
547 async getOrCreate(
548 key: string,
549 kind: "page" | "database",
550 resourceId: string,
551 ): Promise<Room> {
552 const existing = this.roomMap.get(key);
553 if (existing !== undefined) return existing;
554
555
556
557
558 const seed: HydrateResult =
559 kind === "page"
560 ? await this.opts.store.hydrate(resourceId)
561 : { blocks: null, version: 0 };
562 const room = new Room(key, kind, resourceId, this.opts, this.events, seed);
563 this.roomMap.set(key, room);
564 return room;
565 }
566
567 get(key: string): Room | undefined {
568 return this.roomMap.get(key);
569 }
570
571
572 remove(key: string): void {
573 this.roomMap.delete(key);
574 }
575
576
577 rooms(): Room[] {
578 return [...this.roomMap.values()];
579 }
580
581
585 async drainAll(budgetMs: number): Promise<{ flushed: number; evicted: number }> {
586 const deadline = this.opts.clock.now() + budgetMs;
587 let flushed = 0;
588 let evicted = 0;
589 for (const room of this.rooms()) {
590 if (room.isDirty) {
591 if (this.opts.clock.now() > deadline) break;
592 await room.flush();
593 flushed += 1;
594 }
595 await room.evict();
596 this.roomMap.delete(room.key);
597 evicted += 1;
598 }
599 return { flushed, evicted };
600 }
601}
602
603
604export function makePeer(
605 connId: string,
606 userId: string,
607 canEdit: boolean,
608 name?: string,
609): Peer {
610 const roster: RoomPeer = {
611 user: { id: userId, name: name ?? "Guest (shared)", color: Room.colorFor(userId) },
612 };
613 return {
614 connId,
615 userId,
616 name: roster.user.name,
617 color: roster.user.color,
618 canEdit,
619 roster,
620 };
621}
622