feat: launch local-first Sudoku workbench
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
import { SudokuFormatError } from "../formats";
|
||||
import {
|
||||
cloneProjectRecord,
|
||||
MAX_PROJECT_BYTES,
|
||||
normalizeProjectRecord,
|
||||
} from "./record";
|
||||
import type {
|
||||
ProjectLibraryExport,
|
||||
SudokuProjectRecord,
|
||||
SudokuProjectSummary,
|
||||
} from "./types";
|
||||
|
||||
export const MAX_LIBRARY_PROJECTS = 256;
|
||||
export const MAX_MEMORY_LIBRARY_BYTES = 32 * 1_048_576;
|
||||
const DATABASE_VERSION = 1;
|
||||
const STORE_NAME = "projects";
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = database.createObjectStore(STORE_NAME, { keyPath: "id" });
|
||||
store.createIndex("updatedAt", "updatedAt");
|
||||
}
|
||||
};
|
||||
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>();
|
||||
#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(): Promise<readonly SudokuProjectSummary[]> {
|
||||
const database = await this.#db();
|
||||
if (database === undefined) {
|
||||
return [...this.#memory.values()]
|
||||
.map(summary)
|
||||
.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)))
|
||||
.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();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
Reference in New Issue
Block a user