Merge
Two devices edit the same store while apart; eventually both changesets apply everywhere, and every device must land on the same rows. This page is the semantics of that landing: the clock that orders edits, the column-level merge, and what wins when edits truly collide.
How changesets travel between devices is the Sync page; this one starts where a changeset is already in hand. Examples: Alice and Bob share the todos store.
The clock
Wall clocks cannot order edits: on a laptop a few minutes behind a phone, a correction would sort as older than the edit it corrects, and lose to it. The ordering has to survive clocks that drift, sit offline for weeks, or lie, and it must preserve one guarantee above all: if you pull my edit and then change it, your change wins. A hybrid logical clock provides exactly that.
_updated_at is a hybrid logical clock stamp, not wall-clock time. The host must treat it as opaque: bind the string coven hands it into the row and never parse or compare it as a date. Its format, internal to coven, is {millis:013}-{counter:04}-{device_id}, for example 1735689600000-0000-alice. The three parts make the string sort lexicographically in causal order: a fixed-width millisecond field, then a counter that breaks same-millisecond ties on one device, then the device id that breaks ties across devices.
The clock is an Hlc. Hlc::now mints the next stamp: if wall-clock millis moved forward it adopts them and resets the counter, otherwise it bumps the counter, so each stamp is strictly greater than the last. The host never calls this directly. It calls sql.stamp() inside handle.sql or handle.write, binding the result into every synced-row write. The SQL context and the sync layer share one Arc<Hlc>.
The handle open path seeds that clock before it returns, so every stamp minted through the handle is already past every value on disk. The floor is max(persisted high-water mark, max(_updated_at) scanned across every synced table), so a restart cannot mint a stamp behind a value already written. The on-disk scan is the authoritative source: the high-water mark is flushed only at cycle end and lags any local row stamp minted between cycles.
Advancing past pulled rows
As each changeset applies, the cycle takes the greatest _updated_at among its applied rows and calls advance_past, so an edit made between two applies already sorts after the rows the first apply landed. The next local stamp then sorts strictly after everything pulled so far: pull, then edit, and the edit wins.
The advance is bounded the same way arbitration is (below): a stamp the arbiter refused as grossly future never ratchets the clock either, because only applied rows feed the advance.
Concretely: Alice creates a todo at her 12:00:00, stamped ...-alice. Bob pulls it; his clock advances past Alice's stamp. Bob edits the same todo five seconds later. Even if Bob's wall clock were behind Alice's, his stamp is seeded past hers, so it is lexicographically greater. His changeset reaches Alice, her pull applies it, and his edit wins. Both devices converge on Bob's version: pull-then-edit wins, whatever the wall clocks say.
Which rows merge
(table, id) is the logical row identity across every device. Equal ids in one table therefore select one row and enter the merge below. Tables whose rows are created independently declare RowIdentity::IndependentUuid and accept only canonical UUIDv4 or UUIDv7 ids. Tables with application keys that intentionally name shared state declare RowIdentity::SharedKey; equal keys then merge as one row under _updated_at.
A primary-key change removes the old identity and inserts the new identity; SQLite records it the same way as an explicit delete plus insert. The introduced id must satisfy the table's mode. A valid UUID collision still means one logical row, because equality of the identifier is the identity rule; UUID mode prevents predictable key reuse rather than changing equality semantics.
The merge
Two devices edit while apart; both changesets eventually apply everywhere. Merge runs in two stages inside apply.
Stage one: column-level three-way premerge. An UPDATE changeset carries, per column it changed, the value it moved from (the base) and the value it moved to. When an incoming update loses row arbitration, the premerge rescues its column edits: any column the update moved away from a base value the local row still holds is folded into the local row. The local device never touched that column, so the incoming edit to it survives. When the incoming update wins, it only writes the columns it changed in the first place. Either way, concurrent edits to different columns of one row both land.
Stage two: row arbitration. For every collision the premerge did not fold in, arbitrate_row_conflict compares the two _updated_at stamps and the later writer wins. Concurrent edits to the same column therefore resolve to the later stamp. The _updated_at column index is read from PRAGMA table_info at apply time, so adding columns to the end of a table stays safe.
Two special cases:
- Deletes are remove-wins. A hard delete carries only the row's pre-delete stamp and cannot be reconstructed from a later partial update, so an incoming delete always wins, and an incoming update targeting a locally deleted row is dropped. The row stays gone.
- Grossly-future stamps are refused. A member is trusted, so arbitration is robustness, not a security boundary; still, a buggy client or broken clock could stamp a row far in the future and win every conflict forever. The receiver bounds an incoming stamp to its own wall clock plus an offline allowance (
MAX_FUTURE_SKEW_MS, 30 days) and refuses to let a grossly-future stamp win or ratchet its clock.
Constraints and foreign keys
A child row can arrive in a changeset whose parent is in a different device's changeset, not yet applied. The child's insert violates a foreign key and is dropped on the first pass. Pull collects every such changeset and retries each once after the first pass over all devices completes, by which point the parent rows exist. If a changeset still violates a foreign key after the retry, it is logged and skipped.
Non-foreign-key constraint conflicts (a uniqueness violation, a CHECK failure) are different: retrying cannot make them valid, so the conflicting rows are omitted, the affected tables are surfaced in ApplyResult::constraint_conflict_tables, and the changeset is not retried.