Conflict Handling
FlashQL sync is designed around predictable conflict behavior.
It means the system has a clear model for when a local write can still be applied and when it has lost the race to newer authoritative state. What FlashQL doesn't do is automatic merge. That would be bad "magic".
What a Conflict Usually Means
A conflict usually means one of these:
- another client already updated the row
- another client already deleted the row
and so, the origin row version no longer matches the version the view knows.
These scenarios are expected sync outcomes, not mysterious events.
Example Conflict Scenarios
These are the typical cases to keep in mind.
Update vs Update
- two offline clients both update the same row
- one reconnects first to apply upstream
- the second reconnects later
Delete vs Update
- one client deletes while offline
- the other updates the same row and reconnects first
- the earlier delete later tries to apply
Update vs Delete
- one client updates while offline
- the other deletes the same row and reconnects first
- the earlier update later tries to apply
Conflict Handling
FlashQL sync is built around a single rule of thumb:
If two clients both edit the same row, you do not want the latter to silently overwrite the former.
You want the system to say, in effect:
"when a row has evolved past the version you last saw, do not overwrite" "apply this write only if the origin row is still the version you think it is"
That is the heart of FlashQL's conflict model.
The Conflict Detection Model: Row Versioning
A write back to the origin table should succeed only if the origin row is still the exact version the view holds a reference to. Should the origin row be one or more commits ahead of the view, a write back to that row should fail with a conflict error.
For this to work, incoming rows must carry with them a "version" tag into the view. A write back to the origin uses the tag to assert that the row hasn't evolved past the version it holds a reference to.
This "row-version" idea is native to MVCC-based – Multi-Version Concurrency Control – database systems like PostgreSQL and FlashQL. FlashQL sync automatically infers a row's version from the row's internal XMIN tag. Conflict detection becomes automatic.
For non-MVCC-based systems like MySQL, a custom "version" column must be explicitly created and manually managed on the origin table. (The custom "version" column idea is also allowed for MVCC-based setups where that's desired.)
await db.query(`
CREATE TABLE public.blog (
id INT PRIMARY KEY,
content TEXT,
author_name TEXT,
_custom_row_version BIGINT
);
`);This column must be of type BIGINT.
The chosen column name must be passed to the view at creation time:
await db.query(`
CREATE REALTIME VIEW public.users AS
SELECT * FROM public.users
WITH (
replication_origin = 'postgres:primary',
upstream_mvcc_key = '_custom_row_version'
);
`);This tells FlashQL over which column to compare row version.
For non-MVCC-based systems like MySQL, not specifying an upstream_mvcc_key defaults the write behaviour to:
update row
id = 1
Meaning:
"update anyway; last-writer-wins"
With row versioning, that instead becomes:
update row
id = 1only if it is still version13297
That difference is what makes conflict handling predictable.
- conflicts are explicit
- data loss is avoided
- the app can react intentionally
What Happens on Conflict
If the version no longer matches:
- the write operation is rejected
- the write attempt is marked as
conflictedon the view's control plane table - origin state remains authoritative
- the view eventually catches up with the commit that caused the divergence (This happens via an explicit refresh or via inbound sync – in the case of realtime views.)
What Happens on Success
On version equality pass:
- the write operation is applied
- the write attempt is marked as
appliedon the view's control plane table - origin table dispatches a commit event that may echo back to the view, in the case of realtime views
- the view catches up via that event (inbound sync), or via an explicit refresh
How This Relates to Write Policies
Conflict detection works with both write policies, but the user experience differs.
origin_first – the Default
- local writes are queued and dispatched async
- the view's visible state waits until either:
- inbound sync echos back the change – for realtime views
- the view is explicitly refreshed
local_first
- local writes are staged as visible rows immediately
- the row carries
__staged = trueuntil either:- inbound sync echos back the change – for realtime views
- the view is explicitly refreshed
In a conflict scenario, the view self-normalizes on an explicit refresh or via inbound sync.
The difference between the policies is really the view's immediate state until normalization.
Observable Conflict Behavior
db.sync emits a dedicated conflict event for these cases.
That lets applications observe conflict as its own operational category instead of as generic errors. (See Observable Sync Events)
This can be useful for inspection and debugging.
What Conflict Handling Is Not
FlashQL explicitly doesn't do:
- arbitrary semantic merges
- domain-specific reconciliation logic
What it does is narrower and more useful:
- when a replicated writable view has a usable origin version token, FlashQL can detect and classify write races predictably
