// Sync debugging. Cross-device sync keeps producing bugs we can't reproduce on // demand — a deleted task resurrecting, an edit that never lands, a task // flipping done/undone (and jumping days) on load — and by the time anyone // notices, the console that would have explained it is gone. So this is a // *stateful* trail: every state-changing sync decision is appended to a ring // buffer in IndexedDB, where it survives reloads and can be read back on // whichever device shows the symptom, then lined up against the other device's // trail by timestamp. // // What gets logged (see the call sites in atproto/sync.ts, store/tasks.svelte.ts // and store/settings.svelte.ts): // • every record this device pushes (put/delete), and every conflict with the // branch that resolved it // • every pull decision, applied or refused — an op skipped because the local // copy is dirty is exactly the line that explains a "missed" update // • snapshot deletion propagation — the path that can turn a read glitch into // data loss, and the prime suspect for resurrected/missed deletes // • when `reconciled` fired and via which path (a failure-limit reconcile // releases the carry-over sweep against stale state — the prime suspect for // "finished yesterday, now undone and on today") // • local mutations that sync will later propagate (patch/add/delete/occ), // tagged as local, so a bad value can be traced to the device that wrote it // // Reading it, from the console on the affected device: // __sync() → print the last 300 lines // __sync(1000) → more of them // __sync("3lz2abc") → only lines mentioning that record id (or any substring) // __syncExport() → resolves to the whole buffer as a JSON string (feed it // to copy(await __syncExport()) to get it off the device) // __syncClear() → start fresh // // Every entry carries the per-install device id and a per-page-load session id, // so two exported trails interleave unambiguously. // // Only the debug account pays for (or leaves) any of this: like the cursor // trail, recording is gated on the signed-in handle — see `setSyncDebug`. Other // accounts' calls short-circuit, and anything recorded before a non-debug // sign-in resolves is dropped and cleared. import { appendSyncLog, listSyncLog, pruneSyncLog, clearSyncLog } from "./db/idb"; /** One appended log entry. `data` values are pre-flattened to short primitives so the record is always structured-cloneable and never holds a live proxy. */ export interface SyncLogEntry { t: string; // ISO timestamp s: string; // session id (per page load) ev: string; // dot-namespaced event name data?: Record; } /** Ring-buffer size. At the log's actual volume (nothing on a no-change cycle) this is days-to-weeks of history, and small enough to getAll() casually. */ const MAX_ENTRIES = 4000; const DEBUG_HANDLE = "tyler.fun"; /** Survives reinstalls of the tab but not the browser profile — enough to tell two devices' trails apart, which is all it is for. */ function deviceId(): string { try { const k = "chalky:deviceId"; let v = localStorage.getItem(k); if (!v) { v = Math.random().toString(36).slice(2, 8); localStorage.setItem(k, v); } return v; } catch { return "nolocal"; } } const DEVICE = deviceId(); const SESSION = Math.random().toString(36).slice(2, 6); /** localStorage key remembering whether this install belongs to the debug handle. The handle is only known once sign-in resolves, which is *after* the events that matter most at boot (load baseline, first cycle, carry-over sweep) — remembering the last decision lets those be captured on the debug account's devices from the second load onward. */ const ENABLED_KEY = "chalky:synclog"; function readStoredEnabled(): boolean { try { return localStorage.getItem(ENABLED_KEY) === "1"; } catch { return false; } } /** Whether the trail records at all — only for the debug handle (see `setSyncDebug`). Everyone else's `slog` calls drop at the door. */ let enabled = readStoredEnabled(); /** Entries logged before this session's handle is known, held in memory in case the debug handle signs in. Capped so an anonymous session can't grow it. */ let undecided: SyncLogEntry[] | null = enabled ? null : []; const UNDECIDED_CAP = 300; /** * Tell the log whose session this is. `tyler.fun` turns persistence and the * console mirror on (and flushes anything buffered since boot); any other * handle turns it off and clears whatever the store holds; null/undefined (no * session yet, or signed out) leaves the remembered decision in force. */ export function setSyncDebug(handle: string | undefined | null): void { if (handle == null) return; const next = handle === DEBUG_HANDLE; try { localStorage.setItem(ENABLED_KEY, next ? "1" : "0"); } catch { /* ignore */ } if (next && !enabled) { console.log( `%c[synclog] trail ON for @${DEBUG_HANDLE} — __sync() dumps it`, "color:#60a5fa;font-weight:600", ); } enabled = next; if (next) { // Adopt anything logged before the handle resolved, then persist as usual. if (undecided?.length) queue.unshift(...undecided); undecided = null; if (queue.length) void flush(); } else { // Not the debug account: drop the buffer and any residue from before. undecided = null; queue = []; void clearSyncLog().catch(() => {}); } } /** Flatten a value to something short, primitive, and clone-safe. */ function flat(v: unknown): string | number | boolean | null { if (v == null) return null; if (typeof v === "number" || typeof v === "boolean") return v; const s = typeof v === "string" ? v : safeJson(v); return s.length > 80 ? s.slice(0, 77) + "…" : s; } function safeJson(v: unknown): string { try { return JSON.stringify(v) ?? String(v); } catch { return String(v); } } // ── write path ─────────────────────────────────────────────────────────────── // Entries buffer in memory and flush in one transaction shortly after — a sync // cycle logs a burst, and one tx per burst beats one per line. A hidden tab // flushes immediately, since that's the last chance before a likely close. let queue: SyncLogEntry[] = []; let flushTimer: ReturnType | null = null; let flushes = 0; /** * Append one entry to the persistent sync trail. Never throws and never blocks * the caller; a logging failure must not take the sync engine down with it. * Records only for the debug handle: before the session resolves, entries wait * in the `undecided` buffer; once a non-debug handle is known they drop here. */ export function slog(ev: string, data?: Record): void { if (!enabled && !undecided) return; const entry: SyncLogEntry = { t: new Date().toISOString(), s: SESSION, ev }; if (data) { const out: NonNullable = {}; for (const [k, v] of Object.entries(data)) { if (v !== undefined) out[k] = flat(v); } entry.data = out; } if (!enabled) { undecided!.push(entry); if (undecided!.length > UNDECIDED_CAP) undecided!.shift(); return; } console.log(`%c[sync] ${formatEntry(entry)}`, "color:#60a5fa"); queue.push(entry); if (flushTimer == null) flushTimer = setTimeout(() => void flush(), 300); } async function flush(): Promise { if (flushTimer != null) { clearTimeout(flushTimer); flushTimer = null; } if (!queue.length || !enabled) return; const batch = queue; queue = []; try { await appendSyncLog(batch); // Prune occasionally rather than per flush; overshoot is harmless. if (flushes++ % 25 === 0) await pruneSyncLog(MAX_ENTRIES); } catch { /* a broken log must stay invisible to sync itself */ } } if (typeof document !== "undefined") { document.addEventListener("visibilitychange", () => { if (document.hidden) void flush(); }); } // ── diffing ────────────────────────────────────────────────────────────────── /** Keys that are bookkeeping, not content — omitted from diffs except `updatedAt`, which is the LWW clock and always worth seeing. */ const SKIP_KEYS = new Set(["dirty", "cid", "id"]); /** * One-line summary of what adopting `incoming` over `held` changes: * `"(new)"` for a record we didn't hold, else `key:old→new` per differing * field. Array/object fields compare and print via JSON (truncated). An empty * string means the two are identical in content — also worth knowing, since an * "update" that changes nothing but updatedAt is its own kind of clue. */ export function diffSummary(held: unknown, incoming: unknown): string { if (held == null) return "(new)"; const a = held as Record; const b = incoming as Record; const parts: string[] = []; for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) { if (SKIP_KEYS.has(k)) continue; const av = a[k]; const bv = b[k]; const as = typeof av === "object" && av !== null ? safeJson(av) : String(av ?? "∅"); const bs = typeof bv === "object" && bv !== null ? safeJson(bv) : String(bv ?? "∅"); if (as !== bs) parts.push(`${k}:${clip(as)}→${clip(bs)}`); } return parts.join(" "); } function clip(s: string): string { return s.length > 40 ? s.slice(0, 37) + "…" : s; } // ── read path ──────────────────────────────────────────────────────────────── function formatEntry(e: SyncLogEntry): string { const kv = e.data ? " " + Object.entries(e.data) .map(([k, v]) => `${k}=${v === "" || v == null ? "∅" : v}`) .join(" ") : ""; // 2026-09-01T14:22:33.123Z → 09-01 14:22:33.123 const t = e.t.slice(5, 10) + " " + e.t.slice(11, 23); return `${t} [${e.s}] ${e.ev}${kv}`; } /** * Print the stored trail. `arg`: a number caps how many trailing lines (default * 300); a string filters to lines containing it (record id, event name, day…). * Prints as one plain string so it survives a copy out of any console. */ async function dump(arg?: number | string): Promise { await flush(); const limit = typeof arg === "number" ? arg : 300; const filter = typeof arg === "string" ? arg : null; let entries = await listSyncLog(); if (filter) entries = entries.filter((e) => formatEntry(e).includes(filter)); const shown = entries.slice(-limit); const lines = [ `[synclog] device=${DEVICE} session=${SESSION} showing ${shown.length}/${entries.length}` + (filter ? ` filter="${filter}"` : ""), ...shown.map(formatEntry), ]; console.log(lines.join("\n")); } async function exportJson(): Promise { await flush(); const entries = await listSyncLog(); return JSON.stringify({ device: DEVICE, exportedAt: new Date().toISOString(), entries }); } declare global { interface Window { __sync: (arg?: number | string) => Promise; __syncExport: () => Promise; __syncClear: () => Promise; } } if (typeof window !== "undefined") { window.__sync = dump; window.__syncExport = exportJson; window.__syncClear = async () => { queue = []; await clearSyncLog(); console.log("[synclog] cleared"); }; }