Skip to content

Local-First Architecture Series V: Bidirectional Sync & Conflict Resolution

Posted on:December 18, 2025

Welcome, Developer!

In Part 4, we implemented the Write Pipeline: a local-first mechanism that logs mutations in a Write-Ahead Log (WAL), applies them optimistically to local state, and uploads them when connectivity allows.

At this point, the system can write safely while offline.

However, a local-first system is not complete until it can safely merge reality from multiple replicas.

This part of our series focuses on the second half of the problem:

By the end of this part, your app will:

Does that sound convincing enough to you? Let’s get going!

The Core Problem: Concurrent Reality

Consider two devices, both offline:

Both changes are valid. Both are intentional.

When connectivity returns, there is no universally correct answer.

Your app must:

  1. Preserve user intent
  2. Avoid silent data loss
  3. Resolve conflicts deterministically
  4. Produce the same final state on every replica

This is the essence of conflict resolution.

Mental Model: Actions, Not Documents

Local-first systems do not sync full documents. They sync actions.

From Part 4, every mutation is captured in the WAL as an immutable record.

You must be questioning β€œWhat is a WAL”? πŸ€”

What Is a WAL (Write-Ahead Log) in a Local-First System?

In a local-first architecture, the Write-Ahead Log (WAL) is not a database optimization.

It is the core coordination primitive of the entire system.

A WAL is an append-only, ordered log of user intent. Every mutation a user performs is first recorded in the WAL before it is applied to local state or sent to the server.

This guarantees that no intent is ever lost, regardless of crashes, restarts, or network failures.

WAL β‰  Event Log β‰  Audit Log

It is important to clarify what the WAL is not:

The WAL exists for correctness, not observability.

Its job is to make the system replayable.

WAL as a Log of Intent

Instead of storing β€œwhat the document looks like,” the WAL stores what the user tried to do. Example:

{
  id: "a1f3",
  entityId: "note-42",
  type: "update",
  payload: { title: "Groceries" },
  clientId: "device-B",
  logicalTime: 1042
}

This entry does not say: β€œThe title is Groceries.”. Instead, it says:

β€œAt logical time 1042, device B intended to update the title to Groceries.”

That distinction is critical. Intent-based logs allow the system to:

Why the WAL Is Append-Only

The WAL is immutable by design. Once an action is written:

This gives you great guarantees:

If local state becomes corrupted, it can be re-derived entirely from WAL.

WAL and Materialized State

Local state (what the UI reads from) is derived data.

The WAL is the source of truth.

The relationship is one-way:

WAL ──▢ Reconciliation ──▢ Materialized State

This is why Part 5 can treat reconciliation as a pure function:

state = replay(baseSnapshot, walEntries);

If the WAL is correct and replay is deterministic, the state will always be correct.

Why the WAL Enables Bidirectional Sync

Bidirectional sync works because both client and server speak the same language: actions, not documents.

The server does not need to understand UI state. The client does not need full server snapshots.

They only exchange facts about intent.

This symmetry is what makes the system scalable, testable, and debuggable.

Key Properties to Remember

A WAL in a local-first system must be:

If any of these properties are violated, conflict resolution becomes guesswork.

Sync Phases Overview

Bidirectional sync happens in three explicit phases:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Upload WAL   β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚
β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”
β”‚ Download WAL β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚
β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Reconcile & Resolve β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Each phase is isolated, testable, and idempotent.

The most important rule is - reconciliation and conflict resolution must never live in UI or network code.

Define Contracts

The Rule of Thumb is - if a file imports React, SQLite, Fetch, Axios, or Express, then it is not a contract.

Contracts must remain:

The contract layer is what lets everything else run safely!

WalStore β€” Write-Ahead Log Persistence Contract

The Wal store owns local durability. It does not know about HTTP, sync, or reconciliation.

Create a new file called wal.ts on src/contracts, with this logic:

export interface WalEntry {
  id: string;
  entityId: string;
  type: "create" | "update" | "delete";
  payload: unknown;
  clientId: string;
  logicalTime: number;
  uploaded?: boolean;
}
 
export interface WalStore {
  // Append a new local action
  append(entry: WalEntry): Promise<void>;
 
  // Append remote actions (idempotent)
  appendMany(entries: WalEntry[]): Promise<void>;
 
