ethercalc
Version:
Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)
199 lines (190 loc) • 7.67 kB
text/typescript
import { withRoomsSchema } from './d1-schema.ts';
import { isPublicRoomIndexEntry } from './formdata-sibling.ts';
/**
* D1-backed cross-room index helpers (Phase 5.1).
*
* The DO owns the authoritative per-room state (snapshot/log/audit under
* `state.storage`). Cross-room queries like `/_rooms`, `/_roomlinks`, and
* `/_roomtimes` need a flat listing that D1 serves cheaply.
*
* Mirror semantics:
* - `mirrorRoomToD1(db, room, updatedAt)` — upsert `rooms(room, updated_at)`
* after every snapshot write in RoomDO.
* - `deleteRoomFromD1(db, room)` — on `DELETE /_do/all`.
* - `listRooms(db)` — ordered by room name (matches `GET /_rooms` shape).
* - `listRoomTimes(db)` — `{room: updated_at}` sorted desc by updated_at.
* - `renderRoomLinks(rooms, basepath)` — HTML `<a>` list body for
* `/_roomlinks` (Phase 5 sensible-fix per §13 Q1).
*
* All helpers are pure functions of their arguments: they take a
* `D1Database` and compute/return a value with no hidden state. The
* route/RoomDO layer is responsible for deciding whether a binding is
* present at all — when `env.DB` is `undefined`, callers short-circuit
* to the empty-index case before invoking these helpers. That keeps
* this module simple, 100% coverage-gated, and platform-agnostic.
*
* Schema (migrations/0001_rooms.sql):
*
* CREATE TABLE rooms (
* room TEXT PRIMARY KEY,
* updated_at INTEGER NOT NULL,
* cors_public INTEGER NOT NULL DEFAULT 0
* );
* CREATE INDEX rooms_updated_at ON rooms(updated_at DESC);
*
* `cors_public` is pre-wired for the Phase 9 `?cors=1` toggle; Phase 5.1
* doesn't read or write it (the `DEFAULT 0` in the DDL handles inserts).
*/
/**
* Upsert a row into `rooms`. Idempotent — repeated mirrors of the same
* room with a newer `updatedAt` overwrite the timestamp but preserve
* the `cors_public` flag (via `ON CONFLICT(room) DO UPDATE` clause that
* only touches `updated_at`).
*/
export async function mirrorRoomToD1(
db: D1Database,
room: string,
updatedAt: number,
): Promise<void> {
if (!isPublicRoomIndexEntry(room)) return;
await withRoomsSchema(db, async () => {
await db
.prepare(
'INSERT INTO rooms (room, updated_at) VALUES (?1, ?2) ' +
'ON CONFLICT(room) DO UPDATE SET updated_at = excluded.updated_at',
)
.bind(room, updatedAt)
.run();
});
}
/**
* Upsert many rooms in a single SQL statement — the batched sibling of
* {@link mirrorRoomToD1}. Migration seeds 1.8M rooms through
* `PUT /_migrate/seed/:room` + `skipIndex: true`, then the migrator
* flushes `(room, updatedAt)` pairs in chunks of ~200 through the
* `PUT /_migrate/bulk-index` route (which calls this helper).
*
* One `INSERT … VALUES (?,?),(?,?),…` is ~100× cheaper than N single-
* row inserts against D1's sqlite primary: each call is one network
* round-trip, one transaction commit, one WAL fsync. Without this,
* the full-migration wall-clock ends up D1-bound (~5 h at 100 rps).
*
* No-ops cleanly when `entries` is empty — D1 would reject the empty
* `VALUES ()` clause otherwise. Prepared-statement parameter cap on
* D1 is **100** (not SQLite's 999) — at two params per entry the safe
* max is ~49 entries per call. The migrator's `HttpTarget` batches at
* 50, so the ceiling comes from D1, not this helper. Over-batching
* lands a generic `500 Internal Server Error` with no actionable
* detail — see AGENTS.md §14 2026-04-21 for the incident.
*/
export async function bulkMirrorRoomsToD1(
db: D1Database,
entries: readonly { readonly room: string; readonly updatedAt: number }[],
): Promise<void> {
const publicEntries = entries.filter((e) => isPublicRoomIndexEntry(e.room));
if (publicEntries.length === 0) return;
await withRoomsSchema(db, async () => {
const placeholders = publicEntries.map(() => '(?, ?)').join(', ');
const params: (string | number)[] = [];
for (const e of publicEntries) {
params.push(e.room, e.updatedAt);
}
await db
.prepare(
`INSERT INTO rooms (room, updated_at) VALUES ${placeholders} ` +
'ON CONFLICT(room) DO UPDATE SET updated_at = excluded.updated_at',
)
.bind(...params)
.run();
});
}
/** Delete a room row. Safe to call on a room that doesn't exist. */
export async function deleteRoomFromD1(
db: D1Database,
room: string,
): Promise<void> {
await withRoomsSchema(db, async () => {
await db.prepare('DELETE FROM rooms WHERE room = ?1').bind(room).run();
});
}
/**
* Return all room names, ordered by name. Matches the legacy `KEYS
* snapshot-*` path per §10.2 — there's no semantic order guarantee in
* the Redis variant either, but Node's `.sort()` happened to yield
* ascending bytes. We do the same via `ORDER BY room ASC`.
*/
export async function listRooms(db: D1Database): Promise<string[]> {
return withRoomsSchema(db, async () => {
const res = await db
.prepare('SELECT room FROM rooms ORDER BY room ASC')
.all<{ room: string }>();
// D1's `.all()` guarantees `results: Array<T>` when the query is a
// SELECT that succeeded. In practice the binding also sets it to an
// empty array for zero-row responses, so we can read it directly.
return res.results.map((r) => r.room).filter(isPublicRoomIndexEntry);
});
}
/**
* Return `{room: updated_at}` sorted by `updated_at` desc. The
* insertion order of the returned object's keys is what the HTTP
* route serializes via `JSON.stringify`, which preserves property
* insertion order for string keys. Matches the legacy
* `HGETALL timestamps` + desc sort in `src/main.ls`.
*/
export async function listRoomTimes(
db: D1Database,
): Promise<Record<string, number>> {
return withRoomsSchema(db, async () => {
const res = await db
.prepare(
'SELECT room, updated_at FROM rooms ORDER BY updated_at DESC, room ASC',
)
.all<{ room: string; updated_at: number }>();
const out: Record<string, number> = {};
for (const row of res.results) {
if (!isPublicRoomIndexEntry(row.room)) continue;
out[row.room] = row.updated_at;
}
return out;
});
}
/**
* HTML-encode a string for safe inclusion in `<a>` href/text. Targets
* the five mandatory XML characters. `encodeURI` does not protect
* against `&`/`"`, which legit room names cannot contain today (see
* `encodeRoom` + §7 item 15), but encoding defensively keeps any
* future relaxation safe from injection.
*/
function htmlEscape(s: string): string {
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/**
* Build the HTML `<a>` list body for `GET /_roomlinks`. Keeps the
* Phase 5 sensible-fix shape (§13 Q1) — legacy returned a JSON array
* inside a `text/html` response; we render actual anchors so browsers
* can use the page.
*
* Empty-state body is `[]` for oracle-recording byte compatibility —
* the baseline recording captured the legacy JSON-in-HTML bug exactly
* once, at empty state, where the legacy body happens to be `[]`. When
* rooms exist the HTML anchors diverge from the legacy JSON and that's
* the documented sensible fix. `basepath` is passed through so deploys
* behind a sub-path router get correct relative links.
*/
export function renderRoomLinks(
rooms: readonly string[],
basepath: string,
): string {
if (rooms.length === 0) return '[]';
return rooms
.map((r) => {
const href = `${basepath}/${htmlEscape(r)}`;
return `<a href="${href}">${htmlEscape(r)}</a>`;
})
.join('');
}