UNPKG

ethercalc

Version:

Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)

189 lines (172 loc) 6.85 kB
/** * Phase 11b — migration seed payload validation. * * Pure (`Node`-gated) helpers used by both the DO's `POST /_do/seed` * handler and the worker-level `PUT /_migrate/seed/:room` route. Keeping * validation here means the 100% coverage gate covers every rejection * branch without requiring workerd. * * The seed payload shape mirrors one legacy Redis room's worth of data * (per AGENTS.md §6.3): * - `snapshot` string — SocialCalc save. `''` means "don't * store a snapshot" (log-only rooms). * - `log`/`audit`/`chat` string[] — ordered entries under their * corresponding DO prefix. * - `ecell` Record — per-user last cursor. * - `updatedAt` number — epoch ms for the D1 `rooms.updated_at` * mirror; defaults to injected `now()`. * * Inputs we don't recognize (unknown top-level keys, nested non-strings) * are rejected with a specific error message — migration runs should * fail loudly on corrupt dumps rather than silently skip data. */ /** Normalized payload. Missing fields are filled with sensible defaults. */ export interface SeedPayload { /** `''` means no snapshot recorded; the DO should skip the snapshot write. */ readonly snapshot: string; readonly log: readonly string[]; readonly audit: readonly string[]; readonly chat: readonly string[]; readonly ecell: Readonly<Record<string, string>>; /** Epoch ms to record as `meta:updated_at` and the D1 `rooms.updated_at`. */ readonly updatedAt: number; /** * When `true`, the DO persists the room but does NOT write the D1 * `rooms` index row. The migrator sets this during bulk imports so * it can batch index writes via `PUT /_migrate/bulk-index` — one * SQL statement for N rows instead of one round-trip per room. * Defaults to `false` (DO mirrors D1 inline, matching the * pre-2026-04-21 shape). */ readonly skipIndex: boolean; } export type SeedParseResult = | { readonly ok: true; readonly value: SeedPayload } | { readonly ok: false; readonly error: string }; /** * Parse a seed request body (already `JSON.parse`d). `now` is injected so * tests can pin the default `updatedAt`. */ export function parseSeedPayload(raw: unknown, now: () => number): SeedParseResult { if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { return { ok: false, error: 'body must be a JSON object' }; } const rec = raw as Record<string, unknown>; const rawSnap = rec['snapshot']; const snapshot = rawSnap === undefined ? '' : rawSnap; if (typeof snapshot !== 'string') { return { ok: false, error: 'snapshot must be a string' }; } const log = parseStringArray(rec['log'], 'log'); if (!log.ok) return log; const audit = parseStringArray(rec['audit'], 'audit'); if (!audit.ok) return audit; const chat = parseStringArray(rec['chat'], 'chat'); if (!chat.ok) return chat; const ecell = parseStringRecord(rec['ecell']); if (!ecell.ok) return ecell; const rawTs = rec['updatedAt']; let updatedAt: number; if (rawTs === undefined) { updatedAt = now(); } else if (typeof rawTs === 'number' && Number.isFinite(rawTs)) { updatedAt = rawTs; } else { return { ok: false, error: 'updatedAt must be a finite number' }; } const rawSkip = rec['skipIndex']; let skipIndex: boolean; if (rawSkip === undefined) { skipIndex = false; } else if (typeof rawSkip === 'boolean') { skipIndex = rawSkip; } else { return { ok: false, error: 'skipIndex must be a boolean' }; } return { ok: true, value: { snapshot, log: log.value, audit: audit.value, chat: chat.value, ecell: ecell.value, updatedAt, skipIndex, }, }; } /** One entry of a `PUT /_migrate/bulk-index` payload. */ export interface BulkIndexEntry { readonly room: string; readonly updatedAt: number; } export type BulkIndexParseResult = | { readonly ok: true; readonly value: readonly BulkIndexEntry[] } | { readonly ok: false; readonly error: string }; /** * Parse the batched index payload: `{ rooms: [{room, updatedAt}, …] }`. * Rejects anything else so corrupt dumps fail loudly rather than * silently skipping timestamp writes (which would break `/_roomtimes` * ordering without any error surface). */ export function parseBulkIndexPayload(raw: unknown): BulkIndexParseResult { if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { return { ok: false, error: 'body must be a JSON object' }; } const rec = raw as Record<string, unknown>; const rooms = rec['rooms']; if (!Array.isArray(rooms)) { return { ok: false, error: 'rooms must be an array' }; } const out: BulkIndexEntry[] = []; for (const entry of rooms) { if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { return { ok: false, error: 'rooms entries must be objects' }; } const e = entry as Record<string, unknown>; const room = e['room']; if (typeof room !== 'string' || room.length === 0) { return { ok: false, error: 'rooms[].room must be a non-empty string' }; } const updatedAt = e['updatedAt']; if (typeof updatedAt !== 'number' || !Number.isFinite(updatedAt)) { return { ok: false, error: 'rooms[].updatedAt must be a finite number' }; } out.push({ room, updatedAt }); } return { ok: true, value: out }; } type ArrayResult = | { readonly ok: true; readonly value: readonly string[] } | { readonly ok: false; readonly error: string }; function parseStringArray(raw: unknown, field: string): ArrayResult { if (raw === undefined) return { ok: true, value: [] }; if (!Array.isArray(raw)) return { ok: false, error: `${field} must be a string[]` }; for (const entry of raw) { if (typeof entry !== 'string') { return { ok: false, error: `${field} must be a string[]` }; } } return { ok: true, value: raw as readonly string[] }; } type RecordResult = | { readonly ok: true; readonly value: Readonly<Record<string, string>> } | { readonly ok: false; readonly error: string }; function parseStringRecord(raw: unknown): RecordResult { if (raw === undefined) return { ok: true, value: {} }; if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { return { ok: false, error: 'ecell must be Record<string, string>' }; } const out: Record<string, string> = {}; for (const [key, value] of Object.entries(raw)) { if (key.length === 0) { return { ok: false, error: 'ecell keys must be non-empty' }; } if (typeof value !== 'string') { return { ok: false, error: 'ecell must be Record<string, string>' }; } out[key] = value; } return { ok: true, value: out }; }