  // All actions, ordered by logical time
  getAll(): Promise<WalEntry[]>;
 
  // Actions not yet uploaded
  getPending(): Promise<WalEntry[]>;
 
  // Mark actions as successfully uploaded
  markAsUploaded(ids: string[]): Promise<void>;
 
  // Clear acknowledged entries (optional compaction)
  compact?(untilLogicalTime: number): Promise<void>;
}

ApiClient β€” Network Boundary Contract

ApiClient is intentionally boring. It does not contain business logic, retries, or state.

On contracts/api.ts:

export interface ApiClient {
  get<T>(
    path: string,
    options?: { params?: Record<string, unknown> }
  ): Promise<T>;
 
  post<T>(path: string, body?: unknown): Promise<T>;
}

It makes sync code testable, allows mocking without magic, keeps retry logic outside domain logic.

SyncCursor β€” Progress & Causality Tracking

The cursor tracks how far the client has observed global history. This is a persistence contract, not a storage decision.

On contracts/sync.ts:

import { WalEntry } from "./wal";
 
export interface SyncCursor {
  // Highest logical time observed from remote actions
  lastSeenLogicalTime: number;
 
  // Persist cursor state
  save(): Promise<void>;
 
  // Load cursor state
  load(): Promise<void>;
 
  // Advance cursor safely
  advance(newValue: number): void;
}
 
export interface SyncResponse {
  actions: WalEntry[];
  newCursor: number;
}

Domain - Domain as Contract

It defines the shared meaning of the system.

It contains the domain types and action definitions that every layer agrees on: UI, WAL, sync, server, and tests.

This file does not contain implementations. It describes what exists and what kinds of actions are valid, not how they are applied.

On contracts/domain.ts:

// contracts/domain.ts
export type ClientId = string;
export type LogicalTime = number;
 
// Notes domain (example used throughout Part 5)
export type NoteId = string;
 
export interface Note {
  id: NoteId;
  title: string;
  body?: string;
  deleted?: boolean;
  updatedAt: LogicalTime;
}
 
export type NotesState = Record<NoteId, Note>;
 
/**
 * Domain actions represent user intent (meaning), not transport concerns.
 * These are the payloads your WAL stores and your sync exchanges.
 */
export type NoteAction =
  | {
      type: "create";
      id: NoteId;
      payload: {
        title: string;
        body?: string;
      };
      logicalTime: LogicalTime;
    }
  | {
      type: "update";
      id: NoteId;
      payload: Partial<{
        title: string;
        body?: string;
      }>;
      logicalTime: LogicalTime;
    }
  | {
      type: "delete";
      id: NoteId;
      logicalTime: LogicalTime;
    };
 
/**
 * Domain rule contracts
 */
export type ApplyNoteAction = (
  state: NotesState,
  action: NoteAction
) => NotesState;
 
/**
 * Optional: if you want reconciliation itself treated as a domain-level contract.
 * Often reconcile() lives in /sync as β€œpure computation”, but it can be defined here
 * so both client and server share the same signature.
 */
export type ReconcileNotes = (
  base: NotesState,
  actions: NoteAction[]
) => NotesState;
 
/**
 * Optional: invariants checker signature (useful for asserts in tests/CI).
 */
export type ValidateNotesState = (state: NotesState) => void;

Notes Domain

Now that we have defined the contracts, we must adjust the domain/notes/notesRepository.ts file accordingly:

// src/domain/notes/notesRepository.ts
import { getDb } from "@/src/db/database";
import * as Crypto from "expo-crypto";
 
import type {
  ClientId,
  LogicalTime,
  Note,
  NoteAction,
  NoteId,
} from "@/src/contracts/domain";
import type { WalEntry, WalStore } from "@/src/contracts/wal";
 
/**
 * Notes repository is a persistence adapter:
 * - Reads the materialized Notes table for UI
 * - Writes local materialized state optimistically
 * - Appends domain actions to the WAL for sync
 *
 * It does NOT:
 * - call the network
 * - implement reconciliation
 * - decide conflict rules
 */
export class NotesRepository {
  constructor(
    private readonly walStore: WalStore,
    private readonly clientId: ClientId,
    private readonly now: () => number = () => Date.now()
  ) {}
 
