Sync was push-on-demand only: a deck frozen on the laptop stayed in dirty
until someone opened Settings and pressed Sync now. The two stores whose
writes mean "the user just made something" now schedule one debounced
background syncNow("auto") through lib/sync-auto: debounced 2.5s so a burst
of writes is one push, one run at a time, silent on failure (the data stays
dirty and Settings still offers the manual sync), retried on the browser's
online event, and imported dynamically so the sync code stays out of the
first load with no import cycle.
markSyncDirty() now reports whether it actually marked, which keeps pull and
backup-import paths quiet while a real import pushes like any other local
change. Answers and chat keep the existing manual behaviour on purpose —
they change every few seconds while studying.
Tests: phase-1 (a deck write pushes itself, then Sync now is a no-op — 50
checks) and phase-3 (a check run pushes the new homework record — 30 checks).
README: decision 59. SW -> freezmo-v46.
141 lines
3.8 KiB
TypeScript
141 lines
3.8 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Local bookkeeping for opt-in encrypted sync: whether it is on, the random
|
|
* sync ID, when this device last synced and whether local data changed since.
|
|
* The passphrase itself is never stored here — the derived key lives in
|
|
* IndexedDB as a non-extractable CryptoKey (lib/sync-key.ts) and nowhere else.
|
|
*/
|
|
|
|
const STORAGE_KEY = "freezmo.sync.v1";
|
|
|
|
export type SyncRecord = {
|
|
v: 1;
|
|
enabled: boolean;
|
|
/** Random 128-bit id — the only handle the server ever sees. */
|
|
id: string;
|
|
/** KDF salt (not secret; travels in the envelope too). */
|
|
salt: string;
|
|
lastSyncedAt: number | null;
|
|
/** Set whenever local data changes; cleared after a successful sync. */
|
|
dirtyAt: number | null;
|
|
/** True when a sync found both sides changed (the UI asks what to keep). */
|
|
conflict: boolean;
|
|
};
|
|
|
|
const EMPTY: SyncRecord = {
|
|
v: 1,
|
|
enabled: false,
|
|
id: "",
|
|
salt: "",
|
|
lastSyncedAt: null,
|
|
dirtyAt: null,
|
|
conflict: false,
|
|
};
|
|
|
|
type Listener = () => void;
|
|
|
|
let record: SyncRecord | null = null;
|
|
let cached: SyncRecord = EMPTY;
|
|
const listeners = new Set<Listener>();
|
|
let suppressDirty = false;
|
|
|
|
function read(): SyncRecord {
|
|
if (typeof window === "undefined") return EMPTY;
|
|
try {
|
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
if (!raw) return EMPTY;
|
|
const parsed = JSON.parse(raw) as Partial<SyncRecord>;
|
|
if (parsed.v !== 1) return EMPTY;
|
|
return {
|
|
v: 1,
|
|
enabled: parsed.enabled === true,
|
|
id: typeof parsed.id === "string" ? parsed.id : "",
|
|
salt: typeof parsed.salt === "string" ? parsed.salt : "",
|
|
lastSyncedAt: typeof parsed.lastSyncedAt === "number" ? parsed.lastSyncedAt : null,
|
|
dirtyAt: typeof parsed.dirtyAt === "number" ? parsed.dirtyAt : null,
|
|
conflict: parsed.conflict === true,
|
|
};
|
|
} catch {
|
|
return EMPTY;
|
|
}
|
|
}
|
|
|
|
function ensureLoaded(): void {
|
|
if (record !== null) return;
|
|
record = read();
|
|
cached = record;
|
|
}
|
|
|
|
function write(next: SyncRecord): void {
|
|
record = next;
|
|
cached = next;
|
|
try {
|
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
} catch {
|
|
/* private mode */
|
|
}
|
|
listeners.forEach((listener) => listener());
|
|
}
|
|
|
|
export const syncStore = {
|
|
subscribe(listener: Listener): () => void {
|
|
ensureLoaded();
|
|
listeners.add(listener);
|
|
const onStorage = (event: StorageEvent) => {
|
|
if (event.key === STORAGE_KEY) {
|
|
record = null;
|
|
ensureLoaded();
|
|
listeners.forEach((l) => l());
|
|
}
|
|
};
|
|
window.addEventListener("storage", onStorage);
|
|
return () => {
|
|
listeners.delete(listener);
|
|
window.removeEventListener("storage", onStorage);
|
|
};
|
|
},
|
|
getSnapshot(): SyncRecord {
|
|
ensureLoaded();
|
|
return cached;
|
|
},
|
|
getServerSnapshot(): SyncRecord {
|
|
return EMPTY;
|
|
},
|
|
};
|
|
|
|
export function setSyncRecord(next: Partial<SyncRecord>): void {
|
|
ensureLoaded();
|
|
write({ ...syncStore.getSnapshot(), ...next });
|
|
}
|
|
|
|
/**
|
|
* Called by every data store's write(); no-op when sync is off (or while a
|
|
* pulled payload is being applied). Returns true when the data was actually
|
|
* marked dirty, so callers can schedule an immediate sync.
|
|
*/
|
|
export function markSyncDirty(): boolean {
|
|
if (suppressDirty) return false;
|
|
ensureLoaded();
|
|
const current = syncStore.getSnapshot();
|
|
if (!current.enabled || current.dirtyAt !== null) return false;
|
|
write({ ...current, dirtyAt: Date.now() });
|
|
return true;
|
|
}
|
|
|
|
/** Applying a pulled payload must not mark the local data dirty again. */
|
|
export function withoutDirtyTracking<T>(fn: () => T): T {
|
|
suppressDirty = true;
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
suppressDirty = false;
|
|
}
|
|
}
|
|
|
|
/** 16 random bytes as hex = 128 bits, unguessable. */
|
|
export function newSyncId(): string {
|
|
const bytes = new Uint8Array(16);
|
|
crypto.getRandomValues(bytes);
|
|
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
}
|