← Complete Projects

阅读中文版本

Build a Local-First Notes Sync Server

A complete sync server for local-first notes: version vectors, conflict detection, merge strategy, incremental sync, and divergence tests.

advanced~6 hsynclocal-firstnodepostgresqlconflict-resolution

Project goal

Sync notes between devices while offline, detect when two devices edited the same note, and merge safely instead of silently overwriting.

Final features

  • Version vector
  • Incremental pull
  • Push with base version
  • Deterministic merge
  • Cursor

Architecture

Each note carries a version vector and an updated-at timestamp. The client sends changes since its last sync cursor; the server returns changes and reports conflicts where neither version dominates. Conflicts are resolved by a deterministic merge rule and surfaced to the user.

Prerequisites

  • Node.js 20 LTS or later
  • PostgreSQL 15+
  • Two clients for the divergence test

Dependencies

  • pg ^8.12.0 — Note storage and change log
  • express ^4.21.0 — Sync endpoints

Directory structure

notes-sync/
├── src/pull.ts
├── src/push.ts
├── src/conflict.ts
├── src/cursor.ts
├── tests/sync.test.ts
└── package.json

Phases

1. Version vector

Track per-device counters to compare note versions.

const vv = { [deviceId]: previous[deviceId] + 1 };

Expected behavior: A vector says whether one version causally dominates another.

2. Incremental pull

Return only changes after the client cursor.

SELECT * FROM changes WHERE seq > $1 ORDER BY seq LIMIT 500;

Expected behavior: Full pulls do not scale with note count.

3. Push with base version

Accept a change only if the base matches the server version or is a concurrent edit.

if (base.equals(server.version)) return apply(change); return conflict(change);

Expected behavior: The base version detects concurrent edits.

4. Deterministic merge

Resolve concurrent text edits with a stable rule and record both.

const merged = deterministicMerge(local, remote);

Expected behavior: A deterministic rule keeps devices convergent.

5. Cursor

Persist the last change sequence the client saw.

await db.setCursor(clientId, lastSeq);

Expected behavior: A cursor makes sync resumable after interruption.

Testing

Edit the same note on two simulated devices offline, sync both, and assert a conflict is reported and the merged result is identical on both.

Troubleshooting

Devices never converge

The merge rule depended on wall-clock time, which differs across devices; use a deterministic tiebreak.

Sync loop from echo

The server echoed back the client change as a new change; record the origin device and skip it on pull.

Deployment

Keep the change log append-only and compact it periodically. Clients must be able to retry idempotently by change id, so deduplicate on the server.

Known limitations

  • Text notes only; no attachments.
  • Merge is line-based, not semantic.
  • No end-to-end encryption.

Project overview

Syncing notes between devices is a distributed-systems problem disguised as a to-do list feature. This build detects when two devices edited the same note without seeing each other’s change and merges deterministically instead of silently overwriting one.

You will track a version vector and an update time per note, let a client pull changes since its last cursor, accept pushes relative to a known base version, and report a conflict when neither version dominates.

The conflict is surfaced rather than hidden. A sync that quietly picks a winner will lose a user’s writing, and no amount of clever merging makes that acceptable unless the user can see and correct it.

The merge rule is deterministic so every device arrives at the same result from the same inputs, which is what makes the eventual state consistent without a central authority deciding who wins.

Each phase adds one property: version vectors, incremental pull, base-version push, deterministic merge, and a cursor, so sync is usable and correct at every stage.

The project is for developers building an offline-first application who have discovered that the hard part is not storing notes locally but reconciling them later. It keeps the reconciliation rule explicit and testable, which is what makes the sync trustworthy.

It is also a small, complete example of eventual consistency: there is no coordinator deciding the truth, only a rule that every participant applies to the same set of inputs.

What you will build

The service provides five capabilities, each with a test that simulates offline devices:

  • Version vector
  • Incremental pull
  • Push with base version
  • Deterministic merge
  • Cursor

A version vector records what each device has seen; incremental pull returns only changes since a cursor; push names the base version it edited; the merge rule combines concurrent edits deterministically; and the cursor lets a client resume without re-downloading history.