  async getAllNotes(): Promise<Note[]> {
    const db = await getDb();
    const rows = await db.getAllAsync<Note>(
      "SELECT id, title, body, deleted, updatedAt FROM notes ORDER BY updatedAt DESC"
    );
    return rows;
  }
 
  async createNote(title: string, body: string): Promise<Note> {
    const db = await getDb();
 
    const id: NoteId = `note_${this.now()}_${Math.random()
      .toString(36)
      .slice(2, 9)}`;
    const logicalTime: LogicalTime = this.now();
 
    // Domain action (meaning)
    const action: NoteAction = {
      type: "create",
      id,
      payload: { title, body },
      logicalTime,
    };
 
    // 1) Materialize locally (optimistic UI)
    const note: Note = {
      id,
      title,
      body,
      deleted: false,
      updatedAt: logicalTime,
    };
 
    await db.runAsync(
      `INSERT INTO notes (id, title, body, deleted, updatedAt)
       VALUES (?, ?, ?, ?, ?)`,
      [
        note.id,
        note.title,
        note.body ?? null,
        note.deleted ? 1 : 0,
        note.updatedAt,
      ]
    );
 
    // 2) Append to WAL (durable intent)
    await this.appendToWal(id, action, logicalTime);
 
    return note;
  }
 
  async updateNote(
    id: NoteId,
    data: { title?: string; body?: string }
  ): Promise<void> {
    const db = await getDb();
    const logicalTime: LogicalTime = this.now();
 
    const action: NoteAction = {
      type: "update",
      id,
      payload: { ...data },
      logicalTime,
    };
 
    // 1) Materialize locally (optimistic UI)
    await db.runAsync(
      `UPDATE notes
       SET title = COALESCE(?, title),
           body  = COALESCE(?, body),
           updatedAt = ?,
           deleted = COALESCE(deleted, 0)
       WHERE id = ?`,
      [data.title ?? null, data.body ?? null, logicalTime, id]
    );
 
    // 2) Append to WAL (durable intent)
    await this.appendToWal(id, action, logicalTime);
  }
 
  async deleteNote(id: NoteId): Promise<void> {
    const db = await getDb();
    const logicalTime: LogicalTime = this.now();
 
    const action: NoteAction = {
      type: "delete",
      id,
      logicalTime,
    };
 
    // 1) Materialize locally: prefer tombstone, not hard delete (safer for sync)
    await db.runAsync(
      `UPDATE notes
       SET deleted = 1,
           updatedAt = ?
       WHERE id = ?`,
      [logicalTime, id]
    );
 
    // 2) Append to WAL (durable intent)
    await this.appendToWal(id, action, logicalTime);
  }
 
  private async appendToWal(
    entityId: NoteId,
    action: NoteAction,
    logicalTime: LogicalTime
  ) {
    const entry: WalEntry = {
      id: Crypto.randomUUID(),
      entityId,
      type: action.type, // "create" | "update" | "delete"
      payload: action, // store the domain action
      clientId: this.clientId,
      logicalTime,
    };
 
    await this.walStore.append(entry);
  }
}

Phase 1: Upload Pending Actions

Uploading is straightforward because the WAL is already ordered and immutable.

On sync/upload.ts:

import type { WalStore, WalEntry } from "@/src/contracts/wal";
import type { ApiClient } from "@/src/contracts/api";
 
export async function uploadPendingActions(
  walStore: WalStore,
  api: ApiClient
): Promise<void> {
  // 1) Read pending WAL entries (append-only; ordered at write time or in query)
  const pending = await walStore.getPending();
 
  if (pending.length === 0) return;
 
  // 2) Upload as a batch (server must treat as idempotent by action id + client id)
  await api.post<void>("/sync/actions", { actions: pending });
 
  // 3) Mark uploaded locally (so retries don't resend everything forever)
  await walStore.markAsUploaded(pending.map((a) => a.id));
}

Important guarantees:

The server stores actions keyed by (clientId, actionId).

Phase 2: Download Missing Actions

Downloading is the symmetric operation of fetching intent the client has not seen yet.

This is done using a high-water mark cursor: a monotonic value that represents progress through global history.

On sync/download.ts:

import type { ApiClient } from "@/src/contracts/api";
import type { SyncCursor, SyncResponse } from "@/src/contracts/sync";
 
