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

95 lines
3.1 KiB
TypeScript

// ── Canonical JSON ───────────────────────────────────────────
function sortKeys(obj: unknown): unknown {
if (obj === null || obj === undefined || typeof obj !== 'object') return obj;
if (Array.isArray(obj)) return obj.map(sortKeys);
const sorted: Record<string, unknown> = {};
for (const key of Object.keys(obj as Record<string, unknown>).sort()) {
sorted[key] = sortKeys((obj as Record<string, unknown>)[key]);
}
return sorted;
}
export function canonicalJson(obj: unknown): string {
return JSON.stringify(sortKeys(obj));
}
// ── Hashing ──────────────────────────────────────────────────
export async function sha256Hex(data: string): Promise<string> {
const buf = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(data),
);
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
// ── Event filenames ──────────────────────────────────────────
const HASH_PREFIX_LEN = 12;
export function eventFilename(ts: number, fullHash: string): string {
return `${ts}_${fullHash.slice(0, HASH_PREFIX_LEN)}.json`;
}
const EVENT_FILE_RE = /^(\d+)_([0-9a-f]+)\.json$/;
export function parseEventFilename(
name: string,
): { ts: number; hash: string } | null {
const m = EVENT_FILE_RE.exec(name);
if (!m) return null;
return { ts: Number(m[1]), hash: m[2] };
}
// ── Client ID ────────────────────────────────────────────────
export function generateClientId(): string {
return crypto.randomUUID();
}
// ── Index value extraction ───────────────────────────────────
export function extractIndexValue(
data: unknown,
fields: string[],
): IDBValidKey | undefined {
if (!data || typeof data !== 'object') return undefined;
const rec = data as Record<string, unknown>;
if (fields.length === 1) {
const v = getNestedValue(rec, fields[0]);
if (v === undefined || v === null) return undefined;
return toIDBKey(v);
}
const parts: IDBValidKey[] = [];
for (const f of fields) {
const v = getNestedValue(rec, f);
if (v === undefined || v === null) return undefined;
parts.push(toIDBKey(v));
}
return parts;
}
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
let cur: unknown = obj;
for (const seg of path.split('.')) {
if (cur === null || cur === undefined || typeof cur !== 'object') {
return undefined;
}
cur = (cur as Record<string, unknown>)[seg];
}
return cur;
}
function toIDBKey(v: unknown): IDBValidKey {
if (typeof v === 'string' || typeof v === 'number') return v;
if (v instanceof Date) return v;
if (Array.isArray(v)) return v.map(toIDBKey);
// Fallback: stringify
return String(v);
}