Skip to content

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.

Set per table via schema metadata:

import { pgTable, text } from "@nizhal/kernel";
// Default: whole-row LWW by server commit order
const notes = pgTable("notes", { id: text("id").primaryKey(), body: text("body") });
// Field-level merge: each column resolves independently
const docs = {
table: pgTable("docs", { id: text("id").primaryKey(), title: text("title") }),
merge: "field" as const,
};
mergeBehavior
"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.

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

With merge: "field", scalar columns compare HLC-ordered timestamps (createHlcClock in kernel). Each column wins independently — useful for profile fields edited on different devices.

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.

NizhalObserver.onConflict fires when merge policies resolve competing writes — wire metrics or logs there.

See Choosing a conflict strategy.