export async function downloadMissingActions(
  api: ApiClient,
  cursor: SyncCursor
): Promise<SyncResponse> {
  // Ask: β€œgive me everything since my last seen logical time”
  return api.get<SyncResponse>("/sync/actions", {
    params: { since: cursor.lastSeenLogicalTime },
  });
}

Phase 3: Reconciliation

Reconciliation is the step where a local-first system turns β€œfacts” (WAL entries from all replicas) into β€œtruth” (the materialized state the UI reads).

It is deliberately designed as pure computation:

Given the same inputs, reconciliation must always return the same output. That property is what guarantees convergence: every device reaches the same final state after syncing the same set of actions.

Reconciliation has three responsibilities:

On src/applyAction.ts, it adapts a WalEntry into a domain action (keeps reconcile generic):

// sync/applyAction.ts
import type { NoteAction } from "@/src/contracts/domain";
import type { WalEntry } from "@/src/contracts/wal";
 
/**
 * Convert a WAL entry payload into a domain action.
 * Keep this adapter small and pure.
 *
 * If you later support multiple entity types, this is where you route
 * based on entity type or schema.
 */
export function toNoteAction(entry: WalEntry): NoteAction {
  // You can add runtime validation here (e.g., Zod).
  return entry.payload as NoteAction;
}

On src/reconcile.ts:

// sync/reconcile.ts
import type { ApplyNoteAction, NotesState } from "@/src/contracts/domain";
import type { WalEntry } from "@/src/contracts/wal";
import { toNoteAction } from "./applyAction";
 
/**
 * Deterministic ordering:
 * 1) logicalTime (causality)
 * 2) clientId (tie-breaker across replicas)
 * 3) entry id (final tie-breaker for absolute stability)
 */
function compareWal(a: WalEntry, b: WalEntry): number {
  if (a.logicalTime !== b.logicalTime) return a.logicalTime - b.logicalTime;
 
  const byClient = a.clientId.localeCompare(b.clientId);
  if (byClient !== 0) return byClient;
 
  return a.id.localeCompare(b.id);
}
 
/**
 * Reconcile WAL entries into the final NotesState.
 * Pure function: same inputs => same output.
 */
export function reconcileNotes(
  base: NotesState,
  entries: WalEntry[],
  apply: ApplyNoteAction
): NotesState {
  const ordered = [...entries].sort(compareWal);
 
  let state = structuredClone(base);
 
  for (const entry of ordered) {
    const action = toNoteAction(entry);
    state = apply(state, action);
  }
 
  return state;
}

Deterministic Replay

The rule is simple: The same inputs must always produce the same output.

We achieve this by:

This guarantees that every replica, regardless of network timing or arrival order, converges to the same final state.

Phase 4: Conflict Resolution Rules (Domain Policy)

Conflicts are not β€œbugs” in a local-first system. They are a normal outcome of independent replicas accepting valid user intent while disconnected.

The point of conflict resolution is not to find a universally correct answer.

It is to enforce a system guarantee that given the same set of actions, every replica must compute the same final state.

That guarantee comes from two ingredients working together:

Ordering ensures all replicas replay the same sequence. Policy ensures that when actions disagree, we resolve them consistently.

Example Policy for Notes

For a notes app, a simple and predictable policy is:

  1. Delete wins over update If a note has been deleted (tombstoned), later updates should not β€œresurrect” it unless you explicitly model an undelete action.
  2. Last-writer-wins at the field level If two replicas update the same field concurrently, the later action (by logical time) wins for that field.
  3. Deterministic tie-breaking If two actions share the same logical time, the system must still pick a winner consistently. We do that during replay by sorting with (logicalTime, clientId, entryId).

This is not the only valid policy, but it is deterministic, easy to test, easy to explain to users.

Domain logic

These rules belong in the domain, not in sync code. Sync decides when to replay actions. Domain decides what they mean.

That’s why the rule function consumes a domain action (NoteAction), not a storage/transport record (WalEntry).

On domain/notes/notes.ts:

// src/domain/notes/notes.ts
import type { NoteAction, NotesState } from "@/src/contracts/domain";
 
/**
 * Notes domain rules.
 *
 * This file defines what actions mean and how conflicts are resolved.
 * Pure, deterministic, and side-effect free:
 * - no SQLite
 * - no network
 * - no Date.now()
 * - no randomness
 */
