Row-level last-write-wins on updated_at, cursor-based on a server-assigned change_seq. The schema was built for this in step 1, so the work here is the three things it did not yet have. Tombstones (migration 4). Row-level sync cannot express a delete: with the row gone there is nothing to compare timestamps against, so the other device pushes its still-live copy back and the row silently returns. Every delete path now writes a tombstone inside the same transaction. The wire format lives in shared/sync-protocol.mjs and is imported by both sides, so there is one definition rather than two that drift. It carries the syncable-meta allowlist, which is the load-bearing part: meta mixes the learner's preferences with bookkeeping that describes one install, and replicating dict.loadedBands would tell a phone that had loaded bands 0-2 it holds every row the desktop has — the word rail would then fail to find words it believes are present. The sync loop pushes first, then pages the pull. Two details it would be easy to get wrong, both commented at their site: - The pull cursor advances only as rows are applied, never from the push response. The server's newest change_seq includes rows this device has not seen; adopting it skips them permanently, and nothing ever asks for that range again. - Pulled rows advance sync.pushedAt too, bounded by the instant the sync started. Otherwise they look like local edits and get pushed straight back, and an edit made during the sync is not swept up with them. Seeded rows carry updated_at = 0, so a fresh device is never dirty and can never win a conflict — the artifact's clobbering bug stays unrepresentable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
44 lines
1.5 KiB
TypeScript
44 lines
1.5 KiB
TypeScript
/* 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<ServerConfig | null> {
|
|
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<void> {
|
|
await editMeta(db, URL_KEY, cfg.baseUrl.trim());
|
|
await editMeta(db, TOKEN_KEY, cfg.token.trim());
|
|
}
|
|
|
|
export async function clearServerConfig(db: Db): Promise<void> {
|
|
await editMeta(db, URL_KEY, "");
|
|
await editMeta(db, TOKEN_KEY, "");
|
|
}
|