import * as Y from "yjs"; import { HttpError } from "../errors"; import { withStore } from "../git/store"; import type { GitStore } from "../git/store"; import { MAX_POINTER_BYTES, parseLfsPointer } from "../lfs/pointer"; import { loadRepoById } from "../repos"; import { readBounded } from "./blob-read"; import { FILES_MAP, textKey } from "~/lib/editor-protocol"; import type { ChangeStatus, FileKind, FileMeta } from "~/lib/editor-protocol"; export const MAX_ROOM_FILES = 300; export const MAX_TEXT_BYTES = 1024 * 1024; const EVICT_TARGET = 0.8; const decoder = new TextDecoder(); export type RoomSql = SqlStorage; export type HeadFile = { kind: FileKind; size: number; text: string | null } | null; export function filesMap(doc: Y.Doc): Y.Map { return doc.getMap(FILES_MAP); } export function textOf(doc: Y.Doc, path: string): Y.Text { return doc.getText(textKey(path)); } /** Rewrites a Y.Text to `next` touching only the differing middle, so remote cursors stay put. */ export function replaceText(text: Y.Text, next: string): void { const current = text.toString(); if (current === next) return; let start = 0; while (start < current.length && start < next.length && current[start] === next[start]) start++; let end = 0; while ( end < current.length - start && end < next.length - start && current[current.length - 1 - end] === next[next.length - 1 - end] ) { end++; } text.delete(start, current.length - start - end); text.insert(start, next.slice(start, next.length - end)); } export function getBase(sql: RoomSql, path: string): { text: string | null } | null { const row = sql.exec<{ text: string | null }>("SELECT text FROM bases WHERE path = ?", path).toArray()[0]; return row ?? null; } export function setBase(sql: RoomSql, path: string, text: string | null): void { sql.exec( "INSERT INTO bases (path, text) VALUES (?, ?) ON CONFLICT(path) DO UPDATE SET text = excluded.text", path, text, ); } export function getMeta(sql: RoomSql, key: string): string | null { return sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = ?", key).toArray()[0]?.value ?? null; } export function setMeta(sql: RoomSql, key: string, value: string): void { sql.exec( "INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", key, value, ); } async function readHeadFile(store: GitStore, rootTree: string, path: string): Promise { const entry = await store.entryAtPath(rootTree, path); if (!entry || entry.type === "tree") return null; if (entry.type === "gitlink") return { kind: "gitlink", size: 0, text: null }; const read = await readBounded(store, entry.sha, MAX_TEXT_BYTES); if (!read) return null; if (entry.type === "symlink") return { kind: "symlink", size: read.size, text: read.bytes ? decoder.decode(read.bytes) : null }; if (read.bytes && read.size <= MAX_POINTER_BYTES && parseLfsPointer(read.bytes)) { return { kind: "lfs", size: read.size, text: decoder.decode(read.bytes) }; } if (read.binary) return { kind: "binary", size: read.size, text: null }; if (!read.bytes) return { kind: "too-large", size: read.size, text: null }; return { kind: "text", size: read.size, text: decoder.decode(read.bytes) }; } /** Reads files at a commit of the room's repo; bounded by the number of paths the caller passes. */ export async function readAtCommit(repoId: string, sha: string, paths: string[]): Promise> { const repo = await loadRepoById(repoId); if (!repo) throw new HttpError(404, "Repository not found"); return withStore(repo.artifacts_name, async (store) => { const commit = await store.commit(sha); if (!commit) throw new HttpError(404, "Commit not found"); const out = new Map(); for (const path of paths) out.set(path, await readHeadFile(store, commit.treeSha, path)); return out; }); } /** Paths whose shared text differs from the branch head, plus staged creates and deletes. */ export function pendingChanges( doc: Y.Doc, sql: RoomSql, ): Array<{ path: string; content: string | null; status: ChangeStatus }> { const out: Array<{ path: string; content: string | null; status: ChangeStatus }> = []; for (const [path, meta] of filesMap(doc).entries()) { if (meta.state === "created") out.push({ path, content: textOf(doc, path).toString(), status: "A" }); else if (meta.state === "deleted") out.push({ path, content: null, status: "D" }); else if (meta.kind === "text" && (meta.state === "seeded" || meta.state === "conflict")) { const text = textOf(doc, path).toString(); if (text !== getBase(sql, path)?.text) out.push({ path, content: text, status: "M" }); } } return out.sort((left, right) => left.path.localeCompare(right.path)); } export function conflicts(doc: Y.Doc): string[] { return [...filesMap(doc).entries()].filter(([, meta]) => meta.state === "conflict").map(([path]) => path); } /** * Moves the room to a new branch head. Clean files follow upstream; files edited here that also changed * upstream become conflicts that block committing until resolved, so neither side is silently lost. */ export async function rebaseRoom(doc: Y.Doc, sql: RoomSql, repoId: string, head: string): Promise { const files = filesMap(doc); const tracked = [...files.entries()].filter(([, meta]) => meta.state !== "created").map(([path]) => path); const upstream = await readAtCommit(repoId, head, tracked); doc.transact(() => { for (const path of tracked) { const meta = files.get(path); if (!meta) continue; const next = upstream.get(path) ?? null; const base = getBase(sql, path)?.text ?? null; const text = textOf(doc, path); const dirty = meta.state === "deleted" || meta.state === "conflict" || (meta.kind === "text" && text.toString() !== base); if (!dirty) { if (!next) { files.delete(path); text.delete(0, text.length); sql.exec("DELETE FROM bases WHERE path = ?", path); } else { replaceText(text, next.text ?? ""); setBase(sql, path, next.text); files.set(path, { state: "seeded", kind: next.kind, size: next.size }); } } else if ((next?.text ?? null) !== base) { sql.exec( "INSERT INTO upstream (path, text) VALUES (?, ?) ON CONFLICT(path) DO UPDATE SET text = excluded.text", path, next?.text ?? null, ); files.set(path, { ...meta, state: "conflict" }); } } }, "rebase"); setMeta(sql, "head", head); } /** "Keep mine": adopt the upstream version as the new base so the local edit commits on top of it. */ export function resolveConflict(doc: Y.Doc, sql: RoomSql, path: string): void { const files = filesMap(doc); const meta = files.get(path); if (!meta || meta.state !== "conflict") return; const upstream = sql.exec<{ text: string | null }>("SELECT text FROM upstream WHERE path = ?", path).toArray()[0]; setBase(sql, path, upstream?.text ?? null); sql.exec("DELETE FROM upstream WHERE path = ?", path); files.set(path, { ...meta, state: upstream?.text == null ? "created" : "seeded" }); } /** After a commit: committed paths either become the new base (same branch) or revert (moved to a PR branch). */ export function applyCommitted( doc: Y.Doc, sql: RoomSql, changes: Array<{ path: string; content: string | null }>, sameBranch: boolean, ): void { const files = filesMap(doc); doc.transact(() => { for (const change of changes) { const meta = files.get(change.path); const text = textOf(doc, change.path); const untouched = change.content === null ? meta?.state === "deleted" : text.toString() === change.content; if (sameBranch) { if (change.content === null) { sql.exec("DELETE FROM bases WHERE path = ?", change.path); if (untouched) { files.delete(change.path); text.delete(0, text.length); } else if (meta) files.set(change.path, { ...meta, state: "created" }); } else { setBase(sql, change.path, change.content); files.set(change.path, { state: "seeded", kind: "text", size: change.content.length }); } } else if (!untouched) { // Edited again while the commit was in flight: keep the newer text pending here. continue; } else if (meta?.state === "created") { files.delete(change.path); text.delete(0, text.length); } else if (meta) { replaceText(text, getBase(sql, change.path)?.text ?? ""); files.set(change.path, { ...meta, state: "seeded" }); } } }, "commit"); } const COMPACT_AFTER = 300; /** Folds the append-only update log into one state update once it grows, keeping wake-up cheap. */ export function compactUpdates(storage: DurableObjectStorage, doc: Y.Doc): void { const count = storage.sql.exec<{ count: number }>("SELECT COUNT(*) AS count FROM updates").one().count; if (count < COMPACT_AFTER) return; const state = Y.encodeStateAsUpdate(doc); storage.transactionSync(() => { storage.sql.exec("DELETE FROM updates"); storage.sql.exec("INSERT INTO updates (data) VALUES (?)", state); }); } /** Frees room capacity by dropping clean, seeded files nobody is looking at; they reseed on next open. */ export function evictClean(doc: Y.Doc, sql: RoomSql, open: Set): void { const files = filesMap(doc); doc.transact(() => { for (const [path, meta] of [...files.entries()]) { if (files.size < MAX_ROOM_FILES * EVICT_TARGET) break; if (meta.state !== "seeded" || open.has(path)) continue; const text = textOf(doc, path); if (meta.kind === "text" && text.toString() !== getBase(sql, path)?.text) continue; files.delete(path); text.delete(0, text.length); sql.exec("DELETE FROM bases WHERE path = ?", path); } }, "evict"); } const CLOSE_POLICY = 1008; /** * The `files` map is server-owned. Any client transaction that touches it (sets, overwrites or deletes) * is undone straight away and the socket is closed, so writers can't forge a file's kind or state. */ export function guardMetadata(doc: Y.Doc): void { const undo = new Y.UndoManager(filesMap(doc), { captureTimeout: 0, captureTransaction: (transaction) => transaction.origin instanceof WebSocket, trackedOrigins: new Set([WebSocket]), }); undo.on("stack-item-added", (event: { origin: unknown }) => { queueMicrotask(() => { undo.undo(); undo.clear(); if (event.origin instanceof WebSocket) event.origin.close(CLOSE_POLICY, "File metadata is managed by the server"); }); }); }