export function applyNoteAction(
  state: NotesState,
  action: NoteAction
): NotesState {
  const existing = state[action.id];
 
  switch (action.type) {
    case "create": {
      // Idempotent create: if it already exists, ignore.
      if (existing) return state;
 
      return {
        ...state,
        [action.id]: {
          id: action.id,
          title: action.payload.title,
          body: action.payload.body,
          deleted: false,
          updatedAt: action.logicalTime,
        },
      };
    }
 
    case "update": {
      // Updates do not create or resurrect notes.
      if (!existing || existing.deleted) return state;
 
      // Field-level merge. Ordering during replay decides last-writer-wins.
      return {
        ...state,
        [action.id]: {
          ...existing,
          ...action.payload,
          updatedAt: action.logicalTime,
        },
      };
    }
 
    case "delete": {
      // Delete wins over everything by tombstoning the record.
      if (!existing) return state;
 
      return {
        ...state,
        [action.id]: {
          ...existing,
          deleted: true,
          updatedAt: action.logicalTime,
        },
      };
    }
  }
}
 
/**
 * Optional invariant checks (useful in tests / CI).
 * Throwing here is acceptable because it's still pure (no external side effects).
 */
export function assertValidNotesState(state: NotesState): void {
  for (const [id, note] of Object.entries(state)) {
    if (note.id !== id) {
      throw new Error(
        `Invariant violated: state key ${id} does not match note.id ${note.id}`
      );
    }
    if (typeof note.title !== "string") {
      throw new Error(`Invariant violated: note ${id} title must be a string`);
    }
    if (typeof note.updatedAt !== "number") {
      throw new Error(
        `Invariant violated: note ${id} updatedAt must be a number`
      );
    }
  }
}

This design guarantees:

Phase 4: Coordinate

Is the orchestration layer that runs the full sync cycle end-to-end.

It is the only place where the phases are sequenced:

  1. Upload pending WAL entries
  2. Download missing WAL entries
  3. Append downloaded actions to the WAL
  4. Advance/persist the cursor
  5. Reconcile deterministically
  6. Materialize or publish the new state

It contains workflow, not business rules.

On sync/coordinator.ts:

// sync/coordinator.ts
import type { ApiClient } from "@/src/contracts/api";
import type { ApplyNoteAction, NotesState } from "@/src/contracts/domain";
import type { SyncCursor, SyncResponse } from "@/src/contracts/sync";
import type { WalStore } from "@/src/contracts/wal";
 
import { downloadMissingActions } from "./download";
import { reconcileNotes } from "./reconcile";
import { uploadPendingActions } from "./upload";
 
export interface SyncDependencies {
  walStore: WalStore;
  api: ApiClient;
  cursor: SyncCursor;
 
  // domain
  applyNoteAction: ApplyNoteAction;
 
  // state
  getBaseSnapshot: () => Promise<NotesState>;
  publishState: (state: NotesState) => Promise<void>;
}
 
export async function runSyncCycle(deps: SyncDependencies): Promise<void> {
  const {
    walStore,
    api,
    cursor,
    applyNoteAction,
    getBaseSnapshot,
    publishState,
  } = deps;
 
  // Ensure cursor is loaded (no-op if already loaded)
  await cursor.load();
 
  // Phase 1: Upload local intent
  await uploadPendingActions(walStore, api);
 
  // Phase 2: Download missing intent
  const response: SyncResponse = await downloadMissingActions(api, cursor);
 
  // Append facts to WAL (idempotent)
  await walStore.appendMany(response.actions);
 
  // Advance cursor and persist progress
  cursor.advance(response.newCursor);
  await cursor.save();
 
  // Phase 3: Deterministic reconciliation
  const base = await getBaseSnapshot();
  const allEntries = await walStore.getAll();
 
  const finalState = reconcileNotes(base, allEntries, applyNoteAction);
 
  // Publish to UI / persist materialized view
  await publishState(finalState);
}

The client appends these actions to its local WAL.

Running the Sync Cycle in the App

Up to this point, we have defined what synchronization does and how it remains correct.

What we have not covered yet is how this logic is actually executed at runtime.

That responsibility belongs to runSyncCycle function.

