Conflicts and merging¶
A write made offline is sent later, and by then another device may have changed the same row. Name a table's version column, and the library sends a queued update or delete of one of its rows only while the server's row still has the version this device saw. When it does not, the library reads the row again, sees which columns moved on each side, and lets your resolver settle the conflict, or holds the write for the user. A table without a version column keeps the rule it had: the last write wins.
This page follows one write through each step, from the moment it is queued to the moment the server takes it, then plays one conflict through from two phones. The short reference, and the other ways a replay can fail, are on Failures and the rejected list.
Opting in¶
The configuration names the column, the server's trigger moves it, and every read that selects it carries it into the cache, where the queue finds it:
flowchart LR
C["versionColumn: tasks, updated_at"] --> Q[("Mutation queue")]
U["Any update of a row"] --> T["The table's trigger"]
T --> V["updated_at moves"]
V -- "a read that selects it brings it" --> K[("Query cache")]
K -- "the version this device saw" --> Q
From the samples: SampleSupabaseClient.kt · ClientSettings.swift
// The column the server moves on every change (moddatetime): a queued update or delete of a
// row is sent only while the server's row still has the version this device saw, so a change
// made elsewhere meanwhile is found, not overwritten. None is the default: the last write wins.
versionColumn(Tables.TASKS, UPDATED_AT)
versionColumn(Tables.TASK_PHOTOS, UPDATED_AT)
// Settles what it finds; null, the default, keeps this device's change of a row that moved.
conflictResolver = holdTitleConflicts
// The column the server moves on every change (moddatetime): a queued update or delete
// of a row is sent only while the server's row still has the version this device saw,
// so a change made elsewhere meanwhile is found, not overwritten. None is the default:
// the last write wins.
versionColumns: [Tables.tasks: "updated_at", Tables.taskPhotos: "updated_at"],
// Settles what it finds; nil, the default, keeps this device's change of a row that moved.
conflictResolver: holdTitleConflicts,
On the server, either kind of column works, as long as every update changes it:
create extension if not exists moddatetime with schema extensions;
alter table public.tasks
add column if not exists updated_at timestamptz not null default now();
create or replace trigger tasks_set_updated_at
before update on public.tasks
for each row execute function extensions.moddatetime(updated_at);
alter table public.tasks
add column if not exists version bigint not null default 1;
create or replace function public.tasks_bump_version()
returns trigger
language plpgsql
set search_path = ''
as $$
begin
new.version := old.version + 1;
return new;
end;
$$;
create or replace trigger tasks_bump_version
before update on public.tasks
for each row execute function public.tasks_bump_version();
The column is the server's. The library never writes it and compares it for equality only, as text:
it sends back exactly the string the server sent, whatever its offset or precision. A trigger is
what makes it a version, since a value the app sends in an update is overwritten. Without one, a row
that moved cannot be told from one that did not, and every replay goes through as before.
versionColumn(table, column) and the versionColumns map are keyed as primaryKeys is (tasks,
or schema.table in another schema); a blank column, or one of the table's key columns, fails
createSupabaseClient with IllegalArgumentException (Swift: OfflineError with
.configuration). moddatetime stamps the transaction's start time, so a row updated twice in one
server-side transaction keeps one version: use the integer there (Project
setup). The samples' schema already has
the trigger on both tables (Set up Supabase).
What the queue records¶
When an update or a delete is queued, the queue looks for the row it targets among the cached lists and keeps the version it finds there, with the columns the write sets:
flowchart LR
W["A queued UPDATE or DELETE"] --> V{"Version column?"}
V -- no --> N["Unguarded: sent as made"]
V -- yes --> K{"Pins one key?"}
K -- no --> N
K -- yes --> F{"Names the version?"}
F -- yes --> N
F -- no --> C{"Row cached with the column?"}
C -- no --> N
C -- yes --> B["Guarded: base version and base columns kept"]
| Kept with the write | What it holds |
|---|---|
| The base version | The version column's value in a cached copy of the row that holds the column, as text. PendingMutation.baseVersion shows it; null means the write is not guarded |
| The base columns | That copy's values of the columns the write sets, and its version; for a DELETE, the version alone |
A filter pins one row when it names every key column with eq (filter { eq("id", 42) } for a
table keyed on id); other conditions may sit beside the key. The cached list must include the
version column: a list read with select(Columns.list("id", "title")) holds the row without it,
and a write to that row records no base. Read the column in the lists your screens show (the default
select() reads every column). Not guarded, and replayed as they always were: a write that updates
every done task; one whose row no cached list holds, or holds only without the version column; one
to a table with more than 100,000 cached rows, past which the cache keeps no key index to find the
row by; and one queued before the app named the column. So are the writes this page never reaches:
an insert, an upsert, an OnlineOnly write, and a write the queue cannot hold (Writes the queue
cannot hold).
A queued write is first tried at once, when nothing of its user waits ahead of it, and that first send is the request you built, unchanged. The base matters when the write waits: offline, behind another write, or when that send may not have run.
Writes queued one after another do not conflict with each other. When a guarded write, or a
write sent at once that asked for its rows back, returns the row, every later queued write to that
row by the same user takes the returned version and columns as its base. Only the same user's: on
a shared device, another user's queued write to the row keeps the base its user saw, and its replay
meets the change as a conflict. A write sent at once without select() returns no row to forward,
so the next write meets an old version; once the first write's answer has been patched into the
cache, the next write's base holds its values, and it is resent at the new version without asking
anyone (below). The exception is a write queued while an earlier write to
the same row is still on the wire without select(): its base is the row from before that
write, so if both set one column to different values, the replay meets the earlier write as a
change made elsewhere, and your resolver is asked. Call select() on such writes: the returned row
then forwards its version to the writes behind it. A Realtime event or applyServerRecord never
moves a base: a base is what this device's user saw, and a change made elsewhere has to show as a
conflict.
What a replay sends¶
When a sync pass replays a guarded write, the write carries its base as one more filter and asks for its row back:
flowchart LR
Q[("The write and its base")] --> S["Stored filter, plus version = base"]
S --> P["PostgREST"]
P -- "the row" --> A["Applied: the row cached, its version forwarded"]
P -- "an empty list" --> R["Read the row by its key"]
The update of task 42, made when the device had version 2026-10-18T13:00:27.486123+00:00:
PATCH /rest/v1/tasks?id=eq.42&updated_at=eq.2026-10-18T13:00:27.486123+00:00&select=id,updated_at,title,done
Prefer: return=representation
{"title":"Buy oat milk","done":true}
The request lines on this page are shown decoded; on the wire Ktor percent-encodes the colons, the
plus and the commas (%3A, %2B, %2C), and PostgREST decodes them, so the server reads back
exactly the string it sent. It asks back for the key, the version and the columns it sets, never
*, so a column the user cannot read elsewhere in the row cannot turn it into a refusal; the row it
returns must still pass the table's SELECT policy, as for any write with select(). Its answer is
one of two, both 200:
- The row, with the trigger's new version: the write applied. The row is written into every cached list that holds it, so a read after the replay shows it once, at the server's version, and its version is forwarded to the writes the same user queued behind it.
- An empty list: no row matched. The server has moved the row, deleted it, hidden it, or the filter missed; the answer does not say which, so the library reads the row by its key, from the network only:
A guarded DELETE asks back for the key and the version only, and its returned row is not written into the cache, which already hides the deleted row.
The five outcomes¶
What the read by key finds decides what happens to the write. Two of its questions need a word
first. A write's filter can hold conditions beyond the key (eq("id", 42) and eq("done", false)),
so the row may no longer meet the rest of it. And PostgREST answers an empty list, never an error,
when row-level security hides a row from an update. So an empty answer while the row is still at
the base version and still meets the filter can only mean a policy refused the write, which goes to
the rejected list.
flowchart TD
Z["An empty list back"] --> R["Read the row by its key"]
R -- "no connection" --> S["The pass stops, the write stays queued"]
R -- "no row" --> G["Gone"]
R -- "a row" --> F{"Meets the filter?"}
F -- no --> M["Synced: nothing to change"]
F -- yes --> V{"At the base?"}
V -- yes --> D["A policy denies it: the rejected list"]
V -- no --> L{"Values already there?"}
L -- yes --> A["Already on the server"]
L -- no --> C{"Its columns moved?"}
C -- "none did" --> P["Stale base: resent at the new version"]
C -- "some did" --> X["A conflict: the resolver"]
| The read finds | An UPDATE | A DELETE |
|---|---|---|
| No connection, or the session changed | The pass stops and the write stays queued, as on any connectivity failure | The same |
| No row: deleted, or hidden from the user by a policy | Gone: a conflict with server = null. The default, KeepServer, drops the write and counts it in conflictsKeptServerCount; nothing is recreated |
Already gone: synced, counted in alreadyOnServerCount |
| A row the rest of the write's filter no longer reaches | Synced, as the unguarded replay's empty answer was | The same |
| The row at the base version, still meeting the filter | A policy denies it: the write moves to the rejected list, with no serverError and nothing resent |
The same |
| Another version, already holding every value the write sets | Already applied: an earlier send landed and its answer was lost. Synced, counted in alreadyOnServerCount, the row cached and forwarded |
— |
| Another version, none of the write's columns changed since the base | A stale base, typically your own earlier write: resent at the new version without the resolver, and not counted as a conflict | — |
| Another version, otherwise | Moved: a conflict, settled as the next section says | Moved: always a conflict, with local = null and both column sets empty, so an app can hold a delete of a row someone edited. The default, KeepLocal, deletes it |
The policy denial is declared only when every other condition of the write's filter holds on the
row as the overlay evaluates conditions (eq, neq, in, is). One it cannot judge (like,
gt, a JSON path) at the base version reads as the filter missing, the empty answer the unguarded
replay had; on a moved row the resend carries the whole filter, so the server judges it. No row and
a row hidden by a policy look the same from the device, which is why a gone row is never
recreated by default.
The resolver¶
A moved row is compared column by column, as the device saw it, as the write sets it and as the server has it:
flowchart TD
B["base: what this device saw"] --> CS["changedOnServer"]
S["server: the row now"] --> CS
CS --> CC["conflictingColumns"]
L["local: what the write sets"] --> CC
CC --> R{"Resolver"}
R -- "KeepLocal or Apply" --> RS["Resent at the server's version"]
RS -- "moved again, up to three sends" --> R
R -- "KeepServer" --> K["Dropped, the server's row cached"]
R -- "Hold, a throw, or 30 s" --> H["Parked on the conflicted list"]
changedOnServeris the columns the write sets whose server value differs from the base: what changed elsewhere.conflictingColumnsis those of them the write sets to another value than the server's: where the two changes disagree. A column both sides set to the same value is changed but not conflicting.- The resolver is shown a
MutationConflict: the write'smutationId,table,operation,rowKey(the key values as the filter pins them, as text),base,local(nullfor a DELETE),server(nullwhen gone), and the two sets.serverholds the key, the version, the columns the write sets and the columns its filter names, not the whole row. - It answers a
ConflictResolution:
| Resolution | What happens |
|---|---|
KeepLocal |
The write is sent again, preconditioned on the server's version now: an UPDATE sets its columns over the server's, and the others keep the server's values; a DELETE deletes. On a gone row it cannot apply, so the write is parked |
ConflictResolution.apply(columns) |
These columns are sent instead, preconditioned the same way: for a merge the app works out. Only for an UPDATE of a row still there; otherwise the write is parked. It needs at least one column |
KeepServer |
The write leaves the queue unsent. The server's values for the columns the read asked for are laid into the cached row, which keeps the version the device saw (so a later write to it still meets the move); a gone row is dropped from the cache |
Hold |
The write is parked on the conflicted list for later, the server's values are laid into the cached row as for KeepServer when the read found a row, and the pass goes on with the next write |
A resend that finds the row moved again is a new conflict, and the resolver is asked again; a write
is sent at most three times in one pass, and if the third send still finds the row moved, or an
update's row gone, the write is parked without asking. Any other ending of the third send (the row
applied, a lost answer, a policy denial) is the usual one. The resolver is called during the pass,
outside every database transaction, on the library's own dispatcher, never the one your
syncPendingMutations() call runs on, while the pass waits for it: answer promptly, and never wait
for a sync from inside it. Anything it throws but a cancellation, and 30 seconds without an answer,
read as Hold, logged at WARNING with the table and the failure's type only. In Swift the
resolver is an async throws closure run in a task of its own, off the caller's actor; one that
runs past the 30 seconds keeps running, and its late answer is dropped.
Without a resolver, every conflict is settled as KeepLocal, but for an UPDATE of a row that
is gone, which is settled as KeepServer. An UPDATE sends only the columns it sets, so the default
is the last write winning column by column: a title changed here and a done flag ticked elsewhere
both survive, and of two titles, this device's wins. The samples' resolver holds a title both sides
changed for the user to choose, and settles everything else as the default does:
From the samples: SampleSupabaseClient.kt · ClientSettings.swift
/**
* Holds a conflict on a task's title for the user to settle on the Sync tab, and settles every
* other one as the plugin would without a resolver: this device's change wins a row that moved, and
* an edit of a row deleted elsewhere is dropped.
*/
internal val holdTitleConflicts = ConflictResolver { conflict ->
when {
TITLE in conflict.conflictingColumns -> ConflictResolution.Hold // both sides changed it
conflict.server == null -> ConflictResolution.KeepServer // gone from the server
else -> ConflictResolution.KeepLocal // sent again over the server's row as it is now
}
}
/// Holds a conflict on a task's title for the user to settle on the Sync tab, and settles every
/// other one as the library would without a resolver: this device's change wins a row that
/// moved, and an edit of a row deleted elsewhere is dropped.
static let holdTitleConflicts: @Sendable (MutationConflict) async throws -> ConflictResolution = { conflict in
if conflict.conflictingColumns.contains("title") { return .hold } // both sides changed it
return conflict.server == nil ? .keepServer : .keepLocal
}
The conflicted list¶
A parked write waits on the conflicted list until the app settles it:
stateDiagram-v2
[*] --> Queued
Queued --> Parked: Hold, a throw, 30 s, a third move, or no version
Parked --> Queued: resolved with KeepLocal or Apply, under a new id
Parked --> [*]: resolved with KeepServer, or dismissed
Queued --> [*]: sent and applied
conflictedMutations() is the current user's entries as a Flow<List<ConflictedMutation>> (Swift:
a ConflictedMutationSequence), oldest first, kept on the device across restarts.
conflictedMutationList() reads the same list once, as the flow's first list. Each entry holds the
write's id, table, operation, createdAt, rowKeys and body; the base; the server row
and its serverVersion when the replay read it; changedOnServer and conflictingColumns; a
message saying why it was parked; and conflictedAt. Besides the resolver's Hold, a write is
parked when the resolver throws or takes 30 seconds, when its third send still finds the row moved,
and when the server's row comes back without a version to precondition the resend on.
resolveConflictedMutation(id, KeepLocal), or withConflictResolution.apply(columns), queues the write again at the end of the user's queue, under a new id, preconditioned on the entry'sserverVersion, and asks for a pass. Its replay is an ordinary guarded replay: when the row comes back, the write counts insyncedCountalone; when the row has moved again since, the resolver is asked, as for any conflict.resolveConflictedMutation(id, KeepServer)drops it, and shows the server's side in the cache as the resolver's ownKeepServerdoes: a gone row is dropped from the cached lists; the columns the entry recorded are laid into the cached row, without the version, while that row is still as the device saw it before the write (an entry a build before this one parked left it so), and a row fetched since is left as it is. Either way the lists holding the row are fetched again when they can be, since the row may have moved while the entry waited.dismissConflictedMutation(id)drops the entry alone.- While a write is parked, the cached row shows the server's values for the columns the replay
read, under the version the device saw: the write is on the conflicted list, not laid over the
cache, so a read shows neither the device's change nor the values the device last read. The
version stays, so a later write to the row still meets the move. A write parked because its row
was gone leaves the cached row as it was, since a row that is gone and one a policy hides look
the same from the device;
KeepServeron the entry then drops it. Holdis refused withIllegalArgumentException, as areApplyon anything but an UPDATE of a row that was there, andKeepLocalon a row that was gone or on an entry whose body was too large to keep.- Only the current user's entries are listed and changed, as with the rejected list, and
clearLocalData()empties it. A later queued write to the same row replays as usual and meets the same check. - Its bounds are the rejected list's: the newest 100 entries of every user together, within
512 K characters; a body over 16 K characters is not kept (
Applystill works on that entry,KeepLocaldoes not), and texts are kept to 4 K characters. When it is full the oldest entries go, and their writes are lost.
The samples show each entry on the Sync tab under Conflicting changes, with both values of each conflicting column, and Keep mine and Keep theirs:
From the samples: LibraryMappings.kt · OfflineTaskRepository.kt · QueueWording.swift · TaskRepository.swift
/**
* A parked write as the Sync tab shows it: which task, and for each column both sides changed, the
* server's value beside this device's.
*/
internal fun ConflictedMutation.toConflictedTaskMutation(): ConflictedTaskMutation {
val mine = body as? JsonObject // the columns this device sets; null for a delete
return ConflictedTaskMutation(
id = id,
table = table,
operation = operation.toTaskMutationOperation(),
rowKeys = rowKeys.mapNotNull { it.idOrNull() },
taskTitle = body?.titleOrNull() ?: server?.titleOrNull(), // the server row's otherwise
columns =
conflictingColumns.sorted().map { column ->
ColumnConflict(
column = column,
theirs = server?.get(column)?.asText(),
mine = mine?.get(column)?.asText(),
)
},
changedOnServer = changedOnServer,
createdAt = createdAt,
conflictedAt = conflictedAt,
// Keep mine needs the server's version, and the body for an update.
canKeepMine =
serverVersion != null && (operation == MutationOperation.DELETE || body != null),
)
}
/**
* The writes parked because their task changed on the server meanwhile (the client's resolver
* holds a title both sides changed), newest first: the library lists the oldest first.
*/
override fun conflictedMutations(): Flow<List<ConflictedTaskMutation>> =
offline.conflictedMutations().map { list ->
list.asReversed().map { it.toConflictedTaskMutation() }
}
/**
* Keep mine queues the write again, preconditioned on the server's row as the conflict saw it,
* and asks for a pass; Keep theirs drops it, and the server's row stands on the device too.
*/
override suspend fun resolveConflictedMutation(id: Long, choice: ConflictChoice) {
val resolution =
when (choice) {
ConflictChoice.KeepMine -> ConflictResolution.KeepLocal
ConflictChoice.KeepTheirs -> ConflictResolution.KeepServer
}
try {
offline.resolveConflictedMutation(id, resolution)
} catch (cannotKeep: IllegalArgumentException) {
throw cannotKeep.toAppFailure(LibraryCall.Sync) // the row is gone, or too long to keep
}
}
/// What a parked write is about: "“Buy milk” changed in two places".
static func conflictHeadline(_ conflict: ConflictedMutation) -> String {
"“\(title(conflict.taskTitle))” changed in two places"
}
/// Each column both sides changed, with both values: "Title: “Buy oat milk” on the server,
/// “Buy milk” on this device". A delete changes no column, so it says so instead.
static func conflictingColumns(_ conflict: ConflictedMutation) -> [String] {
guard conflict.operation != .delete else { return ["Deleted on this device, changed on the server"] }
var mine: [String: OfflineJSON] = [:]
if case .object(let values)? = conflict.body { mine = values }
return conflict.conflictingColumns.sorted().map { column in
"\(columnLabel(column)): “\(text(conflict.server?[column]))” on the server, “\(text(mine[column]))” on this device"
}
}
/// When a parked write was made and held: "Made 10:41 · held 10:42".
static func conflictTimes(_ conflict: ConflictedMutation) -> String {
let made = conflict.createdAt.formatted(date: .omitted, time: .shortened)
let held = conflict.conflictedAt.formatted(date: .omitted, time: .shortened)
return "Made \(made) · held \(held)"
}
/// The writes parked because their task changed on the server meanwhile, oldest first: the
/// client's resolver holds a title both sides changed, until the user keeps one side.
func conflictedMutations() -> ConflictedMutationSequence {
client.conflictedMutations()
}
/// Keep mine queues the write again, preconditioned on the server's row as the conflict saw
/// it, and asks for a sync; Keep theirs drops it, and the server's row stands on the device
/// too. Either way it leaves the list.
func resolveConflictedMutation(id: Int64, keepMine: Bool) async throws {
try await client.resolveConflictedMutation(id: id, resolution: keepMine ? .keepLocal : .keepServer)
}
What a sync reports¶
| Where | Field | What it counts |
|---|---|---|
SyncReport |
conflictsResolvedCount |
Conflicts this call settled with KeepLocal or Apply, from the resolver or the default, and sent; counted in syncedCount too |
SyncReport |
conflictsKeptServerCount |
Writes this call dropped for the server's row, with KeepServer |
SyncReport |
conflicted |
The ConflictedMutations this call parked, now on conflictedMutations() |
SyncReport |
alreadyOnServerCount |
Of syncedCount, also the guarded writes whose earlier send had landed, and the guarded DELETEs whose row was already gone |
SyncStatus |
conflictedMutationCount |
The current user's entries on the conflicted list, read with the pending and rejected counts, so a write moving between the queue and either list is counted in exactly one |
PendingMutation |
baseVersion |
The version a queued write replays against; null when it is not guarded |
A stale base resent without the resolver counts in syncedCount alone. The library's own passes
report nothing to the app, so read conflictedMutations() or the status count to show a parked
write that an automatic pass found.
What stays last write wins¶
- Upserts. PostgREST has no precondition for an upsert, so a replayed one is sent as made: it overwrites the columns it sets, and recreates a row deleted elsewhere. Use an update for an edit of a row that exists.
- Writes sent at once. The request you built goes out as it is; only a replay is guarded.
- Unguarded writes. An update or delete whose filter pins no single row, names the version
column, or targets a row no cached list holds; a write queued before the table had a version
column; an
OnlineOnlywrite; a write the queue cannot hold; anrpc. - Other tables. A table without a version column replays as before.
- A value several devices move. A counter or a running total written as a number replays the number it set, and merging per column does not change that. Compute it on the server from the facts each device writes.
A running example: two phones, one task¶
Two phones share a task. Phone A reads it, goes offline, and renames it while ticking it done;
Phone B renames it too. The app is the samples' own, with their resolver, and the three versions
are the ones the testing page's staging test
uses: T1 is 2026-10-18T13:00:27.486123+00:00, T2 2026-10-18T13:04:00.000001+00:00, T3
2026-10-18T13:05:00.000002+00:00.
sequenceDiagram
participant A as Phone A
participant B as Phone B
participant P as PostgREST
participant T as The trigger
A->>P: GET tasks
P-->>A: Buy milk, version T1
Note over A: Offline. Renamed to Buy oat milk and ticked done. Queued with base T1
B->>P: PATCH task 42: Buy almond milk, done
P->>T: before update
T-->>P: updated_at is T2
Note over A: Online again. The pass replays the write
A->>P: PATCH task 42 where updated_at is T1
P-->>A: an empty list
A->>P: GET task 42 by its key
P-->>A: Buy almond milk, version T2
Note over A: Title and done changed on the server. Title conflicts. The resolver holds it
Note over A: The user taps Keep mine
A->>P: PATCH task 42 where updated_at is T2
P->>T: before update
T-->>P: updated_at is T3
P-->>A: Buy oat milk, version T3
1. Phone A reads the task. The cached list holds it at T1. Offline, the user renames it to
"Buy oat milk" and ticks it done; the update is queued with its base. PendingMutation.baseVersion
shows the version; the columns the queue keeps beside it are not public:
{"baseVersion": "2026-10-18T13:00:27.486123+00:00",
"base": {"title": "Buy milk", "done": false, "updated_at": "2026-10-18T13:00:27.486123+00:00"}}
2. Phone B renames it to "Buy almond milk" and ticks it done too. The trigger moves
updated_at to T2.
3. Phone A comes back online. The pass sends the update preconditioned on T1, and the server
answers an empty list:
PATCH /rest/v1/tasks?id=eq.42&updated_at=eq.2026-10-18T13:00:27.486123+00:00&select=id,updated_at,title,done
Prefer: return=representation
{"title":"Buy oat milk","done":true}
HTTP/1.1 200 OK
[]
4. The read by key finds the row at T2:
GET /rest/v1/tasks?id=eq.42&limit=1&select=id,updated_at,title,done
HTTP/1.1 200 OK
[{"id":42,"updated_at":"2026-10-18T13:04:00.000001+00:00","title":"Buy almond milk","done":true}]
5. The resolver is shown the conflict. Both columns changed on the server; only the title disagrees, since both phones ticked the task done:
{"mutationId": 7, "table": "tasks", "operation": "UPDATE",
"rowKey": {"id": "42"},
"base": {"title": "Buy milk", "done": false, "updated_at": "2026-10-18T13:00:27.486123+00:00"},
"local": {"title": "Buy oat milk", "done": true},
"server": {"id": 42, "updated_at": "2026-10-18T13:04:00.000001+00:00", "title": "Buy almond milk", "done": true},
"changedOnServer": ["title", "done"],
"conflictingColumns": ["title"]}
The samples' resolver answers Hold for a conflicting title
(The resolver has its code), so the write is parked, the cached list shows
"Buy almond milk", the server's title, under the version the device saw, and the report's
conflicted holds it:
{"id": 7, "table": "tasks", "operation": "UPDATE",
"createdAt": "2026-10-18T13:02:10Z", "rowKeys": [{"id": "42"}],
"body": {"title": "Buy oat milk", "done": true},
"base": {"title": "Buy milk", "done": false, "updated_at": "2026-10-18T13:00:27.486123+00:00"},
"server": {"id": 42, "updated_at": "2026-10-18T13:04:00.000001+00:00", "title": "Buy almond milk", "done": true},
"serverVersion": "2026-10-18T13:04:00.000001+00:00",
"changedOnServer": ["title", "done"], "conflictingColumns": ["title"],
"message": "The row changed on the server since this write was made",
"conflictedAt": "2026-10-18T13:04:30Z"}
6. The Sync tab shows it under Conflicting changes: "“Buy oat milk” changed in two places", with "Title: “Buy almond milk” on the server, “Buy oat milk” on this device" (the mapping and the resolve call are under The conflicted list).
7. The user taps Keep mine. resolveConflictedMutation(7, KeepLocal) queues the write again,
under a new id, with T2 as its base, and the pass sends it:
PATCH /rest/v1/tasks?id=eq.42&updated_at=eq.2026-10-18T13:04:00.000001+00:00&select=id,updated_at,title,done
Prefer: return=representation
{"title":"Buy oat milk","done":true}
HTTP/1.1 200 OK
[{"id":42,"updated_at":"2026-10-18T13:05:00.000002+00:00","title":"Buy oat milk","done":true}]
The row comes back at T3, and the cached list shows it at once. That pass's report counts the
write in syncedCount alone: the conflict was settled by the user, not during the pass. Keep
theirs would have dropped the write and left "Buy almond milk" on both phones.
Three quiet cases¶
On the same task, three other things can have happened while Phone A was offline. In these, Phone A's edit sets the title alone. None asks the user anything:
sequenceDiagram
participant A as Phone A
participant P as PostgREST
Note over A,P: Phone B only ticked the task done
A->>P: PATCH task 42 where updated_at is T1
P-->>A: an empty list
A->>P: GET task 42 by its key
P-->>A: Buy milk, version T2
A->>P: PATCH task 42 where updated_at is T2
P-->>A: Buy oat milk, version T3
Note over A,P: The first send landed, and its answer was lost
A->>P: PATCH task 42 where updated_at is T1
P-->>A: an empty list
A->>P: GET task 42 by its key
P-->>A: Buy oat milk, version T2
Note over A,P: Phone B deleted the task
A->>P: PATCH task 42 where updated_at is T1
P-->>A: an empty list
A->>P: GET task 42 by its key
P-->>A: an empty list
| What happened | What the read shows | What the library does | The report |
|---|---|---|---|
| Phone B ticked it done | The title still "Buy milk": none of the write's columns moved | Resends at T2 without the resolver; Phone B's done and Phone A's title both stand |
syncedCount 1 |
| Phone A's first send landed, its answer lost | The title already "Buy oat milk" | Counts the write as applied, and caches the row | syncedCount 1, alreadyOnServerCount 1 |
| Phone B deleted it | No row | Asks the resolver with server = null; the samples' answers KeepServer, as the default does. The write is dropped, and the task leaves Phone A's list |
conflictsKeptServerCount 1 |
Play it yourself¶
On iOS in demo mode, the Sync tab's Try it → Make a conflict does steps 1 to 5 with the stand-in server playing Phone B. Against a project, and on Android, the second writer is the Table Editor (Try a conflict); the Android sample's tests stage the same steps on their fake server:
From the samples: OfflineTaskRepositoryTest.kt · FakeSupabaseServer.kt · SyncModel.swift · DemoServer.swift
/**
* Stages the tour's conflict through the fake server: a task the list read from the server, its
* title edited to [mine] offline, then changed to [theirs] on the server, then the network back
* and the queue replayed. Returns the entry the app's resolver held.
*/
private suspend fun stageTitleConflict(mine: String, theirs: String): ConflictedTaskMutation {
server.insertRow(Tables.TASKS, serverOnlyTask("Milk"))
repository.tasks().awaitValue { tasks -> tasks.any { it.id == SERVER_TASK_ID } }
repository.setNetworkEnabled(false)
assertIs<TaskMutationOutcome.Queued>(
repository.updateTask(SERVER_TASK_ID, mine, notes = null)
)
server.replaceRow(Tables.TASKS, SERVER_TASK_ID, buildJsonObject { put("title", theirs) })
repository.setNetworkEnabled(true)
repository.syncPendingMutations()
return repository.conflictedMutations().awaitState { it.isNotEmpty() }.single()
}
/**
* Sets [values] on the row of [table] with [id], as another device's update would: the server
* stamps a new `updated_at`, and nothing reaches this client's channel. A device that queued a
* change to the row before this finds it moved when the change replays.
*/
fun replaceRow(table: String, id: String, values: JsonObject) {
synchronized(lock) {
val rows = tables.getValue(table)
val index = rows.indexOfFirst { it["id"]?.jsonPrimitive?.contentOrNull == id }
check(index >= 0) { "No row $id in $table" }
rows[index] = JsonObject(rows[index] + values + (UPDATED_AT to now()))
}
}
/// Make a conflict, in demo mode: the newest task's title edited here offline, the same title
/// changed on the stand-in server meanwhile, then the network back and the queue sent. The
/// queued edit was preconditioned on the version this device saw, so it finds the row moved,
/// and the client's resolver holds it under Conflicting changes.
private func makeConflict(taskID: String?) async throws -> String {
guard let server = demoServer else { return TryItItem.makeConflict.call(isDemo: false) }
guard repository.canReachNetwork else { return "Go online first: the conflict is found when the edit syncs." }
guard let taskID, let row = await server.rows(of: Tables.tasks).first(where: { $0["id"] == .string(taskID) }),
case .string(let title)? = row["title"]
else { return "Add a task and let it sync first: the conflict needs a row the server has." }
var notes: String?
if case .string(let text)? = row["notes"] { notes = text }
// The same few suffixes every run, so running it again does not grow the title; the other
// writer's alternates, so its title always differs from the one the device last saw.
let original = [Self.deviceSuffix, Self.elsewhereSuffix, Self.elsewhereAgainSuffix]
.reduce(title) { $0.replacing($1, with: "") }
let elsewhere = original + (title == original + Self.elsewhereSuffix ? Self.elsewhereAgainSuffix : Self.elsewhereSuffix)
try await repository.setNetworkEnabled(false)
do {
_ = try await repository.updateTaskText(id: taskID, title: original + Self.deviceSuffix, notes: notes)
await server.changeElsewhere(task: taskID, ["title": .string(elsewhere)])
} catch {
try? await repository.setNetworkEnabled(true) // never leave Simulate offline on
throw error
}
try await repository.setNetworkEnabled(true)
let report = try await repository.syncPendingMutations()
// The list, not the report: the automatic pass on reconnecting may have parked it first.
let held = try await repository.conflictedMutations().first()
return held.isEmpty
? "Synced, and no conflict was held: \(Wording.syncReport(report))."
: "Both titles changed: it waits under Conflicting changes, for Keep mine or Keep theirs."
}
/// What Make a conflict appends to the title it sets on this device.
static let deviceSuffix = " (this device)"
/// What Make a conflict appends to the title the stand-in server's other writer sets.
static let elsewhereSuffix = " (elsewhere)"
/// What the other writer appends instead when the title already carries ``elsewhereSuffix``.
static let elsewhereAgainSuffix = " (elsewhere again)"
/// Changes task `id` as another device would: `values` set on the server with a new
/// `updated_at`, and the change reported to the client's channel, never through this client.
/// A write this device queued for the row before finds it moved when it syncs.
func changeElsewhere(task id: String, _ values: [String: OfflineJSON]) {
guard let index = tables[Tables.tasks]?.firstIndex(where: { $0.id == id }) else { return }
tables[Tables.tasks]?[index].row.merge(values) { _, given in given }
tables[Tables.tasks]?[index].row["updated_at"] = .string(stamp())
if let row = tables[Tables.tasks]?[index].row { report(saved: row, in: Tables.tasks) }
}
At a glance¶
- Name a table's version column, and a queued update or delete of one cached row is sent only while the server's row still has the version this device saw.
- The version is the server's: a trigger moves it, and the library compares the text for equality only.
- A write sent at once, an insert, an upsert, and a write that pins no single cached row are not guarded: the last write wins.
- A guarded replay asks for its row back; the row, at its new version, is cached and forwarded to
the writes the same user queued behind it, so edits queued one after another do not conflict
with each other.
An edit queued while an earlier one to the row is on the wire can, unless that one asks for its
row back with
select(). - A list read without the version column leaves the writes to its rows unguarded.
- An empty answer is followed by one read by key, which tells a gone row, a policy denial, a lost answer, a stale base and a moved row apart.
- A moved row is compared column by column: a change elsewhere to other columns is resent without asking, and the rest goes to your resolver.
- Without a resolver, this device's change wins a moved row column by column, and an update of a gone row is dropped; nothing is recreated.
- A resolver that throws or takes 30 seconds holds the write; a write is sent at most three times in one pass, and parked if its row is still moving.
- A held write waits on
conflictedMutations()until the app keeps one side or dismisses it; Keep mine sends it against the server's version as the entry saw it. SyncReportcounts what a sync settled, dropped and parked, andSyncStatuscounts what waits.