261 lines
7.8 KiB
TypeScript
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.
|
|
}
|
|
}
|
|
}
|