diff --git a/app/src/db/migrations.ts b/app/src/db/migrations.ts index 33c3dc1..41c0529 100644 --- a/app/src/db/migrations.ts +++ b/app/src/db/migrations.ts @@ -116,6 +116,23 @@ export const MIGRATIONS: Migration[] = [ ); `, }, + + { + id: 4, + name: "tombstones — row-level sync cannot propagate a delete without them", + sql: /* sql */ ` + -- A deleted row leaves nothing behind to compare timestamps against, so + -- last-write-wins would silently resurrect it from the other device on + -- the next pull. The tombstone is the delete, expressed as a row. + CREATE TABLE IF NOT EXISTS tombstone ( + tbl TEXT NOT NULL, -- 'card' | 'chat' | 'peek' | ... + pk TEXT NOT NULL, -- primary key, as text + updated_at INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (tbl, pk) + ) WITHOUT ROWID; + CREATE INDEX IF NOT EXISTS tombstone_updated ON tombstone(updated_at); + `, + }, ]; export const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]!.id; diff --git a/app/src/db/writes.ts b/app/src/db/writes.ts index 2fd81a3..55fc144 100644 --- a/app/src/db/writes.ts +++ b/app/src/db/writes.ts @@ -34,6 +34,22 @@ import type { Card, Grade } from "@lib/srs.js"; /** Wall-clock milliseconds, for stamping a genuine user edit. */ export const now = (): number => Date.now(); +/** + * Record that a row was deleted. + * + * A delete leaves nothing for last-write-wins to compare against, so without + * this the other device would push its still-live copy back and the row + * would silently return. Always written inside the same transaction as the + * delete it describes. + */ +async function tombstone(db: Db, tbl: string, pk: string | number): Promise { + await db.run( + `INSERT INTO tombstone (tbl, pk, updated_at) VALUES (?, ?, ?) + ON CONFLICT(tbl, pk) DO UPDATE SET updated_at = excluded.updated_at`, + [tbl, String(pk), now()], + ); +} + /* ═════════════════════════════════════════════════════════════════════ SEED WRITES — defaults, first-run state, anything the user did not do. None of these may set updated_at; the column default of 0 is the point. @@ -88,7 +104,10 @@ export async function editCard(db: Db, lemmaId: number, card: Card): Promise { - await db.run("DELETE FROM card WHERE lemma_id = ?", [lemmaId]); + await db.tx(async (tx) => { + await tx.run("DELETE FROM card WHERE lemma_id = ?", [lemmaId]); + await tombstone(tx, "card", lemmaId); + }); } /** The tutor's ::progress read, or the learner moving the unit by hand. */ @@ -141,15 +160,27 @@ export async function editChatTurn(db: Db, role: string, body: string): Promise< /** Wipe the transcript. Progress and cards are untouched. */ export async function editChatClear(db: Db): Promise { - await db.run("DELETE FROM chat"); + await db.tx(async (tx) => { + const rows = await tx.all<{ id: number }>("SELECT id FROM chat"); + await tx.run("DELETE FROM chat"); + for (const r of rows) await tombstone(tx, "chat", r.id); + }); } /** Trim the transcript. The artifact kept the last 26 turns. */ export async function editChatTrim(db: Db, keep: number): Promise { - await db.run( - `DELETE FROM chat WHERE id NOT IN (SELECT id FROM chat ORDER BY id DESC LIMIT ?)`, - [keep], - ); + await db.tx(async (tx) => { + const doomed = await tx.all<{ id: number }>( + `SELECT id FROM chat WHERE id NOT IN (SELECT id FROM chat ORDER BY id DESC LIMIT ?)`, + [keep], + ); + if (!doomed.length) return; + await tx.run( + `DELETE FROM chat WHERE id NOT IN (SELECT id FROM chat ORDER BY id DESC LIMIT ?)`, + [keep], + ); + for (const r of doomed) await tombstone(tx, "chat", r.id); + }); } /** Study happened today: reviews, correct answers, drill answers. */ @@ -272,6 +303,7 @@ export async function editRemoveCustomWord(db: Db, lemmaId: number): Promise { await db.tx(async (tx) => { + // Tombstone before deleting, while the keys are still readable — a reset + // must propagate, or the next pull restores everything it just erased. + for (const r of await tx.all<{ unit_id: string }>("SELECT unit_id FROM progress")) + await tombstone(tx, "progress", r.unit_id); + for (const r of await tx.all<{ id: number }>("SELECT id FROM chat")) + await tombstone(tx, "chat", r.id); + await tx.run("DELETE FROM progress"); await tx.run("DELETE FROM chat"); if (scope === "everything") { + for (const r of await tx.all<{ lemma_id: number }>("SELECT lemma_id FROM card")) + await tombstone(tx, "card", r.lemma_id); + for (const r of await tx.all<{ day: number }>("SELECT day FROM study_log")) + await tombstone(tx, "study_log", r.day); + for (const r of await tx.all<{ form: string }>("SELECT form FROM peek")) + await tombstone(tx, "peek", r.form); + await tx.run("DELETE FROM card"); await tx.run("DELETE FROM study_log"); await tx.run("DELETE FROM peek"); diff --git a/app/src/domain/server-config.ts b/app/src/domain/server-config.ts new file mode 100644 index 0000000..e7dd04f --- /dev/null +++ b/app/src/domain/server-config.ts @@ -0,0 +1,43 @@ +/* Where the Pi is, if there is one. + + Kept in `meta` under keys the sync allowlist excludes, because this is + device-local and one of the two values is a secret. Syncing a token to + every device through the very endpoint it authenticates would be + circular, and syncing a base URL would break a device on a different + network. + + With nothing configured the app uses the local stub and never touches the + network — which is the whole offline-first guarantee, expressed as a + default rather than a mode. */ + +import type { Db } from "../db/types.js"; +import { editMeta } from "../db/writes.js"; + +const URL_KEY = "server.url"; +const TOKEN_KEY = "server.token"; + +export interface ServerConfig { + baseUrl: string; + token: string; +} + +export async function readServerConfig(db: Db): Promise { + const rows = await db.all<{ k: string; v: string }>( + "SELECT k, v FROM meta WHERE k IN (?, ?)", + [URL_KEY, TOKEN_KEY], + ); + const map = new Map(rows.map((r) => [r.k, r.v])); + const baseUrl = (map.get(URL_KEY) ?? "").trim(); + const token = (map.get(TOKEN_KEY) ?? "").trim(); + return baseUrl && token ? { baseUrl, token } : null; +} + +export async function writeServerConfig(db: Db, cfg: ServerConfig): Promise { + await editMeta(db, URL_KEY, cfg.baseUrl.trim()); + await editMeta(db, TOKEN_KEY, cfg.token.trim()); +} + +export async function clearServerConfig(db: Db): Promise { + await editMeta(db, URL_KEY, ""); + await editMeta(db, TOKEN_KEY, ""); +} diff --git a/app/src/state/store.tsx b/app/src/state/store.tsx index 5f34cab..4a0d5ed 100644 --- a/app/src/state/store.tsx +++ b/app/src/state/store.tsx @@ -25,6 +25,13 @@ import { bandForUnit } from "@shared/bands.mjs"; import { ensureBands, loadManifest, recordProvenance, type DictManifest } from "../domain/dictionary.js"; import { initProgress, readProgress } from "../domain/progress.js"; import { seedKnownWords } from "../domain/seed-known.js"; +import { + clearServerConfig, + readServerConfig, + writeServerConfig, + type ServerConfig, +} from "../domain/server-config.js"; +import { trySync, type SyncResult } from "../sync/client.js"; import type { ProgressState } from "@lib/gate.js"; import type { FocusMode } from "../domain/gate.js"; @@ -89,6 +96,20 @@ export interface Store { /** Bump to tell tabs that card or log data changed underneath them. */ revision: number; invalidate: () => void; + + /** The Pi, when one is configured. Null means fully local. */ + server: ServerConfig | null; + setServer: (cfg: ServerConfig | null) => Promise; + /** Last sync outcome, for the settings panel. Null until one has run. */ + syncState: SyncState; + syncNow: () => Promise; +} + +export interface SyncState { + at: number | null; + result: SyncResult | null; + error: string | null; + running: boolean; } const StoreContext = createContext(null); @@ -123,6 +144,13 @@ export function StoreProvider({ const [prefs, setPrefs] = useState(DEFAULT_PREFS); const [revision, setRevision] = useState(0); const [today, setToday] = useState(() => dayNumber()); + const [server, setServerState] = useState(null); + const [syncState, setSyncState] = useState({ + at: null, + result: null, + error: null, + running: false, + }); const started = useRef(false); useEffect(() => { @@ -159,6 +187,7 @@ export function StoreProvider({ setStore({ db, dbInfo: db.info, manifest: await loadManifest() }); setProgress(stored); setPrefs(loaded); + setServerState(await readServerConfig(db)); setBoot({ phase: "ready", detail: "" }); } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); @@ -200,12 +229,86 @@ export function StoreProvider({ const invalidate = useCallback(() => setRevision((r) => r + 1), []); + const setServer = useCallback( + async (cfg: ServerConfig | null) => { + if (!store) return; + if (cfg) await writeServerConfig(store.db, cfg); + else await clearServerConfig(store.db); + setServerState(cfg); + }, + [store], + ); + + /* Sync is opportunistic and never blocks anything. A failure is recorded + for the settings panel and otherwise ignored — the app has to stay + fully usable with the Pi unreachable. */ + const running = useRef(false); + const syncNow = useCallback(async () => { + if (!store || !server || running.current) return; + running.current = true; + setSyncState((s) => ({ ...s, running: true })); + const result = await trySync(store.db, server); + running.current = false; + setSyncState({ + at: Date.now(), + result, + error: result ? null : "Could not reach the server", + running: false, + }); + if (result && (result.pulled > 0 || result.pushed > 0)) { + await readProgress(store.db).then(setProgress); + setRevision((r) => r + 1); + } + }, [server, store]); + + /* Once on connect, then every few minutes. Deliberately not on every + write: a tutor turn should never wait on the network. */ + useEffect(() => { + if (!store || !server) return; + void syncNow(); + const timer = setInterval(() => void syncNow(), 5 * 60_000); + const onVisible = () => { + if (document.visibilityState === "visible") void syncNow(); + }; + document.addEventListener("visibilitychange", onVisible); + return () => { + clearInterval(timer); + document.removeEventListener("visibilitychange", onVisible); + }; + }, [server, store, syncNow]); + const value = useMemo( () => store && progress - ? { ...store, progress, refreshProgress, prefs, setPref, today, revision, invalidate } + ? { + ...store, + progress, + refreshProgress, + prefs, + setPref, + today, + revision, + invalidate, + server, + setServer, + syncState, + syncNow, + } : null, - [store, progress, refreshProgress, prefs, setPref, today, revision, invalidate], + [ + store, + progress, + refreshProgress, + prefs, + setPref, + today, + revision, + invalidate, + server, + setServer, + syncState, + syncNow, + ], ); if (!value) return <>{fallback(boot)}; diff --git a/app/src/sync/client.ts b/app/src/sync/client.ts new file mode 100644 index 0000000..053ffb8 --- /dev/null +++ b/app/src/sync/client.ts @@ -0,0 +1,258 @@ +/* The sync loop: push what changed here, pull what changed there, apply. + + Offline-first is not negotiable. Sync runs only when a server is + configured, and every failure is non-fatal — the app must stay completely + usable with the Pi unreachable, which is the acceptance criterion the + whole port was built around. Nothing in here may throw into the UI. */ + +import type { Db } from "../db/types.js"; +import { + PAGE_SIZE, + SYNC_TABLES, + SYNC_TABLE_NAMES, + incomingWins, + isSyncableMetaKey, +} from "@shared/sync-protocol.mjs"; + +/** A row on the wire: its table, its columns, and when it last changed. */ +export interface SyncRow { + tbl: string; + data: Record; + updated_at: number; + /** Present and true when this row is a delete. */ + deleted?: boolean; +} + +export interface PushRequest { + rows: SyncRow[]; +} +export interface PushResponse { + cursor: number; +} +export interface PullResponse { + rows: SyncRow[]; + cursor: number; + more: boolean; +} + +export interface SyncConfig { + baseUrl: string; + token: string; +} + +export interface SyncResult { + pushed: number; + pulled: number; + cursor: number; +} + +const CURSOR_KEY = "sync.cursor"; +const PUSHED_KEY = "sync.pushedAt"; + +/* ── local bookkeeping ──────────────────────────────────────────────── + These are device state, not user data. They are written with + updated_at = 0 and excluded from the syncable-meta allowlist, so they + never travel. */ + +async function readNum(db: Db, key: string): Promise { + const row = await db.get<{ v: string }>("SELECT v FROM meta WHERE k = ?", [key]); + const n = row ? Number(row.v) : 0; + return Number.isFinite(n) ? n : 0; +} + +async function writeNum(db: Db, key: string, value: number): Promise { + await db.run("INSERT OR REPLACE INTO meta (k, v, updated_at) VALUES (?, ?, 0)", [ + key, + String(value), + ]); +} + +/* ── collect ─────────────────────────────────────────────────────────── */ + +/** Everything edited on this device since the last successful push. */ +export async function collectDirty(db: Db, pushedAt: number): Promise { + const out: SyncRow[] = []; + + for (const tbl of SYNC_TABLE_NAMES) { + const spec = SYNC_TABLES[tbl]!; + const cols = [...spec.cols, "updated_at"].join(", "); + const rows = await db.all>( + `SELECT ${cols} FROM ${tbl} WHERE updated_at > ? ORDER BY updated_at LIMIT ?`, + [pushedAt, PAGE_SIZE], + ); + + for (const row of rows) { + // The allowlist is applied at the point of collection, so a + // device-local meta key cannot leave this machine even by accident. + if (tbl === "meta" && !isSyncableMetaKey(String(row.k))) continue; + const { updated_at, ...data } = row; + out.push({ tbl, data, updated_at: Number(updated_at) }); + } + } + + const graves = await db.all<{ tbl: string; pk: string; updated_at: number }>( + "SELECT tbl, pk, updated_at FROM tombstone WHERE updated_at > ? ORDER BY updated_at LIMIT ?", + [pushedAt, PAGE_SIZE], + ); + for (const g of graves) { + out.push({ tbl: g.tbl, data: { pk: g.pk }, updated_at: Number(g.updated_at), deleted: true }); + } + + return out; +} + +/* ── apply ───────────────────────────────────────────────────────────── */ + +/** Apply one incoming row, last-write-wins against what is already here. */ +async function applyRow(db: Db, row: SyncRow): Promise { + const spec = SYNC_TABLES[row.tbl]; + if (!spec) return false; // unknown table — a newer server; ignore rather than fail + + if (row.deleted) { + const pk = String(row.data.pk); + const where = spec.pk.map((c) => `${c} = ?`).join(" AND "); + const parts = pk.split(" "); + const local = await db.get<{ updated_at: number }>( + `SELECT updated_at FROM ${row.tbl} WHERE ${where}`, + parts, + ); + if (local && !incomingWins(row.updated_at, local.updated_at)) return false; + + await db.tx(async (tx) => { + await tx.run(`DELETE FROM ${row.tbl} WHERE ${where}`, parts); + await tx.run( + `INSERT INTO tombstone (tbl, pk, updated_at) VALUES (?, ?, ?) + ON CONFLICT(tbl, pk) DO UPDATE SET updated_at = excluded.updated_at`, + [row.tbl, pk, row.updated_at], + ); + }); + return true; + } + + if (row.tbl === "meta" && !isSyncableMetaKey(String(row.data.k))) return false; + + const where = spec.pk.map((c) => `${c} = ?`).join(" AND "); + const keys = spec.pk.map((c) => row.data[c] as string | number); + const local = await db.get<{ updated_at: number }>( + `SELECT updated_at FROM ${row.tbl} WHERE ${where}`, + keys, + ); + if (local && !incomingWins(row.updated_at, local.updated_at)) return false; + + const cols = [...spec.cols, "updated_at"]; + const holes = cols.map(() => "?").join(", "); + const values = [...spec.cols.map((c) => row.data[c] ?? null), row.updated_at]; + await db.run(`INSERT OR REPLACE INTO ${row.tbl} (${cols.join(", ")}) VALUES (${holes})`, values); + return true; +} + +/* ── transport ───────────────────────────────────────────────────────── */ + +async function call( + cfg: SyncConfig, + path: string, + init: RequestInit, + signal?: AbortSignal, +): Promise { + const res = await fetch(`${cfg.baseUrl.replace(/\/$/, "")}${path}`, { + ...init, + signal, + headers: { + "content-type": "application/json", + authorization: `Bearer ${cfg.token}`, + ...(init.headers ?? {}), + }, + }); + if (!res.ok) throw new Error(`sync ${path}: HTTP ${res.status}`); + return (await res.json()) as T; +} + +/* ── the loop ────────────────────────────────────────────────────────── */ + +/** + * One full exchange. Push first so local edits are never lost to an + * incoming row that would have overwritten them; then pull, paging until + * the server has nothing newer. + */ +export async function syncOnce( + db: Db, + cfg: SyncConfig, + signal?: AbortSignal, +): Promise { + // Anything edited after this instant stays dirty, whatever the pull + // brings back. That is what keeps an edit made *during* a sync from being + // marked clean by a remote row that happens to carry a later timestamp. + const startedAt = Date.now(); + + const pushedAt = await readNum(db, PUSHED_KEY); + const dirty = await collectDirty(db, pushedAt); + let watermark = pushedAt; + + if (dirty.length) { + const body: PushRequest = { rows: dirty }; + await call( + cfg, + "/api/sync", + { method: "POST", body: JSON.stringify(body) }, + signal, + ); + // Only advance once the server has them. A failed push throws before + // this line and the rows stay dirty, which is the safe direction. + watermark = dirty.reduce((m, r) => Math.max(m, r.updated_at), watermark); + await writeNum(db, PUSHED_KEY, watermark); + } + + /* The pull cursor advances ONLY as rows are applied. + It must never be taken from the push response: the server's newest + change_seq includes rows this device has not seen, and adopting it + would skip them permanently — a silent, unrecoverable data loss, since + nothing would ever ask for that range again. */ + let cursor = await readNum(db, CURSOR_KEY); + let pulled = 0; + + for (;;) { + const page = await call( + cfg, + `/api/sync?cursor=${cursor}`, + { method: "GET" }, + signal, + ); + + for (const row of page.rows) { + if (await applyRow(db, row)) pulled++; + // A row we just received is not a local edit. Marking it clean stops + // the next sync pushing it straight back. Bounded by startedAt so a + // concurrent local edit is never swept up with it. + if (row.updated_at <= startedAt) watermark = Math.max(watermark, row.updated_at); + } + + cursor = page.cursor; + await writeNum(db, CURSOR_KEY, cursor); + if (!page.more) break; + } + + if (watermark > pushedAt) await writeNum(db, PUSHED_KEY, watermark); + + return { pushed: dirty.length, pulled, cursor }; +} + +/** + * Sync if configured, and swallow anything that goes wrong. + * + * Callers are UI code on a timer; a sync failure is a diagnostic, never an + * interruption. Returns null when there is nothing configured or the + * exchange failed. + */ +export async function trySync( + db: Db, + cfg: SyncConfig | null, + signal?: AbortSignal, +): Promise { + if (!cfg?.baseUrl || !cfg.token) return null; + try { + return await syncOnce(db, cfg, signal); + } catch (err) { + console.warn("[sync] skipped:", err instanceof Error ? err.message : err); + return null; + } +} diff --git a/app/src/ui/tabs/TodayTab.tsx b/app/src/ui/tabs/TodayTab.tsx index 3efa4f6..5e2336f 100644 --- a/app/src/ui/tabs/TodayTab.tsx +++ b/app/src/ui/tabs/TodayTab.tsx @@ -228,6 +228,87 @@ function About() { ); } +/* The Pi, if there is one. Everything works without it; this is what turns + on the real tutor and syncing between devices. */ +function ServerPanel() { + const { server, setServer, syncState, syncNow } = useStore(); + const [url, setUrl] = useState(server?.baseUrl ?? ""); + const [token, setToken] = useState(server?.token ?? ""); + + const ago = + syncState.at === null + ? "never" + : `${Math.max(0, Math.round((Date.now() - syncState.at) / 1000))}s ago`; + + return ( +
+
+

서버

+ + {server ? "Connected — the real 선생님, and sync" : "Not set — everything stays on this device"} + +
+
+ + + +
+ + {server && ( + <> + + + + )} +
+ + {server && ( +

+ {syncState.error + ? `Last sync failed (${ago}) — ${syncState.error}. Your work is safe here and will go up when the server is reachable.` + : syncState.result + ? `Last sync ${ago}: sent ${syncState.result.pushed}, received ${syncState.result.pulled}.` + : "Not synced yet."} +

+ )} +
+
+ ); +} + export function TodayTab({ onGoTo }: { onGoTo: (tab: "lesson" | "sent") => void }) { const { db, progress, prefs, today, revision } = useStore(); const { start } = useReview(); @@ -353,6 +434,7 @@ export function TodayTab({ onGoTo }: { onGoTo: (tab: "lesson" | "sent") => void + diff --git a/shared/sync-protocol.mjs b/shared/sync-protocol.mjs new file mode 100644 index 0000000..db341b8 --- /dev/null +++ b/shared/sync-protocol.mjs @@ -0,0 +1,69 @@ +/* The sync wire format, defined once and imported by both sides. + + Row-level, last-write-wins on `updated_at`, cursor-based on a + server-assigned `change_seq`. One user, so the loser of a conflict is at + worst one SRS grade — LWW is adequate, and a merge policy would be + over-engineering. + + THE RULE THAT MATTERS: seeded and defaulted rows carry updated_at = 0. + The artifact's sync bug was a fresh device stamping its own empty state + as newer than the server's real history and clobbering it. Here a seed + row can never be dirty (0 is never greater than any watermark) and can + never win a conflict (0 is never greater than any timestamp). The bug is + not avoided, it is unrepresentable. */ + +/** Tables that sync, and the columns forming each primary key. */ +export const SYNC_TABLES = { + card: { + pk: ["lemma_id"], + cols: ["lemma_id", "state", "ease", "interval", "due", "reps", "lapses"], + }, + progress: { pk: ["unit_id"], cols: ["unit_id", "state", "confidence"] }, + chat: { pk: ["id"], cols: ["id", "role", "body", "created_at"] }, + meta: { pk: ["k"], cols: ["k", "v"] }, + study_log: { pk: ["day"], cols: ["day", "reviews", "correct", "drills"] }, + peek: { pk: ["form"], cols: ["form", "count"] }, +}; + +export const SYNC_TABLE_NAMES = Object.keys(SYNC_TABLES); + +/** Primary-key columns for a table, or null if we do not sync it. */ +export const pkFor = (tbl) => + Object.prototype.hasOwnProperty.call(SYNC_TABLES, tbl) ? SYNC_TABLES[tbl].pk : null; + +/** + * `meta` mixes the learner's data with bookkeeping that describes one + * install. Only the former may cross the wire. + * + * `dict.loadedBands` is the dangerous one: replicating it would tell a + * phone that had loaded bands 0-2 that it holds every row the desktop has, + * and the word rail would then fail to find words it believes are present. + * `schema_version` would be worse — a device could be told it has run a + * migration it has not. `server.*` holds this device's endpoint and bearer + * token: syncing a token through the endpoint it authenticates would be + * circular, and a base URL is network-specific. + */ +const SYNCABLE_META_EXACT = new Set(["grammar.learned", "grammar.notes", "trainer.conjugation"]); +const SYNCABLE_META_PREFIX = ["prefs."]; + +export function isSyncableMetaKey(key) { + if (SYNCABLE_META_EXACT.has(key)) return true; + return SYNCABLE_META_PREFIX.some((p) => key.startsWith(p)); +} + +/** Stable string key for a row, used to match it across devices. */ +export const rowKey = (table, row) => SYNC_TABLES[table].pk.map((c) => String(row[c])).join(" "); + +/** Rows a device may send: dirty since its last successful push. */ +export const isDirty = (row, pushedAt) => Number(row.updated_at) > Number(pushedAt); + +/** + * Last-write-wins. Strictly greater, so equal timestamps leave the local row + * alone — a tie means both sides already agree, or the clocks are close + * enough that flapping would be worse than either outcome. + */ +export const incomingWins = (incomingUpdatedAt, localUpdatedAt) => + Number(incomingUpdatedAt) > Number(localUpdatedAt ?? -1); + +/** Maximum rows in one push or pull page. */ +export const PAGE_SIZE = 500; diff --git a/types/shared/sync-protocol.mjs.d.ts b/types/shared/sync-protocol.mjs.d.ts new file mode 100644 index 0000000..3cb6f17 --- /dev/null +++ b/types/shared/sync-protocol.mjs.d.ts @@ -0,0 +1,17 @@ +/* Declarations for shared/sync-protocol.mjs. */ + +export interface SyncTableSpec { + pk: string[]; + cols: string[]; +} + +export const SYNC_TABLES: Record; +export const SYNC_TABLE_NAMES: string[]; +export const PAGE_SIZE: number; + +export function pkFor(tbl: string): string[] | null; + +export function isSyncableMetaKey(key: string): boolean; +export function rowKey(table: string, row: Record): string; +export function isDirty(row: { updated_at: number }, pushedAt: number): boolean; +export function incomingWins(incomingUpdatedAt: number, localUpdatedAt: number | null): boolean;