import { migrateSave, type SaveDocument } from "@deadhead/schema"; /** * Where a save lives (CR-014 ยง2): a main slot plus three rotating backups. * Each backend only stores text in named slots, writing a slot all-or-nothing; * `SaveStorage` does the rotation, validation and fallback once for all of them. */ export type Slot = "main" | "backup-1" | "backup-2" | "backup-3"; export const BACKUP_SLOTS = ["backup-1", "backup-2", "backup-3"] as const; export interface SaveBackend { /** For debug info: "memory", "indexeddb" or "filesystem". */ readonly kind: string; read(slot: Slot): Promise; /** Replaces the slot's text completely, or leaves it as it was. */ write(slot: Slot, text: string): Promise; remove(slot: Slot): Promise; } export type LoadResult = | { status: "empty" } | { status: "loaded"; save: SaveDocument } /** The main save was unreadable; the newest valid backup was loaded instead. */ | { status: "restored-from-backup"; save: SaveDocument; backup: number; problem: string } /** Nothing readable: the main save and every backup failed. */ | { status: "unreadable"; problem: string }; export interface BackupInfo { /** 1 is the newest. */ index: number; lastTimeSeen: number | null; valid: boolean; } /** Text to a save, or the reason it isn't one. */ function parse(text: string) { let raw: unknown; try { raw = JSON.parse(text); } catch { return { ok: false as const, error: "The save isn't valid JSON." }; } return migrateSave(raw); } export class SaveStorage { constructor(private readonly backend: SaveBackend) {} get kind(): string { return this.backend.kind; } /** * The current save. Missing main save: empty (a first launch). Unreadable * main save: the newest backup that validates, with the problem reported. */ async load(): Promise { const text = await this.backend.read("main"); if (text === null) return { status: "empty" }; const main = parse(text); if (main.ok) return { status: "loaded", save: main.save }; for (const [i, slot] of BACKUP_SLOTS.entries()) { const backup = await this.backend.read(slot); if (backup === null) continue; const parsed = parse(backup); if (parsed.ok) { return { status: "restored-from-backup", save: parsed.save, backup: i + 1, problem: main.error, }; } } return { status: "unreadable", problem: main.error }; } /** Writes the save; the previous main save becomes backup 1, and so on, keeping three. */ async save(save: SaveDocument): Promise { const previous = await this.backend.read("main"); if (previous !== null) { for (let i = BACKUP_SLOTS.length - 1; i > 0; i--) { const older = await this.backend.read(BACKUP_SLOTS[i - 1]!); if (older !== null) await this.backend.write(BACKUP_SLOTS[i]!, older); } await this.backend.write("backup-1", previous); } await this.backend.write("main", JSON.stringify(save)); } async listBackups(): Promise { const found: BackupInfo[] = []; for (const [i, slot] of BACKUP_SLOTS.entries()) { const text = await this.backend.read(slot); if (text === null) continue; const parsed = parse(text); found.push({ index: i + 1, valid: parsed.ok, lastTimeSeen: parsed.ok ? parsed.save.lastTimeSeen : null, }); } return found; } async loadBackup(index: number) { const slot = BACKUP_SLOTS[index - 1]; if (!slot) return { ok: false as const, error: `There is no backup ${index}.` }; const text = await this.backend.read(slot); return text === null ? { ok: false as const, error: `Backup ${index} is empty.` } : parse(text); } /** Deletes the save and its backups (a new company or an import starts clean). */ async clear(): Promise { for (const slot of ["main", ...BACKUP_SLOTS] as const) await this.backend.remove(slot); } } /** For tests and as a fallback when the device offers no storage. */ export class MemoryBackend implements SaveBackend { readonly kind = "memory"; readonly slots = new Map(); async read(slot: Slot) { return this.slots.get(slot) ?? null; } async write(slot: Slot, text: string) { this.slots.set(slot, text); } async remove(slot: Slot) { this.slots.delete(slot); } } const DB_NAME = "deadhead"; const STORE = "saves"; /** Web storage: one IndexedDB object store, one record per slot (each put is atomic). */ export class IndexedDbBackend implements SaveBackend { readonly kind = "indexeddb"; #db: Promise | null = null; constructor(private readonly factory: IDBFactory) {} #open(): Promise { this.#db ??= new Promise((resolve, reject) => { const request = this.factory.open(DB_NAME, 1); request.onupgradeneeded = () => request.result.createObjectStore(STORE); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); return this.#db; } async #run( mode: IDBTransactionMode, op: (store: IDBObjectStore) => IDBRequest, ): Promise { const db = await this.#open(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE, mode); const request = op(tx.objectStore(STORE)); tx.oncomplete = () => resolve(request.result); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); } async read(slot: Slot) { const value = await this.#run("readonly", (s) => s.get(slot) as IDBRequest); return value ?? null; } async write(slot: Slot, text: string) { await this.#run("readwrite", (s) => s.put(text, slot)); } async remove(slot: Slot) { await this.#run("readwrite", (s) => s.delete(slot)); } } /** * The parts of the Capacitor Filesystem plugin this uses (`@capacitor/filesystem`), * so the engine doesn't depend on Capacitor and tests can pass a fake. */ export interface FilesystemPlugin { readFile(options: { path: string; directory: "DATA"; encoding: "utf8"; }): Promise<{ data: string | Blob }>; writeFile(options: { path: string; data: string; directory: "DATA"; encoding: "utf8"; recursive?: boolean; }): Promise; rename(options: { from: string; to: string; directory: "DATA"; toDirectory: "DATA"; }): Promise; deleteFile(options: { path: string; directory: "DATA" }): Promise; } /** * Native storage: one JSON file per slot in the app's data directory (included * in iCloud and Android backups). A write goes to a temporary file that is then * renamed over the slot, so the slot is never half-written. */ export class FilesystemBackend implements SaveBackend { readonly kind = "filesystem"; constructor( private readonly fs: FilesystemPlugin, private readonly folder = "deadhead", ) {} #path(name: string) { return `${this.folder}/${name}.json`; } async read(slot: Slot) { try { const { data } = await this.fs.readFile({ path: this.#path(slot), directory: "DATA", encoding: "utf8", }); return typeof data === "string" ? data : await data.text(); } catch { // Capacitor rejects for a missing file. return null; } } async write(slot: Slot, text: string) { const temp = this.#path(`${slot}.tmp`); await this.fs.writeFile({ path: temp, data: text, directory: "DATA", encoding: "utf8", recursive: true, }); await this.fs.rename({ from: temp, to: this.#path(slot), directory: "DATA", toDirectory: "DATA", }); } async remove(slot: Slot) { try { await this.fs.deleteFile({ path: this.#path(slot), directory: "DATA" }); } catch { // Already gone. } } }