LocalHtmlDataTest/sql-js/src/folder-store.ts
Jason Tudisco 6ebe02ad56 Initial commit: local-first browser sync library experiment
Four variants of the same sync library (IndexedDB, NeDB, SQLite WASM, sql.js)
plus a paste-bin demo app for testing multi-browser sync via shared folders.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:04:08 -06:00

155 lines
4.9 KiB
TypeScript

import type { SyncEvent } from './types.js';
import { parseEventFilename } from './utils.js';
const EVENTS_DIR = 'events';
export class FolderStore {
private dirHandle: FileSystemDirectoryHandle | null = null;
// ── Handle management ──────────────────────────────────────
get hasHandle(): boolean {
return this.dirHandle !== null;
}
getHandle(): FileSystemDirectoryHandle | null {
return this.dirHandle;
}
setHandle(handle: FileSystemDirectoryHandle): void {
this.dirHandle = handle;
}
async selectFolder(): Promise<FileSystemDirectoryHandle> {
if (typeof window.showDirectoryPicker !== 'function') {
throw new Error(
'File System Access API is not supported in this browser. ' +
'Use a Chromium-based browser (Chrome, Edge, Brave, etc.).',
);
}
this.dirHandle = await window.showDirectoryPicker({
mode: 'readwrite',
});
// Ensure events directory exists
await this.dirHandle.getDirectoryHandle(EVENTS_DIR, { create: true });
return this.dirHandle;
}
// ── Permission checks ─────────────────────────────────────
async hasPermission(): Promise<boolean> {
if (!this.dirHandle) return false;
try {
const perm = await this.dirHandle.queryPermission({ mode: 'readwrite' });
return perm === 'granted';
} catch {
return false;
}
}
async requestPermission(): Promise<boolean> {
if (!this.dirHandle) return false;
try {
const perm = await this.dirHandle.requestPermission({
mode: 'readwrite',
});
return perm === 'granted';
} catch {
return false;
}
}
// ── Write event file ──────────────────────────────────────
async writeEvent(filename: string, event: SyncEvent): Promise<void> {
const eventsDir = await this.getEventsDir();
const fileHandle = await eventsDir.getFileHandle(filename, {
create: true,
});
const writable = await fileHandle.createWritable();
try {
await writable.write(JSON.stringify(event, null, 2));
} finally {
await writable.close();
}
}
// ── Scan events ────────────────────────────────────────────
async scanEventFilenames(): Promise<string[]> {
const eventsDir = await this.getEventsDir();
const names: string[] = [];
for await (const [name, handle] of eventsDir.entries()) {
if (handle.kind === 'file' && name.endsWith('.json')) {
if (parseEventFilename(name)) {
names.push(name);
}
}
}
// Sort by (timestamp, full filename) for deterministic ordering
names.sort((a, b) => {
const pa = parseEventFilename(a)!;
const pb = parseEventFilename(b)!;
if (pa.ts !== pb.ts) return pa.ts - pb.ts;
return a.localeCompare(b);
});
return names;
}
async readEventFile(filename: string): Promise<SyncEvent | null> {
const eventsDir = await this.getEventsDir();
try {
const fh = await eventsDir.getFileHandle(filename);
const file = await fh.getFile();
const text = await file.text();
return JSON.parse(text) as SyncEvent;
} catch {
return null;
}
}
// ── Data files (SQLite database persistence) ────────────────
async writeDataFile(filename: string, data: Uint8Array): Promise<void> {
const dataDir = await this.getDataDir();
const fh = await dataDir.getFileHandle(filename, { create: true });
const writable = await fh.createWritable();
try {
await writable.write(data.buffer as ArrayBuffer);
} finally {
await writable.close();
}
}
async readDataFile(filename: string): Promise<Uint8Array | null> {
try {
const dataDir = await this.getDataDir();
const fh = await dataDir.getFileHandle(filename);
const file = await fh.getFile();
const buf = await file.arrayBuffer();
return new Uint8Array(buf);
} catch {
return null;
}
}
// ── Internals ──────────────────────────────────────────────
private async getEventsDir(): Promise<FileSystemDirectoryHandle> {
if (!this.dirHandle) {
throw new Error('No folder selected. Call selectFolder() first.');
}
return this.dirHandle.getDirectoryHandle(EVENTS_DIR, { create: true });
}
private async getDataDir(): Promise<FileSystemDirectoryHandle> {
if (!this.dirHandle) {
throw new Error('No folder selected. Call selectFolder() first.');
}
return this.dirHandle.getDirectoryHandle('data', { create: true });
}
}