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.