What runSyncCycle Is (and Is Not)

runSyncCycle is the orchestration entry point of the sync engine.

It is responsible for:

It is not:

Think of it as a sync engine: pure coordination, no framework assumptions.

We already saw the implementation in sync/coordinator.ts.

What matters conceptually is its shape:

runSyncCycle({
  walStore,
  api,
  cursor,
  applyNoteAction,
  getBaseSnapshot,
  publishState,
});

This design is intentional:

This makes the sync engine portable and deterministic.

Wiring Real Dependencies

In a real app, you do not want to pass dependencies around everywhere.

Instead, you create a thin runtime wrapper that wires concrete implementations into runSyncCycle.

Create a file src/sync/index.ts:

// src/sync/index.ts
import { applyNoteAction } from "@/src/domain/notes/notes";
import { runSyncCycle } from "./coordinator";
 
import type { ApiClient } from "@/src/contracts/api";
import type { NotesState } from "@/src/contracts/domain";
import type { SyncCursor } from "@/src/contracts/sync";
import type { WalStore } from "@/src/contracts/wal";
 
/**
 * Runtime entry point for synchronization.
 * Wires real infrastructure into the pure sync engine.
 */
export async function syncNow(deps: {
  walStore: WalStore;
  api: ApiClient;
  cursor: SyncCursor;
 
  getBaseSnapshot: () => Promise<NotesState>;
  publishState: (state: NotesState) => Promise<void>;
}) {
  return runSyncCycle({
    walStore: deps.walStore,
    api: deps.api,
    cursor: deps.cursor,
    applyNoteAction,
    getBaseSnapshot: deps.getBaseSnapshot,
    publishState: deps.publishState,
  });
}

syncNow does no work of its own.

Its only job is to bind domain rules and to bind infrastructure.

Integrating Sync into the UI Layer

Now we need a way to trigger synchronization from the application lifecycle.

This is where a hook (or service) belongs β€” outside the sync engine.

On sync/useSync.ts:

// src/sync/useSync.ts
import { walStore } from "@/src/wal/walStore";
import { useCallback } from "react";
import { syncNow } from ".";
import { api } from "./apiClient";
import { cursor } from "./cursor";
import { loadNotesSnapshot, persistMaterializedNotes } from "./materialize";
 
/**
 * UI-facing sync trigger.
 * Safe to call from effects, events, or lifecycle hooks.
 */
export function useSync() {
  const sync = useCallback(async () => {
    await syncNow({
      walStore,
      api,
      cursor,
      getBaseSnapshot: loadNotesSnapshot,
      publishState: persistMaterializedNotes,
    });
  }, []);
 
  return { sync };
}

Examples where to request the useSync:

The runtime wiring above imports four classes:

If you do not have these yet, the sections below provide minimal, production-safe implementations that compile and match the contracts used throughout this post.

WAL Persistence

This implementation persists WAL entries in SQLite. It is intentionally dumb and reliable:

On src/wal/walStore.ts:

// src/wal/walStore.ts
import type { WalEntry, WalStore } from "@/src/contracts/wal";
import { getDb } from "@/src/db/database";
 
async function ensureWalTables(): Promise<void> {
  const db = await getDb();
 
  await db.runAsync(`
    CREATE TABLE IF NOT EXISTS wal_entries (
      id TEXT PRIMARY KEY,
      entityId TEXT NOT NULL,
      type TEXT NOT NULL,
      payload TEXT NOT NULL,
      clientId TEXT NOT NULL,
      logicalTime INTEGER NOT NULL,
      uploaded INTEGER NOT NULL DEFAULT 0
    )
  `);
 
  await db.runAsync(`
    CREATE INDEX IF NOT EXISTS idx_wal_entries_uploaded
    ON wal_entries (uploaded, logicalTime)
  `);
 
  await db.runAsync(`
    CREATE INDEX IF NOT EXISTS idx_wal_entries_order
    ON wal_entries (logicalTime, clientId, id)
  `);
}
 
function encodePayload(payload: unknown): string {
  return JSON.stringify(payload ?? null);
}
 
function decodeEntry(row: any): WalEntry {
  return {
    id: row.id,
    entityId: row.entityId,
    type: row.type,
    payload: JSON.parse(row.payload),
    clientId: row.clientId,
    logicalTime: row.logicalTime,
    uploaded: row.uploaded === 1,
  };
}
 
