ethercalc
Version:
Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)
189 lines (172 loc) • 6.85 kB
text/typescript
/**
* 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 };
}