Conflict resolution
Copy page
Nizhal’s default is commit-order last-write-wins at the row level. The mutator body is the merge function — business logic runs in one server transaction, not in a hidden resolver.
Merge modes
Section titled “Merge modes”Set per table via schema metadata:
import { pgTable, text } from "@nizhal/kernel";
// Default: whole-row LWW by server commit orderconst notes = pgTable("notes", { id: text("id").primaryKey(), body: text("body") });
// Field-level merge: each column resolves independentlyconst docs = { table: pgTable("docs", { id: text("id").primaryKey(), title: text("title") }), merge: "field" as const,};merge | Behavior |
|---|---|
"lww" | Whole-row LWW by server commit order (default) |
"field" | Per-column merge; scalars use HLC timestamps, CRDT columns merge structurally |
"crdt" | Column-level Yjs-backed map/text inside a field-merge table |
Helpers: schemaMergeMode, schemaMergePolicy, crdtText, crdtMap from @nizhal/kernel.
LWW (default)
Section titled “LWW (default)”Two offline edits to the same row converge to whichever mutator commits last on the server. Design domains to avoid this:
- Append-only ledgers — conflicts become additive entries (see First app)
- Single-writer fields — partition by actor or use field merge
Field merge
Section titled “Field merge”With merge: "field", scalar columns compare HLC-ordered timestamps (createHlcClock in kernel). Each column wins independently — useful for profile fields edited on different devices.
CRDT columns
Section titled “CRDT columns”import { pgTable, text, crdtText, crdtMap } from "@nizhal/kernel";
const collab = { table: pgTable("collab_docs", { id: text("id").primaryKey(), title: text("title"), body: crdtText("body"), meta: crdtMap("meta"), }), merge: "field" as const,};Client helpers in @nizhal/db-collection: createCrdtText, createCrdtMap, applyCrdtUpdate, encodeCrdtUpdate.
Observability
Section titled “Observability”NizhalObserver.onConflict fires when merge policies resolve competing writes — wire metrics or logs there.