class SqliteWalStore implements WalStore {
  private ready: Promise<void> | null = null;
 
  private async ensureReady(): Promise<void> {
    if (!this.ready) this.ready = ensureWalTables();
    await this.ready;
  }
 
  async append(entry: WalEntry): Promise<void> {
    await this.ensureReady();
    const db = await getDb();
 
    await db.runAsync(
      `INSERT INTO wal_entries (
        id, entityId, type, payload, clientId, logicalTime, uploaded
      ) VALUES (?, ?, ?, ?, ?, ?, ?)`,
      [
        entry.id,
        entry.entityId,
        entry.type,
        encodePayload(entry.payload),
        entry.clientId,
        entry.logicalTime,
        entry.uploaded ? 1 : 0,
      ]
    );
  }
 
  async appendMany(entries: WalEntry[]): Promise<void> {
    await this.ensureReady();
    if (entries.length === 0) return;
 
    const db = await getDb();
    await db.runAsync("BEGIN");
 
    try {
      for (const entry of entries) {
        // Idempotency: ignore duplicates by primary key
        await db.runAsync(
          `INSERT OR IGNORE INTO wal_entries (
            id, entityId, type, payload, clientId, logicalTime, uploaded
          ) VALUES (?, ?, ?, ?, ?, ?, ?)`,
          [
            entry.id,
            entry.entityId,
            entry.type,
            encodePayload(entry.payload),
            entry.clientId,
            entry.logicalTime,
            entry.uploaded ? 1 : 0,
          ]
        );
      }
 
      await db.runAsync("COMMIT");
    } catch (e) {
      await db.runAsync("ROLLBACK");
      throw e;
    }
  }
 
  async getAll(): Promise<WalEntry[]> {
    await this.ensureReady();
    const db = await getDb();
 
    const rows = await db.getAllAsync<any>(
      `SELECT *
       FROM wal_entries
       ORDER BY logicalTime ASC, clientId ASC, id ASC`
    );
 
    return rows.map(decodeEntry);
  }
 
  async getPending(): Promise<WalEntry[]> {
    await this.ensureReady();
    const db = await getDb();
 
    const rows = await db.getAllAsync<any>(
      `SELECT *
       FROM wal_entries
       WHERE uploaded = 0
       ORDER BY logicalTime ASC, clientId ASC, id ASC`
    );
 
    return rows.map(decodeEntry);
  }
 
  async markAsUploaded(ids: string[]): Promise<void> {
    await this.ensureReady();
    if (ids.length === 0) return;
 
    const db = await getDb();
    await db.runAsync("BEGIN");
 
    try {
      for (const id of ids) {
        await db.runAsync(`UPDATE wal_entries SET uploaded = 1 WHERE id = ?`, [
          id,
        ]);
      }
      await db.runAsync("COMMIT");
    } catch (e) {
      await db.runAsync("ROLLBACK");
      throw e;
    }
  }
 
  async compact(untilLogicalTime: number): Promise<void> {
    await this.ensureReady();
    const db = await getDb();
 
    // Optional compaction strategy:
    // keep only entries newer than a watermark, but only those already uploaded
    await db.runAsync(
      `DELETE FROM wal_entries
       WHERE uploaded = 1 AND logicalTime <= ?`,
      [untilLogicalTime]
    );
  }
}
 
export const walStore: WalStore = new SqliteWalStore();

Transport Adapter

This provides a concrete implementation of the ApiClient contract using fetch.

On src/sync/apiClient.ts:

// src/sync/apiClient.ts
import type { ApiClient } from "@/src/contracts/api";
 
const BASE_URL = process.env.API_BASE_URL ?? "";
 
async function request<T>(args: {
  method: "GET" | "POST";
  path: string;
  params?: Record<string, unknown>;
  body?: unknown;
}): Promise<T> {
  const url = new URL(args.path, BASE_URL);
 
  if (args.params) {
    for (const [key, value] of Object.entries(args.params)) {
      if (value === undefined || value === null) continue;
      url.searchParams.set(key, String(value));
    }
  }
 
  const res = await fetch(url.toString(), {
    method: args.method,
    headers: { "Content-Type": "application/json" },
    body: args.body ? JSON.stringify(args.body) : undefined,
  });
 
  if (!res.ok) {
    const text = await res.text().catch(() => "");
    throw new Error(
      `API ${args.method} ${args.path} failed: ${res.status} ${text}`
    );
  }
 
  // Some endpoints return no body
  if (res.status === 204) return undefined as T;
 
  return (await res.json()) as T;
}
 
