/* ══════════════════════════════════════════════════════════════════════ SYNCING BETWEEN DEVICES — three gates, each paid for in lost user data ══════════════════════════════════════════════════════════════════════ The artifact shipped without these twice and destroyed real work twice. Last-write-wins is adequate for one user whose worst conflict is a duplicated SRS grade — but ONLY with all three of the following. 1. HYDRATION. A client may not push a document until the server has told it what it already holds. This is the one that caused the loss: a laptop with a week-old local copy ran a boot-time migration, which re-stamped that stale copy with the current time and pushed it. A boot-time write always looks like the newest edit in the world. A week of phone work — chat and roadmap both — was gone. Note that rule 2 would NOT have saved it: the data was real, just old. 2. A COUNTER, NOT A CLOCK. Phone and laptop clocks disagree by minutes. Compare a monotonic per-document counter; fall back to the timestamp only when one side has none (an older client). 3. NO SILENT SHRINKING. A copy holding strictly less than the local one — fewer chat turns, fewer finished units, fewer cards — is never adopted by accident: keep the local copy and push it back. Deliberate deletions set a flag and are obeyed. This is what made the loss recoverable: the phone still had the real chat, refused the truncated server copy, and restored it. And: MIGRATIONS RUN AFTER THE FIRST PULL, NEVER AT BOOT, and never rewrite history. The trigger for the whole incident was a one-shot repair that cleared the chat. It was correct for the device it was written for and a loaded gun for every other one. */ export const DOCS = ["srs", "log", "meta", "chat"]; /** How much a copy holds. Shrinking is always deliberate, never a race. */ export function weigh(name, d) { if (!d) return 0; if (name === "chat") return (d.turns || []).length; if (name === "meta") return d.road && d.road.done ? Object.keys(d.road.done).length : 0; if (name === "srs") return d.cards ? Object.keys(d.cards).length : 0; if (name === "log") return d.days ? Object.keys(d.days).length : 0; return 0; } /** Is the copy that just arrived later than ours? */ export function isLater(remote, localVersion, localStamp) { if (typeof remote.v === "number" && localVersion > 0) { if (remote.v !== localVersion) return remote.v > localVersion; return typeof remote.u === "number" && remote.u > localStamp; } return typeof remote.u === "number" && remote.u > localStamp; } /** * Decide what to do with an incoming document. * @returns "ignore" | "adopt" | "reassert" * reassert = ours holds more and nothing said the shrink was * deliberate, so keep ours, bump past theirs, and push it back. */ export function reconcile(name, remote, local) { if (!remote || !remote.d) return "ignore"; if (!isLater(remote, local.version || 0, local.stamp || 0)) return "ignore"; if (weigh(name, local.data) > weigh(name, remote.d) && !remote.x) return "reassert"; return "adopt"; } /** * The write side. Hold every edit until the document has been hydrated; * an edit made before then keeps its old stamp so a newer server copy * still wins. */ export function makeWriter({ push, now = () => Date.now() }) { const state = {}; // name -> {version,stamp,dirty,hydrated,intent} const S = n => (state[n] = state[n] || { version: 0, stamp: 0, dirty: false, hydrated: false, intent: false }); return { state, /** a normal user edit */ touch(name, deliberateShrink = false) { const s = S(name); s.dirty = true; if (deliberateShrink) s.intent = true; if (s.hydrated) { s.stamp = now(); s.version += 1; } // otherwise: hold return s; }, /** call when the first snapshot for this document arrives (or is known absent) */ hydrate(name) { const s = S(name); if (s.hydrated) return s; s.hydrated = true; if (s.dirty) { s.stamp = now(); s.version += 1; } // the held edit is real after all return s; }, adopted(name, remote) { const s = S(name); s.stamp = typeof remote.u === "number" ? remote.u : now(); s.version = Math.max(s.version, typeof remote.v === "number" ? remote.v : 0); s.dirty = false; s.intent = false; return s; }, reasserted(name, remote) { const s = S(name); s.version = Math.max(s.version, typeof remote.v === "number" ? remote.v : 0) + 1; s.stamp = now(); s.dirty = true; return s; }, flush(name, data) { const s = S(name); if (!s.dirty || !s.hydrated) return null; s.dirty = false; const body = { u: s.stamp, v: s.version, d: data }; if (s.intent) { body.x = 1; s.intent = false; } return push(name, body); }, }; }