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:
- Uploading pending local actions
- Downloading authoritative server state
- Reconciling both sides deterministically
- Resolving conflicts in a predictable, testable way
By the end of this part, your app will:
- Support bidirectional sync
- Merge concurrent edits deterministically
- Guarantee convergence across devices
- Be testable without flaky network assumptions
Does that sound convincing enough to you? Letβs get going!
The Core Problem: Concurrent Reality
Consider two devices, both offline:
- Device A edits Note #45:
title = "Shopping" - Device B edits the same note:
title = "Groceries"
Both changes are valid. Both are intentional.
When connectivity returns, there is no universally correct answer.
Your app must:
- Preserve user intent
- Avoid silent data loss
- Resolve conflicts deterministically
- 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:
- It is not a debug log
- It is not an analytics stream
- It is not a history view for users
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:
- Replay actions deterministically
- Merge concurrent edits
- Resolve conflicts by policy
- Guarantee convergence across replicas
Why the WAL Is Append-Only
The WAL is immutable by design. Once an action is written:
- It is never edited
- It is never reordered
- It is never deleted (only marked as acknowledged)
This gives you great guarantees:
- Crashes cannot corrupt history
- Sync can resume safely after interruption
- Reconciliation can always be recomputed from first principles
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 StateThis 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.
- Upload: client sends WAL entries
- Download: client receives WAL entries
- Reconciliation: both sides replay the same log
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:
- Append-only
- Ordered
- Intent-preserving
- Replayable
- Deterministic
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:
- Framework-agnostic
- Side-effect free
- Stable
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:
- Actions are uploaded in order
- Upload is retryable
- The server must treat uploads as idempotent
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:
- No network calls
- No database reads/writes
- No clocks (Date.now())
- No randomness
- No side effects
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:
- Normalize and order the WAL entries deterministically
- Extract the domain action from each WAL entry
- Replay those actions using the domainβs rule function (applyNoteAction)
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:
- Merging all WAL entries (local and remote) into a single stream
- Sorting them deterministically by (logicalTime, clientId, entryId)
- Replaying them in order from a known base snapshot using pure domain rules.
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:
- Deterministic ordering (reconciliation)
- Explicit domain policy (apply rules)
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:
- Delete wins over update If a note has been deleted (tombstoned), later updates should not βresurrectβ it unless you explicitly model an undelete action.
- 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.
- 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:
- Convergence Every replica replays the same ordered actions and reaches the same state.
- Intent preservation Even losing actions remain in the WAL for replay and debugging.
- Auditability The WAL contains the full history of intent. (Not user-facing history, but replay history.)
- Testability
applyNoteActionandreconcileNotesare pure functions. You can unit test them without SQLite, network, or flakiness.
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:
- Upload pending WAL entries
- Download missing WAL entries
- Append downloaded actions to the WAL
- Advance/persist the cursor
- Reconcile deterministically
- 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:
- Sequencing the sync phases
- Wiring together storage, network, and domain rules
- Producing a reconciled state and publishing it
It is not:
- A React hook
- A background task
- A UI concern
- A domain rule implementation
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:
- All side effects are injected
- No globals are assumed
- The engine can run in any environment (foreground, background, retry loop)
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:
- After app resumes (foreground)
- On connectivity regained (if you already track it)
- Manual βSync nowβ button during development
The runtime wiring above imports four classes:
walStore(local durability for WAL entries)api(the concreteApiClientimplementation)cursor(the concreteSyncCursorpersistence)materializefunctions (loadNotesSnapshot,persistMaterializedNotes)
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:
append()is durable and append-onlyappendMany()is idempotent (replaying the same remote entry does not duplicate it)getPending()returns entries whereuploaded = 0markAsUploaded()flips the flag locally after the server accepts the batch
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():
loadNotesSnapshot()returns a base snapshot (for this post we start from{}and replay WAL)persistMaterializedNotes()writes the reconciled state to SQLite in a transaction
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:
- UI calls
useSync() useSync()callssyncNow()syncNow()callsrunSyncCycle()runSyncCycle()uploads, downloads, reconciles, and publishes state back to SQLite
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!