deadhead-space-freight-tycoon/packages/engine/src/storage.ts

261 lines
7.8 KiB
TypeScript

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<string | null>;
/** Replaces the slot's text completely, or leaves it as it was. */
write(slot: Slot, text: string): Promise<void>;
remove(slot: Slot): Promise<void>;
}
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<LoadResult> {
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<void> {
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<BackupInfo[]> {
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<void> {
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<Slot, string>();
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<IDBDatabase> | null = null;
constructor(private readonly factory: IDBFactory) {}
#open(): Promise<IDBDatabase> {
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<T>(
mode: IDBTransactionMode,
op: (store: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
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<string | undefined>);
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<unknown>;
rename(options: {
from: string;
to: string;
directory: "DATA";
toDirectory: "DATA";
}): Promise<void>;
deleteFile(options: { path: string; directory: "DATA" }): Promise<void>;
}
/**
* 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.
}
}
}