Search selfstore
v1.8.21

API: sync and merge

The merge engine, exposed as pure functions. The narrative version is multi-device sync and how sync works; this page is the surface.

Most apps only ever touch SyncConfig, through SimpleOptions.sync. The rest is here because the engine being callable is what makes its behaviour testable rather than magic.

SyncConfig

interface SyncConfig {
  idField?: string;                            // default: 'id'
  ids?: Record<string, string>;                // per-collection id field
  strategies?: Record<string, MergeStrategy>;  // per-collection strategy
  fallback?: MergeStrategy;
}
const store = await selfstore('app', {
  sync: {
    ids: { people: 'uuid' },
    strategies: { tags: 'grow-set', settings: 'lww-map' }
  }
});

The five strategies

Strategy Behaviour Use it for
lww-set A set of records; the later edit of a record wins, deletes propagate The default. Lists of things.
lww-map Field-level last-write-wins within a record Documents two devices edit in different fields
grow-set Records are added, never removed by merge Append-only logs, tags
lww-register The whole collection is one value; the later write replaces it Single-value settings
manual No automatic merge; conflicts are surfaced, nothing is chosen Data where losing a side is unacceptable

lww-map is the one worth knowing about. Under lww-set, two devices editing different fields of the same record means one edit is dropped - the later record wins whole. Under lww-map, both survive, because the clock is kept per field (ColMeta.fields).

Metadata

type Hlc = string;   // hybrid logical clock
type Id = string;

interface SyncMeta {
  node: string;
  clock: Hlc | null;
  cols: Record<string, ColMeta>;
}

interface ColMeta {
  clocks: Record<Id, Hlc>;
  deleted: Record<Id, Hlc>;
  hashes: Record<Id, number>;
  fields?: Record<Id, Record<string, [Hlc, number]>>;   // lww-map only
}

deleted is why a removal propagates instead of resurrecting: a delete carries a clock, so a device that never saw the record still knows the deletion is newer than its copy.

There is no CRDT runtime here. The clocks travel inside the backup file, which is why sync needs no server.

Function Signature
createNode () => string
createMeta (node?: string) => SyncMeta
stamp (meta, collections, config, wallNow?) => SyncMeta

Merging

interface ReplicaState {
  collections: Record<string, unknown[]>;
  meta: SyncMeta;
}

interface MergeResult {
  collections: Record<string, unknown[]>;
  meta: SyncMeta;
  conflicts: Conflict[];
}

interface Conflict {
  collection: string;
  id: Id;
  local?: unknown;
  remote?: unknown;
  kept: 'local' | 'remote';
}
Function Signature Notes
merge (a, b, config, base?) => MergeResult Deterministic: same inputs, same output, on any device
detectConflicts (a, b, config, base?) => Conflict[] The same detection without applying anything
changes (before, after, config) => Record<string, CollectionChanges> What a merge actually moved
interface CollectionChanges {
  added: number;
  updated: number;
  removed: number;
}

A conflict is journaled, not hidden. kept says which side won, and both values ride along, so an app can show what was overwritten or offer to restore it. Auto-resolution without a record is how local-first data quietly loses edits; see sync.

detectConflicts is the one to reach for when you want to warn before merging - a “this will overwrite 3 records” confirmation.

Determinism, and how to test it

merge is pure. Two devices merging the same pair in the opposite order reach the same state, which is what makes serverless sync safe:

import { merge, createMeta, createNode } from 'selfstore/sync';

const cfg = { fallback: 'lww-set' as const };
const ab = merge(a, b, cfg);
const ba = merge(b, a, cfg);
expect(ab.collections).toEqual(ba.collections);

That property is the whole reason there is no server: convergence is a consequence of the function, not of an authority. See testing for wiring this into your own suite.