Files
sudoku-tools/src/storage/library.ts
T

481 lines
15 KiB
TypeScript

import { SudokuFormatError } from "../formats";
import {
cloneProjectRecord,
createProjectRecord,
MAX_PROJECT_BYTES,
normalizeProjectRecord,
} from "./record";
import type {
ProjectLibraryExport,
ProjectLibraryQuery,
SudokuProjectRecord,
SudokuProjectSummary,
} from "./types";
export const MAX_LIBRARY_PROJECTS = 256;
export const MAX_MEMORY_LIBRARY_BYTES = 32 * 1_048_576;
const DATABASE_VERSION = 2;
const STORE_NAME = "projects";
const AUTOSAVE_STORE_NAME = "autosaves";
const DEFAULT_AUTOSAVE_SLOT = "current";
export type ProjectLibraryMode = "indexeddb" | "memory";
export interface ProjectLibraryOptions {
readonly indexedDB?: IDBFactory | null;
readonly databaseName?: string;
}
function summary(record: SudokuProjectRecord): SudokuProjectSummary {
return {
id: record.id,
title: record.title,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
size: record.puzzle.size,
completed: record.progress?.completed ?? false,
tags: [...(record.tags ?? [])],
thumbnail:
record.thumbnail ??
record.puzzle.givens
.map((value) => (value === 0 ? "." : String(value)))
.join(""),
};
}
function matchesQuery(
item: SudokuProjectSummary,
query: ProjectLibraryQuery,
): boolean {
const search = query.search?.trim().toLocaleLowerCase();
if (
search &&
!item.title.toLocaleLowerCase().includes(search) &&
!item.tags.some((tag) => tag.toLocaleLowerCase().includes(search))
) {
return false;
}
const tags = query.tags?.filter(Boolean) ?? [];
if (tags.length > 0 && !tags.every((tag) => item.tags.includes(tag))) {
return false;
}
return query.completed === undefined || item.completed === query.completed;
}
function bytes(record: SudokuProjectRecord): number {
return new TextEncoder().encode(JSON.stringify(record)).byteLength;
}
function requestResult<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () =>
reject(request.error ?? new Error("IndexedDB request failed."));
});
}
function transactionDone(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () =>
reject(transaction.error ?? new Error("IndexedDB transaction failed."));
transaction.onabort = () =>
reject(transaction.error ?? new Error("IndexedDB transaction aborted."));
});
}
async function openDatabase(
factory: IDBFactory,
name: string,
): Promise<IDBDatabase> {
return await new Promise((resolve, reject) => {
const request = factory.open(name, DATABASE_VERSION);
request.onupgradeneeded = () => {
const database = request.result;
const store = database.objectStoreNames.contains(STORE_NAME)
? request.transaction!.objectStore(STORE_NAME)
: database.createObjectStore(STORE_NAME, { keyPath: "id" });
if (!store.indexNames.contains("updatedAt")) {
store.createIndex("updatedAt", "updatedAt");
}
if (!store.indexNames.contains("title")) {
store.createIndex("title", "title");
}
if (!store.indexNames.contains("tags")) {
store.createIndex("tags", "tags", { multiEntry: true });
}
if (!database.objectStoreNames.contains(AUTOSAVE_STORE_NAME)) {
database.createObjectStore(AUTOSAVE_STORE_NAME, { keyPath: "slot" });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () =>
reject(request.error ?? new Error("Could not open IndexedDB."));
request.onblocked = () =>
reject(new Error("The project library database upgrade is blocked."));
});
}
export class ProjectLibrary {
readonly #factory: IDBFactory | null;
readonly #databaseName: string;
readonly #memory = new Map<string, SudokuProjectRecord>();
readonly #memoryAutosaves = new Map<string, SudokuProjectRecord>();
#database: Promise<IDBDatabase> | undefined;
#mode: ProjectLibraryMode;
constructor(options: ProjectLibraryOptions = {}) {
this.#factory =
options.indexedDB === undefined
? typeof indexedDB === "undefined"
? null
: indexedDB
: options.indexedDB;
this.#databaseName = options.databaseName ?? "sudoku-tools";
this.#mode = this.#factory === null ? "memory" : "indexeddb";
}
get mode(): ProjectLibraryMode {
return this.#mode;
}
async ready(): Promise<ProjectLibraryMode> {
await this.#db();
return this.#mode;
}
async #db(): Promise<IDBDatabase | undefined> {
if (this.#mode === "memory" || this.#factory === null) return undefined;
this.#database ??= openDatabase(this.#factory, this.#databaseName);
try {
return await this.#database;
} catch {
this.#mode = "memory";
this.#database = undefined;
return undefined;
}
}
#memoryTotal(replacement?: SudokuProjectRecord): number {
let total = 0;
for (const record of this.#memory.values()) {
if (replacement !== undefined && record.id === replacement.id) continue;
total += bytes(record);
}
return total + (replacement === undefined ? 0 : bytes(replacement));
}
#memoryPut(record: SudokuProjectRecord): void {
if (
!this.#memory.has(record.id) &&
this.#memory.size >= MAX_LIBRARY_PROJECTS
) {
throw new SudokuFormatError(
"STORAGE_LIMIT",
`The fallback library is limited to ${MAX_LIBRARY_PROJECTS} projects.`,
);
}
if (this.#memoryTotal(record) > MAX_MEMORY_LIBRARY_BYTES) {
throw new SudokuFormatError(
"STORAGE_LIMIT",
"The in-memory project library is full.",
);
}
this.#memory.set(record.id, cloneProjectRecord(record));
}
async list(
query: ProjectLibraryQuery = {},
): Promise<readonly SudokuProjectSummary[]> {
const database = await this.#db();
if (database === undefined) {
return [...this.#memory.values()]
.map(summary)
.filter((item) => matchesQuery(item, query))
.sort(
(a, b) => b.updatedAt - a.updatedAt || a.title.localeCompare(b.title),
);
}
try {
const transaction = database.transaction(STORE_NAME, "readonly");
const records = await requestResult(
transaction.objectStore(STORE_NAME).getAll(),
);
await transactionDone(transaction);
if (records.length > MAX_LIBRARY_PROJECTS) {
throw new SudokuFormatError(
"STORAGE_LIMIT",
"The project library contains too many records.",
);
}
return records
.map((record) => summary(normalizeProjectRecord(record)))
.filter((item) => matchesQuery(item, query))
.sort(
(a, b) => b.updatedAt - a.updatedAt || a.title.localeCompare(b.title),
);
} catch (error) {
if (error instanceof SudokuFormatError) throw error;
this.#mode = "memory";
return this.list(query);
}
}
async get(id: string): Promise<SudokuProjectRecord | undefined> {
const database = await this.#db();
if (database === undefined) {
const record = this.#memory.get(id);
return record === undefined ? undefined : cloneProjectRecord(record);
}
try {
const transaction = database.transaction(STORE_NAME, "readonly");
const value = await requestResult(
transaction.objectStore(STORE_NAME).get(id),
);
await transactionDone(transaction);
return value === undefined ? undefined : normalizeProjectRecord(value);
} catch (error) {
if (error instanceof SudokuFormatError) throw error;
this.#mode = "memory";
return this.get(id);
}
}
async put(value: SudokuProjectRecord): Promise<SudokuProjectRecord> {
const record = normalizeProjectRecord(value);
if (bytes(record) > MAX_PROJECT_BYTES) {
throw new SudokuFormatError(
"STORAGE_LIMIT",
"The project is too large to save.",
);
}
const database = await this.#db();
if (database === undefined) {
this.#memoryPut(record);
return cloneProjectRecord(record);
}
try {
const transaction = database.transaction(STORE_NAME, "readwrite");
const store = transaction.objectStore(STORE_NAME);
const existing = await requestResult(store.getKey(record.id));
const count = await requestResult(store.count());
if (existing === undefined && count >= MAX_LIBRARY_PROJECTS) {
transaction.abort();
throw new SudokuFormatError(
"STORAGE_LIMIT",
`The project library is limited to ${MAX_LIBRARY_PROJECTS} projects.`,
);
}
store.put(record);
await transactionDone(transaction);
return cloneProjectRecord(record);
} catch (error) {
if (error instanceof SudokuFormatError) throw error;
this.#mode = "memory";
this.#memoryPut(record);
return cloneProjectRecord(record);
}
}
async delete(id: string): Promise<boolean> {
const database = await this.#db();
if (database === undefined) return this.#memory.delete(id);
try {
const transaction = database.transaction(STORE_NAME, "readwrite");
const store = transaction.objectStore(STORE_NAME);
const exists = (await requestResult(store.getKey(id))) !== undefined;
if (exists) store.delete(id);
await transactionDone(transaction);
return exists;
} catch {
this.#mode = "memory";
return this.#memory.delete(id);
}
}
async clear(): Promise<void> {
const database = await this.#db();
if (database === undefined) {
this.#memory.clear();
return;
}
try {
const transaction = database.transaction(STORE_NAME, "readwrite");
transaction.objectStore(STORE_NAME).clear();
await transactionDone(transaction);
} catch {
this.#mode = "memory";
this.#memory.clear();
}
}
/** Persist the latest working state separately from explicit Library saves. */
async putAutosave(
value: SudokuProjectRecord,
slot = DEFAULT_AUTOSAVE_SLOT,
): Promise<SudokuProjectRecord> {
if (!slot || slot.length > 128) {
throw new SudokuFormatError(
"INVALID_PROJECT",
"Autosave slot is invalid.",
);
}
const record = normalizeProjectRecord(value);
const database = await this.#db();
if (database === undefined) {
this.#memoryAutosaves.set(slot, cloneProjectRecord(record));
return cloneProjectRecord(record);
}
try {
const transaction = database.transaction(
AUTOSAVE_STORE_NAME,
"readwrite",
);
transaction.objectStore(AUTOSAVE_STORE_NAME).put({ slot, record });
await transactionDone(transaction);
return cloneProjectRecord(record);
} catch {
this.#mode = "memory";
this.#memoryAutosaves.set(slot, cloneProjectRecord(record));
return cloneProjectRecord(record);
}
}
async getAutosave(
slot = DEFAULT_AUTOSAVE_SLOT,
): Promise<SudokuProjectRecord | undefined> {
const database = await this.#db();
if (database === undefined) {
const record = this.#memoryAutosaves.get(slot);
return record === undefined ? undefined : cloneProjectRecord(record);
}
try {
const transaction = database.transaction(AUTOSAVE_STORE_NAME, "readonly");
const value = await requestResult(
transaction.objectStore(AUTOSAVE_STORE_NAME).get(slot),
);
await transactionDone(transaction);
if (typeof value !== "object" || value === null || !("record" in value)) {
return undefined;
}
return normalizeProjectRecord((value as { record: unknown }).record);
} catch (error) {
if (error instanceof SudokuFormatError) throw error;
this.#mode = "memory";
return this.getAutosave(slot);
}
}
async clearAutosave(slot = DEFAULT_AUTOSAVE_SLOT): Promise<void> {
const database = await this.#db();
if (database === undefined) {
this.#memoryAutosaves.delete(slot);
return;
}
try {
const transaction = database.transaction(
AUTOSAVE_STORE_NAME,
"readwrite",
);
transaction.objectStore(AUTOSAVE_STORE_NAME).delete(slot);
await transactionDone(transaction);
} catch {
this.#mode = "memory";
this.#memoryAutosaves.delete(slot);
}
}
async exportAll(): Promise<ProjectLibraryExport> {
const summaries = await this.list();
const projects: SudokuProjectRecord[] = [];
for (const item of summaries) {
const record = await this.get(item.id);
if (record !== undefined) projects.push(record);
}
return {
schema: "de.add-ideas.sudoku-tools.library",
version: 1,
exportedAt: Date.now(),
projects,
};
}
async exportSelected(ids: readonly string[]): Promise<ProjectLibraryExport> {
const unique = [...new Set(ids)].slice(0, MAX_LIBRARY_PROJECTS);
const projects: SudokuProjectRecord[] = [];
for (const id of unique) {
const record = await this.get(id);
if (record !== undefined) projects.push(record);
}
return {
schema: "de.add-ideas.sudoku-tools.library",
version: 1,
exportedAt: Date.now(),
projects,
};
}
/** Create independent local copies without reusing source record IDs. */
async duplicateSelected(ids: readonly string[]): Promise<number> {
const unique = [...new Set(ids)].slice(0, MAX_LIBRARY_PROJECTS);
const sources: SudokuProjectRecord[] = [];
for (const id of unique) {
const source = await this.get(id);
if (source !== undefined) sources.push(source);
}
if ((await this.list()).length + sources.length > MAX_LIBRARY_PROJECTS) {
throw new SudokuFormatError(
"STORAGE_LIMIT",
`Copying this selection would exceed the ${String(MAX_LIBRARY_PROJECTS)}-project limit.`,
);
}
let copied = 0;
for (const source of sources) {
const now = Date.now() + copied;
await this.put(
createProjectRecord(source.puzzle, {
title: `${source.title || "Untitled puzzle"} copy`,
progress: source.progress,
tags: source.tags,
now,
}),
);
copied += 1;
}
return copied;
}
async importAll(value: unknown, replace = false): Promise<number> {
if (
typeof value !== "object" ||
value === null ||
(value as { schema?: unknown }).schema !==
"de.add-ideas.sudoku-tools.library" ||
(value as { version?: unknown }).version !== 1 ||
!Array.isArray((value as { projects?: unknown }).projects)
) {
throw new SudokuFormatError(
"INVALID_LIBRARY",
"Unsupported project library export.",
);
}
const raw = (value as { projects: unknown[] }).projects;
if (raw.length > MAX_LIBRARY_PROJECTS) {
throw new SudokuFormatError(
"STORAGE_LIMIT",
"The import contains too many projects.",
);
}
const records = raw.map(normalizeProjectRecord);
if (replace) await this.clear();
for (const record of records) await this.put(record);
return records.length;
}
}
export function createProjectLibrary(
options?: ProjectLibraryOptions,
): ProjectLibrary {
return new ProjectLibrary(options);
}