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>
95 lines
3.1 KiB
TypeScript
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);
|
|
}
|