Local sync engine
Two replicas can merge in either order and land on the exact same result: a stale peer's edit can never resurrect a row a later delete already won.
What it is
local-sync is Caisson's two-way offline sync engine: a per-tenant changeset log captures every local mutation, a hybrid logical clock (a wall-clock hint plus a non-forgeable replica id and monotonic counter) stamps each change, and a pure last-writer-wins merge converges any set of replicas to one identical result. Tombstones persist across sync rounds, so a stale peer edit can never resurrect a row a later delete already won.
What ships in the module
Per-tenant changeset capture, replica-stamped
ChangesetLog.open binds one instance to exactly one tenant's already-open SQLite file: on first use it mints a randomUUID() replica id and persists it in sync_meta; on re-open it asserts the file's stored tenant matches and throws TenancyError rather than re-pointing the file to a different tenant. recordUpsert and recordDelete mirror every local write into sync_changelog; capture(sinceSeq) packages everything past a watermark into a Changeset.
Fail-closed changeset validation at the boundary
parseChangeset runs an untrusted, peer-supplied payload through changesetSchema (a strictObject that rejects unknown keys) before anything touches local state. A superRefine cross-field check enforces that an upsert entry MUST carry values and a delete entry MUST NOT, and rejects any entry whose seq exceeds the changeset's own until watermark.
A non-forgeable hybrid logical clock
stampFromEntry derives an HlcStamp (physical (the updatedAt wall-clock hint), node (the originating replicaId), counter (the per-replica seq)) for every captured change. compareStamps is a strict total order over the three: physical first, then node, then counter, so a peer can bias the physical leg by skewing its clock but can never forge another replica's node to win a tie.
Order-independent LWW merge, no resurrection by construction
reconcileReplicas folds every changeset's entries into one winners Map keyed by (table, pk), keeping only the entry whose compareStamps result is greatest, a winning delete is simply never pushed into the returned rows, so a losing concurrent upsert can't resurrect it. The result is sorted by (table, pk), so reconcileReplicas([A, B]) and reconcileReplicas([B, A]) serialize byte-equal.
Tombstones persist across sync rounds
reconcileWithTombstones composes reconcileReplicas rather than reimplementing it: it replays a persisted Tombstone set as synthetic delete Changesets so a stale, lower-stamped upsert from a batch that no longer carries the original delete still loses. advanceTombstones folds prior tombstones and new entries into the greatest-stamped delete per key; a strictly-greater upsert legitimately re-creates the row and drops out of the set.
Horizon-gated GC, and a cross-tenant merge fails closed
gcTombstones drops a tombstone only once stamp.physical crosses a horizon the caller must set below the slowest replica's un-synced-edit lag, collect earlier and a still-pending stale upsert could resurrect the row. Both reconcileReplicas and reconcileWithTombstones throw TenancyError the moment two changesets don't share one tenantId, defense-in-depth over the file-per-tenant boundary ChangesetLog.assertApplicable already enforces at the transport edge.
export function reconcileReplicas(
changesets: readonly Changeset[],
): ReconciledRow[] {
// Defense-in-depth: all replicas must belong to the same tenant file (the ADR-0073 partition).
let tenantId: string | undefined;
for (const cs of changesets) {
if (tenantId === undefined) {
tenantId = cs.tenantId;
} else if (cs.tenantId !== tenantId) {
throw new TenancyError("cannot reconcile changesets across tenants", {
reason: "tenant-partition",
});
}
}
// LWW register per (table, pk): keep the change with the greatest HLC stamp.
const winners = new Map<string, Map<string, Winner>>();
for (const cs of changesets) {
for (const entry of cs.entries) {
const stamp = stampFromEntry(entry, cs.replicaId);
let byPk = winners.get(entry.table);
if (byPk === undefined) {
byPk = new Map<string, Winner>();
winners.set(entry.table, byPk);
}
const current = byPk.get(entry.pk);
if (current === undefined || compareStamps(stamp, current.stamp) > 0) {
byPk.set(entry.pk, { entry, stamp });
}
}
}
// Materialize the live set: a winning delete is a tombstone (excluded — no resurrection by a loser).
const rows: ReconciledRow[] = [];
for (const [table, byPk] of winners) {
for (const [pk, winner] of byPk) {
const { entry } = winner;
if (entry.op === "upsert" && entry.values !== null) {
rows.push({ table, pk, values: entry.values });
}
}
}
// Total-order the live set by (table, pk) so divergent replicas serialize byte-equal.
rows.sort((a, b) =>
a.table < b.table
? -1
: a.table > b.table
? 1
: a.pk < b.pk
? -1
: a.pk > b.pk
? 1
: 0,
);
return rows;
}- The winners Map keeps only the entry whose compareStamps result is greatest per (table, pk), the HLC total order is the only comparator, so a skewed peer clock can't decide a tie the node/counter tiebreak already settled.
- A winning delete is never pushed into rows, only entry.op === "upsert" reaches the returned set, so a tombstone is excluded by construction rather than filtered out after the fact.
- rows.sort orders purely by table then pk with no dependency on changeset input order, which is what makes reconcileReplicas([A, B]) and reconcileReplicas([B, A]) serialize byte-identical.