The design assumes devices are offline for long periods and that the network is unreliable, so every operation is idempotent and safe to retry by change id.

Architecture and data model

Each note carries a version vector and an updated-at timestamp. The client sends changes since its last sync cursor; the server returns changes and reports conflicts where neither version dominates. Conflicts are resolved by a deterministic merge rule and surfaced to the user.

Each note carries a version vector and an updated-at time, and each change is recorded in an append-only log with an id. Pulling returns log entries after the client’s cursor, so a device only receives what it has not seen.

A push names the base version the client edited; if the server has moved past that base, the push is a concurrent edit and a conflict is detected rather than applied blindly.

Conflicts are resolved by a deterministic rule, such as a three-way merge on the content with a field-level fallback, so every device computing the result arrives at the same bytes.

The cursor is a monotonic position in the log, so a client can resume after a disconnect without replaying the whole history, and retries are deduplicated on the change id.

A single timestamp is insufficient because two devices can edit within the same second and still be concurrent; the version vector captures the causal relationship rather than an order that clocks cannot guarantee.

Because the log is append-only and each entry has an id, a retried push is deduplicated rather than applied twice, which is what makes the protocol safe on an unreliable network.

Prerequisites and dependencies

You need a store for the append-only change log and the current note state, plus a way for clients to keep a cursor locally.

  • Node.js 20 LTS or later
  • PostgreSQL 15+
  • Two clients for the divergence test

The dependencies cover the HTTP surface, the log store, and the merge logic:

  • pg (^8.12.0) — Note storage and change log
  • express (^4.21.0) — Sync endpoints
pnpm init
pnpm add pg express
pnpm add -D typescript tsx vitest

Keep the merge rule in code rather than in configuration, because a change to the rule must be applied consistently across every client and every stored version; document it as a contract.

Directory structure

Separate the version vector, the change log, incremental pull, base-version push, and merge so each is testable with two simulated devices:

notes-sync/
├── src/pull.ts
├── src/push.ts
├── src/conflict.ts
├── src/cursor.ts
├── tests/sync.test.ts
└── package.json

Phase 1: Version vector

Track per-device counters to compare note versions.

const vv = { [deviceId]: previous[deviceId] + 1 };

A vector says whether one version causally dominates another.

Record a version vector per note so the server can tell which edits are concurrent rather than sequential. A single timestamp is not enough, because clocks disagree and two edits within the same second can be genuinely concurrent.

Phase 2: Incremental pull

Return only changes after the client cursor.

SELECT * FROM changes WHERE seq > $1 ORDER BY seq LIMIT 500;

Full pulls do not scale with note count.

Pull only entries after the client cursor, so a device that has been offline for a week does not download the entire history. The cursor is a position, not a timestamp, which avoids the clock-skew problems a time-based cursor invites.

Phase 3: Push with base version

Accept a change only if the base matches the server version or is a concurrent edit.

if (base.equals(server.version)) return apply(change); return conflict(change);

The base version detects concurrent edits.

Accept a push relative to a base version and reject it as a conflict when the base is stale. Naming the base explicitly is what turns a blind overwrite into a detected conflict.

Phase 4: Deterministic merge

Resolve concurrent text edits with a stable rule and record both.

const merged = deterministicMerge(local, remote);

A deterministic rule keeps devices convergent.

Merge concurrent edits with a deterministic rule and store the merged result as a new change in the log, so every device converges on the same state by replaying the same entries.

Phase 5: Cursor

Persist the last change sequence the client saw.

await db.setCursor(clientId, lastSeq);

A cursor makes sync resumable after interruption.

Maintain the cursor as a monotonic log position and deduplicate retries by change id, so a flaky connection never produces duplicate changes.

Testing

Edit the same note on two simulated devices offline, sync both, and assert a conflict is reported and the merged result is identical on both.

Edit the same note on two simulated devices while offline, sync both, and assert that a conflict is reported and the merged result is identical on both, which is the property the whole design exists for.

Assert that a pull returns only changes after the cursor, so an incremental sync is bounded by what changed rather than by history size.

Assert that replaying the same change id twice results in one change, because retries are expected in an offline-first system and must be harmless.

