API Reference

resolveUnifiedConflict()

The write side of the universal conflict register — takes a user's decision on one frozen conflict and either discards the local change, overwrites the server, or sends a merged payload. Updates the register instantly, then executes the decision.

Prerequisites
  • A conflict frame read from getActiveConflicts() or subscribeToConflicts()
  • api/get-active-conflicts for the UnifiedConflictFrame shape this call consumes
1

Basic usage

Call this once per conflict, with the recordId from the UnifiedConflictFrame you're resolving.
typescript
1// Called from your conflict UI once the user picks a side for one
2// specific conflict, identified by recordId from a UnifiedConflictFrame.
3
4await flux.resolveUnifiedConflict(conflict.recordId, 'proceed')
5
6// 'apply_merged' additionally requires the merged payload itself:
7await flux.resolveUnifiedConflict(conflict.recordId, 'apply_merged', {
8 ...conflict.serverState,
9 ...conflict.localEntry.payload,
10})
2

Signature

mergedPayload is only required — and only used — when decision is 'apply_merged'.
typescript
1resolveUnifiedConflict(
2 recordId: string,
3 decision: 'drop' | 'proceed' | 'apply_merged',
4 mergedPayload?: any // required when decision is 'apply_merged'
5): Promise<void>
3

What each decision does

The three decisions map directly onto the same outcomes a per-channel onConflict callback can return during the handshake itself — this is just the developer-driven equivalent, fired later from your own UI.
typescript
1// 'drop' — discard the local offline change entirely.
2// Server's version wins. The queued entry is removed,
3// nothing is sent.
4
5// 'proceed' — send the user's local payload as-is, overwriting
6// whatever's currently on the server.
7
8// 'apply_merged' — send mergedPayload instead of either version —
9// for when the user (or your own merge logic)
10// combined both sides into one final payload.
11// Requires mergedPayload to be passed.
4

Order of operations

The register updates before anything else happens — your conflict panel reflects the resolution instantly, even if the follow-up network call takes a moment to complete.
typescript
1// Order of operations inside resolveUnifiedConflict():
2
3// 1. The frame is removed from the register immediately
4// 2. onConflictUnified fires with the updated (shorter) list —
5// your conflict panel updates right away, before any network call
6// 3. The decision is executed:
7// 'drop' -> removeQueueEntry(recordId)
8// 'proceed' / 'apply_merged' -> writes the resolved payload into
9// the existing IDB queue entry, then
10// calls replayQueue with
11// replayStrategy forced to
12// 'fire_and_forget' — no second
13// handshake probe needed, the
14// conflict is already resolved
Conflicting entries stay frozen, never lost, until this is called

A conflicting entry is never deleted from IDB the moment it's detected — it's held exactly as-is until resolveUnifiedConflict() runs, so there's no window where the user's offline change could be silently lost before they've made a choice.

5

Scoped execution

The follow-up replay this triggers for 'proceed' and 'apply_merged' is scoped the same way every other replay call is — resolved from the target store's own registration, not the active session.
typescript
1// The replay this triggers is scoped exactly like any other replay
2// call — userId is resolved from the TARGET STORE'S OWN registered
3// scope, never unconditionally from whoever is logged in right now.
4
5// A 'global'-scoped queue's conflict resolution always passes null,
6// so it executes correctly regardless of the active session.
7// A 'user'-scoped queue's resolution passes the active userId.
6

Bypasses the replay cooldown

Automatic replay triggers are throttled by a 4-second cooldown to avoid hammering the server from stacked events. A manual resolution is explicit user intent, so it skips that cooldown entirely.
typescript
1// Automatic replay triggers respect a 4s cooldown after each run —
2// but a manual resolveUnifiedConflict() call bypasses it entirely.
3// The user just explicitly acted; there's no reason to make them wait.
4
5await flux.resolveUnifiedConflict(conflict.recordId, 'proceed') // fires immediately
7

Building the resolution UI

A typical panel renders each frame from getActiveConflicts() or useFlux().activeConflicts, and wires each button directly to a decision.
typescript
1function ConflictRow({ conflict }: { conflict: UnifiedConflictFrame }) {
2 const [merging, setMerging] = useState(false)
3
4 return (
5 <div>
6 <p>Yours: {JSON.stringify(conflict.localEntry.payload)}</p>
7 <p>Server's: {JSON.stringify(conflict.serverState)}</p>
8
9 <button onClick={() => flux.resolveUnifiedConflict(conflict.recordId, 'proceed')}>
10 Keep mine
11 </button>
12 <button onClick={() => flux.resolveUnifiedConflict(conflict.recordId, 'drop')}>
13 Keep server's
14 </button>
15 <button
16 onClick={() =>
17 flux.resolveUnifiedConflict(conflict.recordId, 'apply_merged', {
18 ...conflict.serverState,
19 ...conflict.localEntry.payload,
20 })
21 }
22 >
23 Merge both
24 </button>
25 </div>
26 )
27}

For reading the conflicts this call resolves, see api/get-active-conflicts. To discard a queued entry outright without going through a conflict decision, see abandonQueueEntry() in core-concepts/offline-queue.