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>
155 lines
4.9 KiB
TypeScript
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 });
|
|
}
|
|
}
|