ethercalc
Version:
Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)
184 lines (174 loc) • 7.15 kB
text/typescript
/**
* Chunked snapshot storage for RoomDO.
*
* Cloudflare Durable Object storage rejects any single `put()` value
* over 128 KiB. Most EtherCalc rooms have snapshots well under that
* ceiling, but the legacy dump contains ~17 k rooms whose SocialCalc
* save is 150 KiB–2 MB — large enough that the naive
* `storage.put("snapshot", <str>)` path would fail with the opaque
* `500 Internal Server Error` we hit during the 2026-04-21 production
* migration (see AGENTS.md §14).
*
* Two layouts on disk:
*
* - Legacy / small: one key `snapshot` → save string.
* - Chunked / large: one key `snapshot:meta` → `{chunks: N}` plus
* N keys `snapshot:chunk:<padSeq(i)>` → split UTF-8 pieces.
*
* Readers call {@link readSnapshot}, which tries the fast single-key
* path first and falls back to reassembly. Writers call
* {@link snapshotEntries} to get a plain-object shard of keys to merge
* into whatever batched `storage.put(entries)` they're building.
*
* Chunk size is 100 KiB — leaves 28 KiB of headroom under 128 KiB for
* JSON framing / encoding overhead when the same batch also carries
* other large values (the margin is conservative; D1's limit-walking
* behavior is documented sparsely).
*/
import {
STORAGE_KEYS,
snapshotChunkKey,
} from '@ethercalc/shared/storage-keys';
/** Max bytes per chunk — well under the 128 KiB DO-storage ceiling. */
export const SNAPSHOT_CHUNK_BYTES = 100 * 1024;
/** Shape of the `snapshot:meta` value when chunking is in use. */
export interface SnapshotMeta {
/** Number of `snapshot:chunk:<i>` entries, 1-based count. */
readonly chunks: number;
}
/**
* Check whether the room has any snapshot at all, under either
* layout, without paying to reassemble the chunked form. Used by
* `/_exists/:room` where the answer is just a boolean.
*/
export async function hasSnapshot(
storage: DurableObjectStorage,
): Promise<boolean> {
const keys = [STORAGE_KEYS.snapshot, STORAGE_KEYS.snapshotMeta];
const got = await storage.get<unknown>(keys);
return (
typeof got.get(STORAGE_KEYS.snapshot) === 'string' ||
got.get(STORAGE_KEYS.snapshotMeta) !== undefined
);
}
/**
* Read the room's snapshot, reassembling from chunks when present.
* Returns `null` when no snapshot exists under either layout — the
* caller distinguishes "empty room" from "never seeded".
*/
export async function readSnapshot(
storage: DurableObjectStorage,
): Promise<string | null> {
// Fast path: single-key snapshot (small room or legacy DO).
const single = await storage.get<string>(STORAGE_KEYS.snapshot);
if (typeof single === 'string') return single;
// Chunked path: meta tells us how many pieces to fetch.
const meta = await storage.get<SnapshotMeta>(STORAGE_KEYS.snapshotMeta);
if (meta === undefined || meta === null) return null;
const keys: string[] = [];
for (let i = 0; i < meta.chunks; i++) keys.push(snapshotChunkKey(i));
const got = await storage.get<string>(keys);
const parts: string[] = [];
for (let i = 0; i < meta.chunks; i++) {
const k = snapshotChunkKey(i);
const part = got.get(k);
// A missing chunk would silently corrupt the save — fail loud.
if (typeof part !== 'string') {
throw new Error(`snapshot chunk ${i} missing under key ${k}`);
}
parts.push(part);
}
return parts.join('');
}
/**
* Produce the snapshot-related entries for a batched `storage.put()`.
* Does NOT write anything — the caller merges the returned keys into
* its own entries object so the whole seed lands atomically in one
* `put(entries)` call.
*
* For snapshots ≤ {@link SNAPSHOT_CHUNK_BYTES}, returns `{snapshot: …}`.
* For larger, returns `{snapshot:meta: {chunks}, snapshot:chunk:<i>: …}`.
*
* Empty-snapshot contract: the caller decides whether to call this at
* all. Passing `""` produces an entries map with the single-key form
* (storing an empty string), which mirrors the behavior of the
* pre-chunking code path.
*/
export function snapshotEntries(snapshot: string): Record<string, unknown> {
const bytes = utf8Bytes(snapshot);
if (bytes <= SNAPSHOT_CHUNK_BYTES) {
return { [STORAGE_KEYS.snapshot]: snapshot };
}
const chunks = chunkString(snapshot, SNAPSHOT_CHUNK_BYTES);
const out: Record<string, unknown> = {
[STORAGE_KEYS.snapshotMeta]: { chunks: chunks.length } satisfies SnapshotMeta,
};
for (let i = 0; i < chunks.length; i++) {
out[snapshotChunkKey(i)] = chunks[i];
}
return out;
}
/**
* Return the full list of storage keys that belong to a snapshot
* under EITHER layout, so callers that don't do a full `deleteAll()`
* can selectively remove the old shape before writing a new one.
* Accepts the prior meta object (from `readMeta`) so the chunk count
* is known without another round-trip.
*/
export async function readSnapshotMeta(
storage: DurableObjectStorage,
): Promise<SnapshotMeta | null> {
const meta = await storage.get<SnapshotMeta>(STORAGE_KEYS.snapshotMeta);
return meta ?? null;
}
/**
* Split a string into UTF-8 byte-bounded chunks without splitting
* multi-byte code points. `String.prototype.slice` can leave a high
* surrogate at the end of one chunk and its pair at the start of the
* next — that's the behavior we want for JS strings (which are UTF-16)
* because concat reassembles byte-for-byte. But to bound each chunk
* by UTF-8 byte length we have to walk character-by-character and
* accumulate until we'd cross the limit, then cut before the
* would-overflow character.
*/
function chunkString(s: string, maxBytes: number): string[] {
const out: string[] = [];
// Iterate code-points (not code-units) so surrogate pairs stay
// together. `for (const ch of s)` does the right thing.
let current = '';
let currentBytes = 0;
for (const ch of s) {
const chBytes = utf8Bytes(ch);
if (currentBytes + chBytes > maxBytes && current.length > 0) {
out.push(current);
current = '';
currentBytes = 0;
}
current += ch;
currentBytes += chBytes;
}
// The loop always has at least one character in `current` when it
// exits (the last iteration appends before checking the next
// threshold), so the tail push is unconditional in practice. The
// guard is kept as a sanity check; istanbul treats the false branch
// as dead code.
/* istanbul ignore else */
if (current.length > 0) out.push(current);
// Degenerate case: empty input should still produce one empty chunk
// so `{chunks: 1}` + `snapshot:chunk:0 = ""` reads back correctly.
// In practice callers skip chunking for empty snapshots via the
// fast path above, so this only fires if someone passes `""` with
// a maxBytes of 0.
/* istanbul ignore next */
if (out.length === 0) out.push('');
return out;
}
/**
* UTF-8 byte length of a string. Uses `TextEncoder` (available in
* workerd, Bun, and Node ≥ 16) rather than `Buffer.byteLength` so the
* same code path runs inside the DO, where `Buffer` is only available
* via the `nodejs_compat` flag.
*/
function utf8Bytes(s: string): number {
return new TextEncoder().encode(s).byteLength;
}