Something went wrong. Try again.
a local-first atmospheric chalky task planner
Something went wrong. Try again.
12 kB · 297 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298// 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<string, string | number | boolean | null>;}
/** 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<typeof setTimeout> | 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<string, unknown>): void { if (!enabled && !undecided) return; const entry: SyncLogEntry = { t: new Date().toISOString(), s: SESSION, ev }; if (data) { const out: NonNullable<SyncLogEntry["data"]> = {}; 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<void> { 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<string, unknown>; const b = incoming as Record<string, unknown>; 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<void> { 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<string> { 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<void>; __syncExport: () => Promise<string>; __syncClear: () => Promise<void>; }}
if (typeof window !== "undefined") { window.__sync = dump; window.__syncExport = exportJson; window.__syncClear = async () => { queue = []; await clearSyncLog(); console.log("[synclog] cleared"); };}