export const api: ApiClient = {
  get: (path, options) =>
    request({ method: "GET", path, params: options?.params }),
  post: (path, body) => request({ method: "POST", path, body }),
};

Cursor Persistence

This stores the high-water mark in SQLite. It uses a single-row table keyed by id = 1.

On src/sync/cursor.ts:

// src/sync/cursor.ts
import type { SyncCursor } from "@/src/contracts/sync";
import { getDb } from "@/src/db/database";
 
class SqliteSyncCursor implements SyncCursor {
  lastSeenLogicalTime = 0;
 
  async load(): Promise<void> {
    const db = await getDb();
 
    await db.runAsync(`
      CREATE TABLE IF NOT EXISTS sync_cursor (
        id INTEGER PRIMARY KEY CHECK (id = 1),
        lastSeenLogicalTime INTEGER NOT NULL
      )
    `);
 
    const row = await db.getFirstAsync<{ lastSeenLogicalTime: number }>(
      "SELECT lastSeenLogicalTime FROM sync_cursor WHERE id = 1"
    );
 
    if (!row) {
      this.lastSeenLogicalTime = 0;
      await db.runAsync(
        "INSERT INTO sync_cursor (id, lastSeenLogicalTime) VALUES (1, 0)"
      );
      return;
    }
 
    this.lastSeenLogicalTime = row.lastSeenLogicalTime ?? 0;
  }
 
  advance(newValue: number): void {
    this.lastSeenLogicalTime = Math.max(this.lastSeenLogicalTime, newValue);
  }
 
  async save(): Promise<void> {
    const db = await getDb();
    await db.runAsync(
      "UPDATE sync_cursor SET lastSeenLogicalTime = ? WHERE id = 1",
      [this.lastSeenLogicalTime]
    );
  }
}
 
export const cursor: SyncCursor = new SqliteSyncCursor();

Materialization

Your UI reads from the notes table, which is your materialized view. After reconciliation, you need to publish the reconciled state into that table.

This file provides two functions that match the imports used by useSync():

On src/sync/materialize.ts:

// src/sync/materialize.ts
import { getDb } from "@/src/db/database";
import type { NotesState } from "@/src/contracts/domain";
 
export async function loadNotesSnapshot(): Promise<NotesState> {
  // Tutorial baseline:
  // use an empty snapshot and derive truth from WAL replay.
  // You can later upgrade this to real snapshots for performance.
  return {};
}
 
export async function persistMaterializedNotes(
  state: NotesState
): Promise<void> {
  const db = await getDb();
 
  await db.runAsync("BEGIN");
  try {
    // Minimal, correct approach:
    // replace the materialized view entirely.
    // Later you can optimize to UPSERT/diff-based publishing.
    await db.runAsync("DELETE FROM notes");
 
    for (const note of Object.values(state)) {
      await db.runAsync(
        `INSERT INTO notes (id, title, body, deleted, updatedAt)
         VALUES (?, ?, ?, ?, ?)`,
        [
          note.id,
          note.title,
          note.body ?? null,
          note.deleted ? 1 : 0,
          note.updatedAt,
        ]
      );
    }
 
    await db.runAsync("COMMIT");
  } catch (e) {
    await db.runAsync("ROLLBACK");
    throw e;
  }
}

With these files in place, the useSync() snippet compiles as-is, and you have a complete runtime path:

What You Learned in This Part

In this part, you learned how to complete a real local-first system by making offline writes converge safely across devices.

Local-first is not about avoiding the server β€” it is about designing for disagreement.

By treating conflicts as first-class, replaying intent deterministically, and enforcing clear domain policy, you build systems that remain correct even when networks, devices, and time disagree.

In the next part, we will focus on observability and operational excellence β€” because a system you cannot observe is a system you cannot trust.

Conclusion

Local-first is not about being offline. It is about building systems that remain correct when reality disagrees.

Keep focused, Developer!