ethercalc
Version:
Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)
327 lines (299 loc) • 12.1 kB
text/typescript
/**
* Filesystem-source adapter — streams rooms out of a legacy on-disk
* EtherCalc dump written by `src/db.ls` when no Redis server was
* reachable. This is the "first-load" path for self-hosters moving from
* the legacy Node implementation to the new Worker, most notably the
* Sandstorm grain (`sandstorm-pkgdef.capnp` sets `OPENSHIFT_DATA_DIR=/var`
* and falls through to filesystem storage).
*
* Two layouts supported, mirroring what the legacy loader reads at
* `src/db.ls:73-96`:
*
* 1. Directory form — `dump/` with per-key files:
* `snapshot-<room>.txt` — raw SocialCalc save string (UTF-8)
* `audit-<room>.txt` — newline-joined with \\n/\\r/\\\\ escape
* encoding (legacy encoder at db.ls:122-125)
* Log, chat, and ecell are NOT persisted to disk in this layout —
* legacy intentionally keeps them in-memory and drops them on restart.
* We yield rooms with empty log/chat/ecell to match.
*
* 2. Single-blob form — `dump.json`:
* A flat object whose keys match the legacy Redis key patterns
* (`snapshot-<room>`, `log-<room>`, `audit-<room>`, `chat-<room>`,
* `ecell-<room>`, plus a shared `timestamps` hash). This is the
* earliest filesystem layout — predates the `dump/` directory form
* and still exists in older self-hosted instances.
*
* Path resolution: if `path` is a file we assume single-blob; if it's a
* directory we prefer `<path>/dump.json` → `<path>/dump/` → `<path>`
* itself (treated as a dump directory). This lets Sandstorm callers
* point `--source file:///var` at the grain root and have auto-detection
* do the right thing whichever layout the previous legacy install used.
*
* Memory: O(1) per room in dir mode (one file read at a time) and O(N)
* in JSON mode (full blob loaded — unavoidable because JSON isn't
* streamable). Legacy `dump.json` instances are tiny (≤ a few MB); the
* large dumps are always Redis-backed.
*/
import type { Room } from '../apply.ts';
import {
filterOversized,
type OversizedCallback,
} from './filter-oversized.ts';
/** Narrow filesystem surface — `node:fs/promises` satisfies it. */
export interface FsLike {
readdir(path: string): Promise<string[]>;
readFile(path: string, encoding: 'utf8'): Promise<string>;
stat(path: string): Promise<FsStatLike>;
}
/** Minimal `Stats` shape — only the `isDirectory`/`isFile` predicates. */
export interface FsStatLike {
isDirectory(): boolean;
isFile(): boolean;
}
export interface RoomsFromFilesystemOptions {
/** Invoked after each room is yielded. */
onProgress?: (info: { readonly done: number; readonly total: number }) => void;
/**
* Max bytes per individual log/audit/chat entry. Default 120 KiB.
* See {@link filterOversized} for the rationale.
*/
maxEntryBytes?: number;
/** Called when an entry is dropped for exceeding `maxEntryBytes`. */
onOversizedEntry?: OversizedCallback;
/**
* Max bytes for a whole room's snapshot — rooms whose snapshot exceeds
* this are skipped entirely (otherwise the DO would exist with logs
* but no save, a confusing half-state). Default Infinity — the worker
* chunks snapshots internally since 2026-04-22 (AGENTS.md §14).
*/
maxSnapshotBytes?: number;
/** Called when a whole room is skipped for an oversized snapshot. */
onSkippedRoom?: (info: {
readonly room: string;
readonly bytes: number;
}) => void;
}
/**
* Enumerate every EtherCalc room in the on-disk dump at `path` and
* yield a fully-assembled {@link Room} for each. Auto-detects directory
* vs single-blob layout per the module docstring.
*/
export async function* roomsFromFilesystem(
fs: FsLike,
path: string,
options: RoomsFromFilesystemOptions = {},
): AsyncIterable<Room> {
const layout = await detectLayout(fs, path);
if (layout.kind === 'json') {
yield* roomsFromJsonBlob(fs, layout.file, options);
return;
}
yield* roomsFromDumpDir(fs, layout.dir, options);
}
type Layout =
| { kind: 'json'; file: string }
| { kind: 'dir'; dir: string };
async function detectLayout(fs: FsLike, path: string): Promise<Layout> {
const st = await fs.stat(path);
if (st.isFile()) return { kind: 'json', file: path };
// Directory: prefer dump.json, then dump/, then treat path as the dump dir.
const entries = await fs.readdir(path);
if (entries.includes('dump.json')) {
return { kind: 'json', file: joinPath(path, 'dump.json') };
}
if (entries.includes('dump')) {
const sub = joinPath(path, 'dump');
const subStat = await fs.stat(sub);
if (subStat.isDirectory()) return { kind: 'dir', dir: sub };
}
return { kind: 'dir', dir: path };
}
// Minimal posix-style join — legacy dumps live on Linux/Unix, so this
// is consistent across caller platforms without pulling `node:path`.
function joinPath(a: string, b: string): string {
if (a.endsWith('/')) return `${a}${b}`;
return `${a}/${b}`;
}
// ───────────── single-blob (dump.json) ─────────────
const KEY_PREFIXES = ['snapshot-', 'log-', 'audit-', 'chat-', 'ecell-'] as const;
/** Intermediate accumulator for the JSON-blob mode. */
interface RoomAccum {
snapshot: string;
log: string[];
audit: string[];
chat: string[];
ecell: Record<string, string>;
}
function emptyAccum(): RoomAccum {
return { snapshot: '', log: [], audit: [], chat: [], ecell: {} };
}
async function* roomsFromJsonBlob(
fs: FsLike,
file: string,
options: RoomsFromFilesystemOptions,
): AsyncIterable<Room> {
const text = await fs.readFile(file, 'utf8');
const parsed = JSON.parse(text) as Record<string, unknown>;
// First pass — group keys by room. We walk the blob once and stash
// typed shape per (room, kind); an unrecognized prefix is ignored,
// matching legacy db.ls which simply doesn't touch keys it doesn't
// know about.
const rooms = new Map<string, RoomAccum>();
const getRoom = (name: string): RoomAccum => {
let r = rooms.get(name);
if (r === undefined) {
r = emptyAccum();
rooms.set(name, r);
}
return r;
};
for (const [key, value] of Object.entries(parsed)) {
const prefix = KEY_PREFIXES.find((p) => key.startsWith(p));
if (prefix === undefined) continue;
const name = key.slice(prefix.length);
const r = getRoom(name);
if (prefix === 'snapshot-' && typeof value === 'string') {
r.snapshot = value;
} else if (prefix === 'log-' && Array.isArray(value)) {
r.log = value.map(String);
} else if (prefix === 'audit-' && Array.isArray(value)) {
r.audit = value.map(String);
} else if (prefix === 'chat-' && Array.isArray(value)) {
r.chat = value.map(String);
} else if (prefix === 'ecell-' && isStringRecord(value)) {
r.ecell = { ...value };
}
}
const timestamps = parseTimestamps(parsed['timestamps']);
const sorted = Array.from(rooms.keys()).sort();
const total = sorted.length;
const maxEntryBytes = options.maxEntryBytes ?? 120 * 1024;
const maxSnapshotBytes = options.maxSnapshotBytes ?? Infinity;
let done = 0;
for (const name of sorted) {
const accum = rooms.get(name) as RoomAccum;
const snapshotBytes = Buffer.byteLength(accum.snapshot, 'utf8');
if (snapshotBytes > maxSnapshotBytes) {
options.onSkippedRoom?.({ room: name, bytes: snapshotBytes });
done += 1;
options.onProgress?.({ done, total });
continue;
}
const ts =
timestamps.get(`timestamp-${name}`) ?? timestamps.get(name);
const updatedAt =
ts !== undefined && Number.isFinite(Number(ts)) ? Number(ts) : undefined;
yield {
name,
snapshot: accum.snapshot,
log: filterOversized(accum.log, 'log', name, maxEntryBytes, options.onOversizedEntry),
audit: filterOversized(accum.audit, 'audit', name, maxEntryBytes, options.onOversizedEntry),
chat: filterOversized(accum.chat, 'chat', name, maxEntryBytes, options.onOversizedEntry),
ecell: accum.ecell,
...(updatedAt !== undefined ? { updatedAt } : {}),
};
done += 1;
options.onProgress?.({ done, total });
}
}
function isStringRecord(v: unknown): v is Record<string, string> {
if (v === null || typeof v !== 'object' || Array.isArray(v)) return false;
for (const val of Object.values(v)) {
if (typeof val !== 'string') return false;
}
return true;
}
function parseTimestamps(v: unknown): ReadonlyMap<string, number | string> {
const out = new Map<string, number | string>();
if (v === null || typeof v !== 'object' || Array.isArray(v)) return out;
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
if (typeof val === 'number' || typeof val === 'string') out.set(k, val);
}
return out;
}
// ───────────── directory (dump/) ─────────────
/**
* Reverse of the legacy encoder at db.ls:122-125:
* str = entry.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\\/g, '\\\\')
*
* Decoder order matters — legacy applies newline/CR first, backslash
* last, so we reverse in the same order (newline first). Note: the
* legacy encoder is asymmetric (encodes backslashes AFTER having added
* escaped newlines, so a newline-containing entry round-trips with an
* extra backslash). We preserve that quirk so migrated audit entries
* byte-match what legacy EtherCalc would have displayed to the user.
*/
function decodeLegacyAuditLine(line: string): string {
return line
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\r')
.replace(/\\\\/g, '\\');
}
async function* roomsFromDumpDir(
fs: FsLike,
dir: string,
options: RoomsFromFilesystemOptions,
): AsyncIterable<Room> {
const entries = await fs.readdir(dir);
// Map of room → what we've found on disk. A `.txt` suffix and a
// `<kind>-<room>` stem is the format db.ls uses (see db.ls:74-89).
// Hidden files (dotfiles) are skipped to match the legacy loader's
// `filter (/^[^.]/.test _)`.
const files = new Map<string, { snapshot?: string; audit?: string }>();
for (const entry of entries) {
if (entry.startsWith('.')) continue;
if (!entry.endsWith('.txt')) continue;
const stem = entry.slice(0, -'.txt'.length);
const dash = stem.indexOf('-');
if (dash <= 0) continue;
const kind = stem.slice(0, dash);
const name = stem.slice(dash + 1);
if (kind !== 'snapshot' && kind !== 'audit') continue;
let f = files.get(name);
if (f === undefined) {
f = {};
files.set(name, f);
}
f[kind] = joinPath(dir, entry);
}
const sorted = Array.from(files.keys()).sort();
const total = sorted.length;
const maxEntryBytes = options.maxEntryBytes ?? 120 * 1024;
const maxSnapshotBytes = options.maxSnapshotBytes ?? Infinity;
let done = 0;
for (const name of sorted) {
const f = files.get(name) as { snapshot?: string; audit?: string };
const snapshot = f.snapshot !== undefined
? await fs.readFile(f.snapshot, 'utf8')
: '';
const snapshotBytes = Buffer.byteLength(snapshot, 'utf8');
if (snapshotBytes > maxSnapshotBytes) {
options.onSkippedRoom?.({ room: name, bytes: snapshotBytes });
done += 1;
options.onProgress?.({ done, total });
continue;
}
let audit: string[] = [];
if (f.audit !== undefined) {
const raw = await fs.readFile(f.audit, 'utf8');
audit = raw.split('\n').filter((s) => s.length > 0).map(decodeLegacyAuditLine);
}
yield {
name,
snapshot,
// log/chat/ecell are never persisted to disk in dir mode — legacy
// db.ls keeps them in-memory only, so a restart drops them. We
// match by yielding empty.
log: [],
audit: filterOversized(audit, 'audit', name, maxEntryBytes, options.onOversizedEntry),
chat: [],
ecell: {},
// updatedAt omitted — legacy initializes timestamps[id]=0 on
// first load from disk (db.ls:80). The apply layer defaults to 0
// when undefined.
};
done += 1;
options.onProgress?.({ done, total });
}
}