Conflicts
Two people edited the same row. What locel calls a conflict, what it does not, and how to encode the answer your product actually wants.
This guide covers what happens when two writers change the same row. You will learn:
- What locel counts as a conflict, and the three common cases it deliberately does not
- Why the default is server-wins, and when to override it
- How to declare a resolution in the schema so both sides agree
- What
mergedmeans, and why it is not an error - How to tell a user what they lost
Status: Built. The API below exists and is under test.
Overview
Two people open the same todo. One renames it; the other ticks it done. Both are offline for a minute. Both come back.
There is a wrong answer that almost every system gives at least once: the second write to arrive replaces the whole row, and the rename disappears with no trace. There is a second wrong answer that looks sophisticated: compare the two timestamps and take the later one, which means the outcome depends on whose laptop clock is right.
locel gives neither. The rename and the tick touched different fields, so both survive and nothing is reported — that is not a conflict and calling it one would train users to ignore the warning. A conflict is narrower than "two people edited a row", and getting that definition right is most of the work.
The definition
Conflict — two writers changing the same field of the same row, where the second writer's base version predates the first writer's write.
Three things follow from it, and each one removes a class of false alarm.
Different fields are never a conflict. The rename and the tick both land. Versions are tracked per field, not per row, so the server can tell them apart.
Your own earlier write is never a conflict. Forty queued edits from an offline session do not fight each other, because the rule asks whether somebody else moved the field.
A base version at or after the other write is not a conflict. If you saw their change before you made yours, you overwrote it knowingly. That is an edit, not a collision.
No clocks appear anywhere in that. The comparison is between two sequence numbers assigned by one server, so it does not matter what any device believes the time is.
The default
Server wins. The field keeps the value that was already committed, your write to that field is discarded, and the transaction is merged rather than rejected — everything else in it lands.
That default is chosen because it is the one that is never surprising. The value on the server is the value other people have already seen; keeping it means no one's screen changes under them because of a write they were not party to.
Declaring a resolution
When server-wins is wrong for a field, say so in the schema:
export const todos = defineCollection({
name: 'todos',
primaryKey: 'id',
fields: {
/* ... */
position: field.number().default(() => Date.now()),
done: field.boolean().default(() => false),
},
writable: ['title', 'note', 'position', 'done'],
resolve: {
/** Dragging is not authoritative. Whoever moved it further up meant it more. */
position: ({ server, client }) => Math.min(server, client),
/** Completion is monotonic. Nobody's tick should be undone by a stale untick. */
done: ({ server, client }) => server || client,
},
})Resolvers live in the schema rather than the server config because they are pure functions of two values, and because both sides need them. The server applies the resolver to decide what is committed; the client applies the same function to predict the outcome while the write is still in flight, so the UI does not flash through a value that was never going to survive. Two implementations of this rule would disagree eventually, and the disagreement would be a row that renders differently from what is stored.
Keep them pure. A resolver that reads the network, the clock, or anything outside its two arguments will produce different answers on the two sides, which is worse than having no resolver at all.
The signature is deliberately narrow:
type Resolver<T> = (values: { server: T; client: T }) => TNo row, no user, no context. A resolution that needs to know who is asking is a write policy — a refusal — not a merge.
Deletes
A delete that collides with an edit is the one case where "resolve it quietly" is usually wrong. Somebody is about to lose a paragraph they just typed, because somebody else pressed a button.
export const todos = defineCollection({
/* ... */
onDeleteConflict: 'reject', // default; 'accept' also available
})With 'reject', an update whose base version predates a concurrent delete is
refused with E_ROW_DELETED, and the client surfaces it — the edit is not
silently swallowed by a tombstone. With 'accept', the delete wins and the edit
is dropped.
Default 'reject' because a delete that interrupts a human should interrupt a
human.
Merged is not an error
This is the distinction to hold on to:
| Outcome | What happened | Client behaviour |
|---|---|---|
| Accepted | Everything applied as sent | Nothing to report |
| Merged | The transaction landed; at least one field resolved against a concurrent write | Row is correct; $conflict is set |
| Rejected | The server refused the whole transaction | Rolled back, removed from the outbox, recorded in replica.rejections |
A merge is the normal outcome of collaboration, so it does not throw and it
does not reject tx.confirmed. The row on screen is right. What is missing is
that the user's edit to one field did not survive, and only you can decide
whether that is worth telling them.
Which means the one genuine mistake here is treating a merge as nothing.
Reading $conflict
todo.$conflict
// {
// transaction: '01J…',
// at: 412, // the sequence number that resolved it
// fields: [
// {
// field: 'title',
// resolution: 'server', // 'server' | 'client' | 'custom'
// yours: 'Ship it today',
// theirs: 'Ship it tomorrow',
// writer: 'w_9f3c…',
// },
// ],
// }yours and theirs are both there because "your change was overwritten" is
almost useless on its own — the user needs to see what replaced it to decide
whether to redo the edit.
Rendering the account a reconnect owes:
const { data: merged } = useLiveQuery((q) =>
q.from({ todo: todosCollection }).where(({ todo }) => todo.$conflict != null)
)
return merged.map((todo) => (
<Notice key={todo.id}>
{todo.$conflict!.fields.map((entry) => (
<p key={entry.field}>
Your {entry.field} “{String(entry.yours)}” was replaced with “{String(entry.theirs)}”.
</p>
))}
<button onClick={() => replica.acknowledge(todo.$conflict!)}>
Got it
</button>
</Notice>
))$conflict persists in the replica until acknowledged. It survives a reload on
purpose: a user who was told about a lost edit by a toast they did not see, and
who then closed the tab, has not been told.
Acknowledging clears it locally and is not sent anywhere. It is a note to yourself about a conversation you have already had.
Sealed fields
An encrypted field can conflict like any other — the server compares versions, not values, so it detects the collision without being able to read either side.
What it cannot do is run a resolver, because that would mean evaluating a
function over plaintext it does not have. Sealed fields are always server-wins,
and declaring a resolve entry for one is a compile error.
What this deliberately is not
Not a CRDT. Two people typing in the same text field do not merge character
by character. title is one value; one of the two writes wins and the other is
reported. If you need collaborative text, put a CRDT document in an
field.json() field and merge it yourself — locel will treat it as one opaque value
and stay out of the way.
Not last-write-wins by time. There is no timestamp comparison anywhere in the merge path.
Not automatic three-way merge. locel knows the two values and which came first. It does not know a common ancestor for anything except the field's own version, and inventing one would be guessing.
Where to go next
- Writing data — refusals, and how they differ from merges
- Collections — where
resolvesits in a declaration - Partitions — write policies, for rules a merge cannot express