Assert that a push from a stale base is detected as a conflict rather than applied, which is the case a naive last-write-wins would silently corrupt.

Simulate the offline period in the test by holding writes on each device and only then syncing, because the concurrency that produces conflicts is exactly the state a single-threaded test tends to avoid.

Assert that the cursor survives a restarted client and that a pull after restart returns only new changes, since a cursor that resets turns every reconnect into a full download.

pnpm vitest run

Deployment and operations

Keep the change log append-only and compact it periodically. Clients must be able to retry idempotently by change id, so deduplicate on the server.

Keep the change log append-only and compact it periodically into a snapshot, so the log does not grow without bound while still supporting cursors.

Make every write idempotent by change id, because a client that cannot tell whether a push succeeded will retry it, and duplicates are the default failure of a flaky network.

Back up the log and the snapshot together, since restoring one without the other breaks the cursor and forces every client to resync from scratch.

Monitor merge rate and conflict rate, because a rising conflict rate indicates devices are diverging longer than expected and may point at a client bug.

Compact the log into a snapshot on a schedule and keep the cursor valid across compaction, since a compaction that invalidates cursors forces every client to resync and is felt immediately as a bandwidth spike.

Roll out merge-rule changes with a version marker on the server and in the client, because two devices applying different rules to the same concurrent edits will not converge.

Security considerations

Authorize every note by owner and reject changes to notes the caller does not own. Never trust a client version vector without validating device identity.

Authorize every pull and push against the owning user, because a cursor is a position in a log that may contain entries for several notes.

Validate the change id and base version as server-issued values rather than trusting the client to be honest about its own causal history.

Bound the size of a change and the number of entries a single pull can return, so a client cannot request the whole history in one call.

Keep note content out of logs, since a notes title and body are personal data and a sync log is otherwise a copy of everything users wrote.

Authorize each change against the note it belongs to, because a cursor is a position in a log and does not by itself establish ownership.

Observability

Track sync latency, conflict rate, and change-log growth. Alert on a rising conflict rate, which may indicate a client bug producing spurious edits.

Track conflict rate, merge duration, and cursor lag, because together they show whether devices are staying close or drifting apart.

Chart the age of the oldest cursor that still pulls, since a device that never advances is either abandoned or stuck, and both are worth knowing.

Measure push retries per client, because a high retry rate usually means the client is timing out on a slow merge rather than experiencing a network problem.

Log the outcome of each merge without the content, so a convergence bug can be diagnosed from the log shape alone.

Track the number of changes per sync and the merge duration, because a rising change count means clients are staying offline longer or the log is not being compacted.

Chart the distribution of cursor lag across clients, because a long lagging tail is usually a subset of clients that cannot persist their cursor rather than a server problem.

Troubleshooting

Devices never converge. The merge rule depended on wall-clock time, which differs across devices; use a deterministic tiebreak.

Sync loop from echo. The server echoed back the client change as a new change; record the origin device and skip it on pull.

If two devices never converge, compare the merge inputs first, because a rule that depends on local state will produce different results on each device.

If a client resyncs from scratch repeatedly, check whether the cursor is persisted across restarts, since an ephemeral cursor forces a full pull on every launch.

If a push is rejected as a conflict every time, check the base version the client sends, because a client that never updates its base will be permanently stale.

Limitations and next steps

  • Text notes only; no attachments.
  • Merge is line-based, not semantic.
  • No end-to-end encryption.

The merge rule is text-oriented; structured notes with independent fields would benefit from a field-level merge that the example approximates but does not fully implement.

The log is compacted by a periodic snapshot rather than continuously, so a client with a very old cursor may need a full resync after compaction.

There is no server-side conflict UI; the conflict is reported to the client, which must present it, so the user experience depends on the client implementation.

The example supports a single device identity per client and does not model a user with many devices cooperatively editing one note beyond the vector.

Conflict resolution is deterministic but not semantically aware, so two edits to the same sentence are merged by position rather than by meaning, which can produce a valid but awkward result.

The build does not attempt character-level merging, so a truly fine-grained collaborative editor would need a richer model than this one provides.