feat: launch local-first Sudoku workbench

This commit is contained in:
2026-08-30 14:14:11 +02:00
commit 659640b231
97 changed files with 19111 additions and 0 deletions
+314
View File
@@ -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);
}