/
mirsud
/
casectrl
Обзор
Документация
Войти
/
mirsud
/
casectrl
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
src/lib/storage/adapters/sqlite.ts
588 строк
17 KB
94StarKing94
ав
28 июл 2026, 13:44
28 июл 2026, 13:44
fd21e96
Код
Авторство
О чём код?
"use client"; /** * Адаптер для SQLite-style хранения в одном файле. * * Браузер не может запустить настоящий SQLite-движок без WASM-бинарника, * поэтому мы эмулируем «однофайловую» БД сериализацией всех KV-значений * и блобов в один JSON-файл. Файл лежит в OPFS (по умолчанию) или в * пользовательской папке (через File System Access API). * * Контракт: * - Файл — UTF-8 JSON со структурой: * { version: 1, kv: { key: value }, blobs: { id: {type, base64} } } * - При чтении весь файл загружается в память (десериализация). * - Каждая запись пишется синхронно в память + асинхронно во внешний * контейнер (OPFS или FileHandle) — это «атомарный» снимок. * - На `clear` файл перезаписывается пустой структурой. * * Преимущества: * - Один переносимый файл — можно копировать между машинами. * - Переживает очистку кэша (если выбран FileHandle). * - Доменные модули используют тот же `KeyValueStore` / `BlobStore`, * что и остальные адаптеры. */ import { BACKEND_KINDS, type BackendDescriptor, type BlobStore, type KeyValueStore, type SqliteConfig, type SqliteContainer, } from "../types"; interface BlobRecord { type: string; base64: string; } interface SqliteDump { version: 1; kv: Record<string, unknown>; blobs: Record<string, BlobRecord>; } const DEFAULT_FILE_NAME = "court.sqlite.json"; const DEFAULT_CONFIG: SqliteConfig = { container: "opfs", fileName: DEFAULT_FILE_NAME, }; function emptyDump(): SqliteDump { return { version: 1, kv: {}, blobs: {} }; } function isSupported(): { ok: boolean; reason?: string } { if (typeof window === "undefined") { return { ok: false, reason: "Серверный рендеринг" }; } const hasOpfs = typeof navigator !== "undefined" && typeof navigator.storage !== "undefined" && typeof navigator.storage.getDirectory === "function"; const hasFsa = typeof window !== "undefined" && typeof (window as unknown as { showDirectoryPicker?: () => unknown }) .showDirectoryPicker === "function"; if (!hasOpfs && !hasFsa) { return { ok: false, reason: "Для SQLite-файла нужен либо OPFS, либо File System Access (Chromium-браузер)", }; } return { ok: true }; } /* ------------------------------------------------------------------ */ /* Контейнер: где физически лежит файл */ /* ------------------------------------------------------------------ */ /** * Минимальный интерфейс контейнера: прочитать и записать JSON-дамп. * Реализации — {@link OpfsSqliteContainer} и {@link FileHandleSqliteContainer}. */ interface SqliteContainerImpl { read(): Promise<SqliteDump | null>; write(dump: SqliteDump): Promise<void>; /** Подключён ли контейнер (для FileHandle нужно разрешение пользователя). */ isReady(): boolean; /** Запросить доступ (для FileHandle — открыть диалог выбора папки). */ request?(): Promise<boolean>; /** Метка для UI (где лежит файл). */ describe(): string; } /* ---------- OPFS ---------- */ function opfsSupported(): boolean { return ( typeof navigator !== "undefined" && typeof navigator.storage !== "undefined" && typeof navigator.storage.getDirectory === "function" ); } class OpfsSqliteContainer implements SqliteContainerImpl { constructor(private fileName: string) {} isReady(): boolean { return opfsSupported(); } describe(): string { return `OPFS / ${this.fileName}`; } async read(): Promise<SqliteDump | null> { if (!opfsSupported()) return null; try { const root = await navigator.storage.getDirectory(); const fh = await root.getFileHandle(this.fileName, { create: false }); const file = await fh.getFile(); const text = await file.text(); if (!text) return null; return JSON.parse(text) as SqliteDump; } catch { return null; } } async write(dump: SqliteDump): Promise<void> { if (!opfsSupported()) return; try { const root = await navigator.storage.getDirectory(); const fh = await root.getFileHandle(this.fileName, { create: true }); const writable = await fh.createWritable(); await writable.write(JSON.stringify(dump)); await writable.close(); } catch { /* ignore */ } } } /* ---------- FileHandle (FSA) ---------- */ interface FsaDirHandle { kind: "directory"; name: string; getFileHandle( name: string, options?: { create?: boolean } ): Promise<{ kind: "file"; createWritable(opts?: { keepExistingData?: boolean }): Promise<{ write(data: string | Blob | ArrayBuffer): Promise<void>; close(): Promise<void>; }>; getFile(): Promise<File>; }>; queryPermission?(opts: { mode: "read" | "readwrite"; }): Promise<"granted" | "prompt" | "denied">; requestPermission?(opts: { mode: "read" | "readwrite"; }): Promise<"granted" | "prompt" | "denied">; } const HANDLE_KEY = "court:sqlite-file-handle"; async function loadPersistedHandle(): Promise<FsaDirHandle | null> { if (typeof indexedDB === "undefined") return null; return new Promise((resolve) => { let req: IDBOpenDBRequest; try { req = indexedDB.open("court-panel-db", 2); } catch { resolve(null); return; } req.onsuccess = () => { const db = req.result; if (!db.objectStoreNames.contains("kv")) { db.close(); resolve(null); return; } try { const tx = db.transaction("kv", "readonly"); const r = tx.objectStore("kv").get(HANDLE_KEY); r.onsuccess = () => { db.close(); resolve((r.result as FsaDirHandle | undefined) ?? null); }; r.onerror = () => { db.close(); resolve(null); }; } catch { db.close(); resolve(null); } }; req.onerror = () => resolve(null); }); } async function persistHandle(handle: FsaDirHandle | null): Promise<void> { if (typeof indexedDB === "undefined") return; return new Promise((resolve) => { let req: IDBOpenDBRequest; try { req = indexedDB.open("court-panel-db", 2); } catch { resolve(); return; } req.onsuccess = () => { const db = req.result; if (!db.objectStoreNames.contains("kv")) { db.close(); resolve(); return; } try { const tx = db.transaction("kv", "readwrite"); if (handle) tx.objectStore("kv").put(handle, HANDLE_KEY); else tx.objectStore("kv").delete(HANDLE_KEY); tx.oncomplete = () => { db.close(); resolve(); }; tx.onerror = () => { db.close(); resolve(); }; } catch { db.close(); resolve(); } }; req.onerror = () => resolve(); }); } class FileHandleSqliteContainer implements SqliteContainerImpl { constructor(private fileName: string) {} isReady(): boolean { return true; } describe(): string { return `Папка пользователя / ${this.fileName}`; } /** Запросить у пользователя папку и сохранить хэндл. */ async request(): Promise<boolean> { if (typeof window === "undefined") return false; const picker = (window as unknown as { showDirectoryPicker?: (opts?: { mode?: "read" | "readwrite" }) => Promise<FsaDirHandle>; }).showDirectoryPicker; if (!picker) return false; try { const handle = await picker({ mode: "readwrite" }); await persistHandle(handle); return true; } catch { return false; } } private async getHandle(): Promise<FsaDirHandle | null> { const handle = await loadPersistedHandle(); if (!handle) return null; if (handle.queryPermission) { try { const cur = await handle.queryPermission({ mode: "readwrite" }); if (cur !== "granted" && handle.requestPermission) { const next = await handle.requestPermission({ mode: "readwrite" }); if (next !== "granted") return null; } } catch { return null; } } return handle; } async read(): Promise<SqliteDump | null> { const handle = await this.getHandle(); if (!handle) return null; try { const fh = await handle.getFileHandle(this.fileName, { create: false }); const file = await fh.getFile(); const text = await file.text(); if (!text) return null; return JSON.parse(text) as SqliteDump; } catch { return null; } } async write(dump: SqliteDump): Promise<void> { const handle = await this.getHandle(); if (!handle) return; try { const fh = await handle.getFileHandle(this.fileName, { create: true }); const writable = await fh.createWritable(); await writable.write(JSON.stringify(dump)); await writable.close(); } catch { /* ignore */ } } } /* ------------------------------------------------------------------ */ /* Контейнер: фабрика */ /* ------------------------------------------------------------------ */ export function createSqliteContainer( container: SqliteContainer, fileName: string ): SqliteContainerImpl { const name = fileName.trim() || DEFAULT_FILE_NAME; if (container === "fileHandle") { return new FileHandleSqliteContainer(name); } return new OpfsSqliteContainer(name); } /** * Полная фабрика: контейнер + общее состояние (дамп). Возвращается * кортеж — оба адаптера ({@link SqliteKv} и {@link SqliteBlob}) должны * получить один и тот же state, чтобы делили данные и запись. */ export function createSqliteEngine( container: SqliteContainer, fileName: string ): { container: SqliteContainerImpl; state: SqliteState } { const cont = createSqliteContainer(container, fileName); const state = new SqliteState(cont); return { container: cont, state }; } /* ------------------------------------------------------------------ */ /* KV-адаптер */ /* ------------------------------------------------------------------ */ /** * Контейнер состояния: общий дамп + флаг «грязный» (есть несохранённые * изменения). Любая мутация помечает дамп как dirty и планирует запись. */ class SqliteState { private dump: SqliteDump = emptyDump(); private dirty = false; private writePromise: Promise<void> = Promise.resolve(); constructor(private container: SqliteContainerImpl) {} async load(): Promise<void> { const loaded = await this.container.read(); if (loaded && loaded.version === 1) { this.dump = { version: 1, kv: loaded.kv ?? {}, blobs: loaded.blobs ?? {}, }; } else { this.dump = emptyDump(); } this.dirty = false; } get<T>(key: string): T | null { return key in this.dump.kv ? (this.dump.kv[key] as T) : null; } set<T>(key: string, value: T): void { this.dump.kv[key] = value; this.scheduleWrite(); } remove(key: string): void { if (key in this.dump.kv) { delete this.dump.kv[key]; this.scheduleWrite(); } } has(key: string): boolean { return key in this.dump.kv; } keys(): string[] { return Object.keys(this.dump.kv); } clear(): void { this.dump = emptyDump(); this.scheduleWrite(); } /** Блоб: положить. */ putBlob(id: string, record: BlobRecord): void { this.dump.blobs[id] = record; this.scheduleWrite(); } /** Блоб: получить. */ getBlob(id: string): BlobRecord | null { return this.dump.blobs[id] ?? null; } /** Блоб: удалить. */ deleteBlob(id: string): void { if (id in this.dump.blobs) { delete this.dump.blobs[id]; this.scheduleWrite(); } } blobIds(): string[] { return Object.keys(this.dump.blobs); } /** Принудительно сохранить. */ async flush(): Promise<void> { if (!this.dirty) return; await this.writePromise; this.dirty = false; } private scheduleWrite() { this.dirty = true; this.writePromise = (async () => { try { await this.container.write(this.dump); } finally { /* файл либо записан, либо молча проглочен (нет доступа) */ } })(); } } /* ------------------------------------------------------------------ */ /* Публичные адаптеры */ /* ------------------------------------------------------------------ */ export class SqliteKv implements KeyValueStore { private ready: Promise<void>; constructor( private readonly state: SqliteState, private readonly container: SqliteContainerImpl ) { this.ready = state.load().catch(() => { /* пустой дамп останется — read вернёт null */ }); } describe(): string { return this.container.describe(); } async get<T = unknown>(key: string): Promise<T | null> { await this.ready; return this.state.get<T>(key); } async set<T = unknown>(key: string, value: T): Promise<void> { await this.ready; this.state.set(key, value); } async remove(key: string): Promise<void> { await this.ready; this.state.remove(key); } async has(key: string): Promise<boolean> { await this.ready; return this.state.has(key); } async keys(): Promise<string[]> { await this.ready; return this.state.keys(); } async clear(): Promise<void> { await this.ready; this.state.clear(); } } export class SqliteBlob implements BlobStore { private ready: Promise<void>; constructor(private readonly state: SqliteState) { this.ready = state.load().catch(() => {}); } async put(id: string, blob: Blob): Promise<void> { await this.ready; const buf = await blob.arrayBuffer(); const bytes = new Uint8Array(buf); let binary = ""; for (let i = 0; i < bytes.byteLength; i += 1) { binary += String.fromCharCode(bytes[i]); } const base64 = typeof btoa !== "undefined" ? btoa(binary) : Buffer.from(binary, "binary").toString("base64"); this.state.putBlob(id, { type: blob.type || "application/octet-stream", base64 }); } async get(id: string): Promise<Blob | null> { await this.ready; const rec = this.state.getBlob(id); if (!rec) return null; try { const binary = typeof atob !== "undefined" ? atob(rec.base64) : Buffer.from(rec.base64, "base64").toString("binary"); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) { bytes[i] = binary.charCodeAt(i); } return new Blob([bytes], { type: rec.type }); } catch { return null; } } async delete(id: string): Promise<void> { await this.ready; this.state.deleteBlob(id); } async has(id: string): Promise<boolean> { await this.ready; return this.state.getBlob(id) !== null; } async ids(): Promise<string[]> { await this.ready; return this.state.blobIds(); } async totalSize(): Promise<number | null> { await this.ready; const all = this.state.blobIds(); if (all.length === 0) return 0; let total = 0; for (const id of all) { const rec = this.state.getBlob(id); if (!rec) continue; // base64 → байты: длина * 3/4 минус padding const padding = rec.base64.endsWith("==") ? 2 : rec.base64.endsWith("=") ? 1 : 0; total += Math.floor((rec.base64.length * 3) / 4) - padding; } return total; } } /* ------------------------------------------------------------------ */ /* Дескриптор */ /* ------------------------------------------------------------------ */ export function sqliteDescriptor(): BackendDescriptor { const sup = isSupported(); return { id: BACKEND_KINDS.SQLITE, label: "SQLite (один файл)", description: "Все данные панели хранятся в одном переносимом файле court.sqlite.json. По умолчанию — в OPFS песочнице браузера, либо в выбранной пользователем папке на диске.", available: sup.ok, unavailableReason: sup.reason, capabilities: { kv: true, blob: true }, }; } export { DEFAULT_CONFIG as DEFAULT_SQLITE_CONFIG };