Skip to content
Alexis-TechnologiesPublic

About

Fast, zero-dependency distributed data sync engine 🌐

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

Β 

History

9 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

syncom

syncom

Fast, zero-dependency distributed data sync engine 🌐

npm CI node dependencies docs license

syncom is a local-first data engine for JavaScript. It stores records on disk β€” or in the browser's origin-private filesystem β€” commits every write to a hash-linked log so tampering is detectable, and replicates every write to peers as an operation that converges without coordination: last-writer-wins registers, per-origin counters, observed-remove sets, lists and text that merge by position, trees whose moves never form a cycle, tombstones. The transport is injected β€” wrpc out of the box β€” and the package has no runtime dependencies.

'use strict';

const { createDatabase } = require('@alexify/syncom');

const main = async () => {
  const storage = await createDatabase({ path: 'data' });

  const id = await storage.insert({ name: 'Ada' });
  const record = await storage.document(id);

  record.on('update', (data, delta) => console.log('changed', delta));

  record.name = 'Ada Lovelace'; // changed { name: 'Ada Lovelace' }
  await record.save();
  await storage.inc(id, 'hits', 1); // a counter: increments add up across nodes

  console.log(await storage.validate({ last: 10 })); // true
};

main();

πŸ“š Documentation Β· Getting started Β· API reference

Table of Contents

Installation

Requires Node.js β‰₯ 22, or a modern browser with OPFS and Web Crypto.

npm install @alexify/syncom

Bundle size

Measured by pnpm size on every CI run. Every browser entry carries a budget that fails the build when exceeded; a subpath is off the main entry, so an app pays for what it imports. The main entry is the core β€” records, the log, queries, versions, the apply path; lists, texts and trees are the text and tree plugins (@alexify/syncom/text, /tree), the identity and signatures, the chain and the sealing of records the signing, chain and encryption plugins (/signing, /chain, /encryption), the sync manager the sync plugin (/sync), and @alexify/syncom/all is the core with every plugin registered (see Entry points).

Entry raw min min+gzip budget
main entry β€” browser (@alexify/syncom) 568.7 KB 220.3 KB 75.4 KB 75.5 KB
main entry β€” node (@alexify/syncom) 543.1 KB 220.1 KB 75.0 KB β€”
wrpc adapter β€” browser (@alexify/syncom/wrpc) 3.8 KB 1.6 KB 0.7 KB 2.0 KB
contracts β€” browser (@alexify/syncom/contracts) 4.2 KB 1.9 KB 1.0 KB 1.0 KB
idb backend β€” browser (@alexify/syncom/idb) 29.1 KB 13.1 KB 4.9 KB 6.0 KB
sqlite engine β€” node (@alexify/syncom/sqlite) 26.2 KB 13.9 KB 4.9 KB β€”
mongodb engine β€” node (@alexify/syncom/mongodb) 33.6 KB 15.8 KB 6.3 KB β€”
engines β€” browser (@alexify/syncom/engines) 58.5 KB 26.5 KB 9.9 KB 10.0 KB
search β€” browser (@alexify/syncom/search) 5.9 KB 2.5 KB 1.1 KB 2.0 KB
order β€” browser (@alexify/syncom/order) 5.0 KB 2.4 KB 1.0 KB 1.5 KB
undo β€” browser (@alexify/syncom/undo) 52.9 KB 24.2 KB 8.8 KB 9.0 KB
branch β€” browser (@alexify/syncom/branch) 22.5 KB 10.9 KB 4.7 KB 5.0 KB
presence β€” browser (@alexify/syncom/presence) 26.0 KB 10.9 KB 4.5 KB 4.8 KB
editor β€” browser (@alexify/syncom/editor) 27.4 KB 12.3 KB 4.6 KB 6.5 KB
codemirror β€” browser (@alexify/syncom/codemirror) 38.3 KB 17.1 KB 6.4 KB 8.0 KB
prosemirror β€” browser (@alexify/syncom/prosemirror) 47.1 KB 20.6 KB 7.8 KB 9.5 KB
store β€” browser (@alexify/syncom/store) 6.3 KB 2.6 KB 1.1 KB 1.5 KB
inspect β€” browser (@alexify/syncom/inspect) 27.2 KB 12.7 KB 5.4 KB 5.5 KB
all β€” browser (@alexify/syncom/all) 1147.4 KB 471.4 KB 162.4 KB 162.5 KB
text β€” browser (@alexify/syncom/text) 279.2 KB 125.6 KB 44.0 KB 44.0 KB
tree β€” browser (@alexify/syncom/tree) 46.8 KB 22.5 KB 8.6 KB 9.0 KB
signing β€” browser (@alexify/syncom/signing) 33.7 KB 16.0 KB 6.6 KB 6.8 KB
chain β€” browser (@alexify/syncom/chain) 20.0 KB 8.7 KB 3.8 KB 4.0 KB
encryption β€” browser (@alexify/syncom/encryption) 36.2 KB 16.5 KB 6.4 KB 6.5 KB
memory β€” browser (@alexify/syncom/memory) 20.2 KB 8.3 KB 3.3 KB 3.5 KB
layered β€” browser (@alexify/syncom/layered) 8.3 KB 3.5 KB 1.5 KB 1.5 KB
sync β€” browser (@alexify/syncom/sync) 110.2 KB 45.8 KB 16.9 KB 23.5 KB
schema β€” browser (@alexify/syncom/schema) 39.2 KB 19.4 KB 7.0 KB 7.0 KB
telemetry β€” browser (@alexify/syncom/telemetry) 24.8 KB 11.2 KB 4.1 KB 4.5 KB
query β€” browser (@alexify/syncom/query) 136.1 KB 50.9 KB 17.3 KB 17.5 KB
versions β€” browser (@alexify/syncom/versions) 268.6 KB 112.4 KB 40.6 KB 41.0 KB
local β€” browser (@alexify/syncom/local) 15.8 KB 6.6 KB 2.8 KB 3.0 KB
state β€” browser (@alexify/syncom/state) 14.8 KB 7.2 KB 2.9 KB 3.0 KB
dump β€” browser (@alexify/syncom/dump) 34.2 KB 16.6 KB 6.6 KB 7.0 KB
pipeline β€” browser (@alexify/syncom/pipeline) 17.0 KB 7.3 KB 3.1 KB 3.3 KB
cleanup β€” browser (@alexify/syncom/cleanup) 15.5 KB 6.7 KB 3.0 KB 3.3 KB

Quick Start

const { createDatabase, diff, merge } = require('@alexify/syncom');

// A schema turns entity names into collections.
const storage = await createDatabase({
  path: 'data',
  schema: { entities: { Post: {}, Author: {} } },
});

const author = await storage.Author.insert({ name: 'Ada' });
const post = await storage.Post.insert({ title: 'Draft', author: author.id });

// Records expose live properties and track their own delta.
post.title = 'Published';
console.log(post.delta());  // { title: 'Published' }
await post.save();

// Compare-and-swap, for writers that must not clobber each other.
await storage.swap(post.id, { title: 'Final' }, { title: 'Published' });

// The CRDT helpers work standalone, on plain objects. Numbers ADD, which is
// what lets two peers each increment a counter and converge.
merge({ hits: 10 }, { hits: 1 });              // { hits: 11 }
diff({ tags: ['a'] }, { tags: ['a', 'b'] });   // { tags: ['b'] }

Syncing between nodes

Host one end on a wrpc server, dial it from the other. wrpc is yours to install β€” syncom only takes the client you built; the manager is the sync plugin (@alexify/syncom/sync), registered by @alexify/syncom/all:

// the hub
const { Server, defineRouter } = require('@alexify/wrpc');
const { sync } = require('@alexify/syncom/sync');
const storage = await createDatabase({ path: 'hub', plugins: [sync] });
const router = defineRouter({ syncom: storage.sync.unit({ access: 'public' }) });
await new Server({ router, host: '0.0.0.0', port: 8000, protocol: 'http' }).listen();

// a node β€” a server, a CLI, a browser tab
const { WrpcClient } = require('@alexify/wrpc');
const { createWrpcLink } = require('@alexify/syncom/wrpc');
const storage = await createDatabase({
  path: 'data',
  plugins: [sync],
  sync: { connect: async (node) => createWrpcLink(await WrpcClient.connect(node.url)) },
});
await storage.sync.addNode({ url: 'ws://hub.example.com:8000/api' });

Every node can be both, and ops relay hop by hop, so any connected graph of nodes converges.

Features

Area What you get
Database Records with live properties and per-record deltas, schema-driven collections, compare-and-swap writes, transactions (transact(): several writes appended as one, all or none, with a signed message), encryption at rest (AES-256-GCM under an RSA-wrapped key), purge() for the right to be forgotten, repair(id) from the log
Field kinds A field is a register, a counter (inc()), a set (add()/remove()), a list or a text (splice(), push(), updateText(); setAt()/move() on a list), or a tree (treeMove()/treeRemove()). A text carries marks (mark(), unmark(), unmarkById(), spans()) and embeds and block markers (insertEmbed(), splitBlock(), joinBlock(), updateBlock(), block(), blocks()), and cursor()/index() name a place that survives edits; @alexify/syncom/order mints fractional indexes for ordering records. See Lists, text and trees
Schema A metaschema model, any Standard Schema (zod, valibot, arktype), ajv or a compiled JSON Schema validates every write before it is stamped β€” locally and again on the hub. Versions ride on ops; an older record reads through a lens (migrations, then the validator's aliases and defaults); migrate() rewrites it. See Schema and validation
TypeScript Hand-written, tsd-tested types; a storage typed by its options β€” a collection per entity, the methods of its plugins β€” and records typed from the entity's Standard Schema (SchemaOutput/SchemaInput) or a Schema<Shapes> annotation: storage.Post.get(id) is Post | null, a query names the record's fields, a live record has a property per field. See TypeScript
Editors @alexify/syncom/codemirror binds CodeMirror 6 (a ChangeSet as exact splices, undo through the storage, cursors as decorations); @alexify/syncom/prosemirror binds ProseMirror (its modules injected): the document as one text with block markers, a peer's edit as one replace step, undo through the storage, a presence room's cursors as decorations. @alexify/syncom/editor binds any editor to a text field through three functions of an adapter: edits leave as one transaction each, a peer's land as deltas rebased over what the editor holds, a delta the field moved under is refused (STALE) and sent again rebased; diffBlocks() for an editor that hands over a whole document; selections as cursor tokens, a presence room's cursors resolved. See Editors
Frameworks @alexify/syncom/store: a query, a live record, a record through get(), a presence room or any emitter as one store object β€” Svelte's store contract and React's useSyncExternalStore read it as it is, Vue and Solid wrap it in a line; no framework imported. See Frameworks
Queries select({ orgId, status: { $in } }).order('name').limit(20).fetch() over an in-memory record catalog: indexed queries with no I/O, unique keys, watch() for live views. See Queries
Full-text search select({ $text: 'quick fox' }): fuzzy, typo-tolerant, TF-IDF ranked trigram search over an entity's search fields, best first, narrowed by any equality β€” the index is @alexify/syncom/search (under 1 KB), injected at createDatabase({ search }). See Full-text search
Backends and engines node:fs, OPFS, IndexedDB or memory as files β€” five functions injected at createDatabase({ fsi }) β€” or an engine over the medium's own structures: IndexedDB object stores (@alexify/syncom/idb), SQLite (@alexify/syncom/sqlite), MongoDB (@alexify/syncom/mongodb), memory; one transaction per write set; atRest(engine) for ciphertext at rest, bound to its place and its database; an engine served over a port and a database copied between engines (@alexify/syncom/engines). durable: true for fsync, one instance per directory. See Backends
Replication Every write is an op in a persisted log; peers exchange ops over an injected link, resume from where they stopped, and bootstrap from a streamed snapshot when they cannot; scopes for partial replication; presence (@alexify/syncom/presence: who is here and what they edit, relayed by a host's rooms, signed, never logged)
Versions history(id) and get(id, { at }) by event id, stamp or vector, folded from the log; tags of the whole storage (tag(), at(name)); diff(), restore(), applyPatches() and compact(id), every way back new ops that merge; an undo stack (@alexify/syncom/undo) and branches that merge back (@alexify/syncom/branch). See Versions and history
Convergence Last-writer-wins registers, per-origin counters, observed-remove sets, lists and text that merge by position (Fugue β€” each writer's run whole), trees whose concurrent moves never form a cycle, tombstones with revival β€” any order, any number of times, same result. A field two nodes make as different kinds at once ends as the same kind everywhere, by a fixed precedence; what a peer can make a node hold is bounded. See Sync
Transport @alexify/syncom/wrpc adapts a WrpcClient β€” over a WebSocket, or a WebRTC PeerLink's remote from @alexify/wrpc/webrtc; storage.sync.unit() is the router unit a wrpc server, or a browser's PeerHost, mounts. Reconnects, resumable subscriptions, binary streams and rooms come from wrpc. See WebRTC
Integrity Every write appends a hash-linked block (chain: false turns it off for a hub); reads re-verify the record against the block that committed it and throw on a mismatch; repair(id) rebuilds a record from the log
Signed ops Every op is signed with the node's Ed25519 key, and the node id is that key: a peer verifies an op against its origin through any relay, a purge leaves a header that still verifies, sync.signatures: 'require' refuses unsigned ops. See Signatures
CRDT diff / merge / apply over plain objects β€” nested maps merge recursively, arrays are grow-only sets, numbers add so counters converge
Contracts @alexify/syncom/contracts: store a procedure in the chain by hash, load it anywhere, run it against a read-only view of storage; failures are recorded as blocks. A subpath, so the package root never runs a block's code
Portability One API over node:fs + node:crypto on the server and OPFS + Web Crypto in the browser, chosen by the browser export condition
Hooks and events createDatabase({ hooks }) runs your code inside a write, a read or a peer's batch β€” validate, stamp, redact, skip β€” and rejects with a typed HookError when it throws; on() events (change, commit, apply, peer, sync, …) observe everything and can never break it. See Hooks and events
Observability Injected OpenTelemetry (createDatabase({ telemetry })): spans for local writes, applied batches, pushes, bootstraps, log rolls, tags, history reads and patches, plus outcome and lag metrics β€” nested under wrpc's spans, which carry the trace across the wire. stats() reports the queue, the cache, the log, the tags and the files that lag the log. See OpenTelemetry; @alexify/syncom/inspect lays a record out raw and anonymizes a dump for a bug report
Scaling Hubs that dial each other, scopes as buckets, per-peer push limits, a reserved shared-log contract for the broker-backed hub. See Scaling
Dependencies None. wrpc, metaschema and fake-indexeddb are devDependencies for the integration tests; everything is injected at runtime

Compared with alternatives

syncom sits where the CRDT libraries (Yjs, Automerge, Loro), the local-first databases (Jazz, RxDB, PouchDB, TinyBase, Dexie Cloud, Fireproof), the peer-to-peer and event-sourced stores (GUN, OrbitDB, Evolu, LiveStore) and the sync services (ElectricSQL, PowerSync, Zero, Triplit, InstantDB) meet. The comparison puts all nineteen in one table of forty-eight features, each cell decided from the published source. In short: syncom is a database that merges field by field β€” and whose list, text and tree fields merge by position, with marks, cursors and moves, like a text CRDT's β€” with tags, history, undo and branches on top, bound to ProseMirror, CodeMirror and the UI frameworks through subpaths. It syncs with no service to run, its hosts validate and scope what peers send, and its log is verifiable and purgeable. It is a pre-release, not on npm yet. RxDB, the closest of them, is compared feature by feature in 300 rows, from its code and every page of its docs, and Yjs, Automerge and Loro as CRDT libraries in 141 rows, from their code.

Benchmarks

Measured against Yjs, Automerge, Loro, TinyBase, PouchDB, RxDB, Fireproof, GUN and OrbitDB on the same scenarios: insert, update, read, a converging counter, a batch, replicating a store, in memory and on disk, and typing into a text, in memory. Each library runs in a fresh process, is used the way its documentation shows, and is checked to give the right answer before it is timed. The method and the interactive charts are on the Benchmarks page.

Replicate a store, in memory: An empty replica pulls a store of 1,000 records, in process, through the library's own sync protocol, until every record reads back. Nothing touches the disk. records/s, log scale, higher is better. Not shown: syncom (the pure build: only its text is typed through openText()); Fireproof (syncs through a cloud or self-hosted gateway only); GUN (needs its storage adapter to answer in Node.js).

Insert a record, on disk: Add one record to a store of 1,000. Each library's own persistence on node:fs, every write awaited. ops/s, log scale, higher is better. Not shown: syncom (the pure build: only its text is typed through openText()); Yjs (memory only β€” on disk the Yjs row stores every update already); Loro (memory only β€” on disk the Loro row appends every update already); RxDB (Node.js disk storages are Premium).

27 more scenarios Insert a record, in memory: Add one record to a store of 1,000. Nothing touches the disk. ops/s, log scale, higher is better. Not shown: syncom (the pure build: only its text is typed through openText()); GUN (needs its storage adapter to answer in Node.js). Update a field, in memory: Change one field of an existing record. Nothing touches the disk. ops/s, log scale, higher is better. Not shown: syncom (the pure build: only its text is typed through openText()); GUN (needs its storage adapter to answer in Node.js). Update a field, on disk: Change one field of an existing record. Each library's own persistence on node:fs, every write awaited. ops/s, log scale, higher is better. Not shown: syncom (the pure build: only its text is typed through openText()); Yjs (memory only β€” on disk the Yjs row stores every update already); Loro (memory only β€” on disk the Loro row appends every update already); RxDB (Node.js disk storages are Premium). Read a record, in memory: Read one record by id, as a plain object. Nothing touches the disk. ops/s, log scale, higher is better. Not shown: syncom (the pure build: only its text is typed through openText()); GUN (needs its storage adapter to answer in Node.js). Read a record, on disk: Read one record by id, as a plain object. Each library's own persistence on node:fs, every write awaited. ops/s, log scale, higher is better. Not shown: syncom (the pure build: only its text is typed through openText()); Yjs (memory only β€” on disk the Yjs row stores every update already); Loro (memory only β€” on disk the Loro row appends every update already); RxDB (Node.js disk storages are Premium). Increment a counter, in memory: Add 1 to a counter that converges when replicas increment it concurrently. Nothing touches the disk. ops/s, log scale, higher is better. Not shown: syncom (the pure build: only its text is typed through openText()); Yjs (no counter that converges); TinyBase (no counter that converges); PouchDB (no counter that converges); RxDB (no counter that converges); Fireproof (no counter that converges); GUN (needs its storage adapter to answer in Node.js); OrbitDB (no counter that converges). Increment a counter, on disk: Add 1 to a counter that converges when replicas increment it concurrently. Each library's own persistence on node:fs, every write awaited. ops/s, log scale, higher is better. Not shown: syncom (the pure build: only its text is typed through openText()); Yjs (no counter that converges); Yjs (memory only β€” on disk the Yjs row stores every update already); Loro (memory only β€” on disk the Loro row appends every update already); TinyBase (no counter that converges); PouchDB (no counter that converges); RxDB (Node.js disk storages are Premium); Fireproof (no counter that converges); GUN (no counter that converges); OrbitDB (no counter that converges). Insert 1,000 at once, in memory: Add 1,000 records in one call or transaction. Nothing touches the disk. records/s, log scale, higher is better. Not shown: syncom (the pure build: only its text is typed through openText()); GUN (needs its storage adapter to answer in Node.js). Insert 1,000 at once, on disk: Add 1,000 records in one call or transaction. Each library's own persistence on node:fs, every write awaited. records/s, log scale, higher is better. Not shown: syncom (the pure build: only its text is typed through openText()); Yjs (memory only β€” on disk the Yjs row stores every update already); Loro (memory only β€” on disk the Loro row appends every update already); RxDB (Node.js disk storages are Premium). Replicate a store, on disk: An empty replica pulls a store of 1,000 records, in process, through the library's own sync protocol, until every record reads back. Each library's own persistence on node:fs, every write awaited. records/s, log scale, higher is better. Not shown: syncom (the pure build: only its text is typed through openText()); Yjs (memory only β€” on disk the Yjs row stores every update already); Loro (memory only β€” on disk the Loro row appends every update already); RxDB (Node.js disk storages are Premium); Fireproof (syncs through a cloud or self-hosted gateway only); GUN (syncs over its WebSocket/WebRTC mesh only). Type at the end of a text, in memory: Insert one character at the end of a text that merges by position β€” the common keystroke. Nothing touches the disk. ops/s, log scale, higher is better. Not shown: TinyBase (no text that merges by position); PouchDB (no text that merges by position); RxDB (no text that merges by position); Fireproof (no text that merges by position); GUN (needs its storage adapter to answer in Node.js); OrbitDB (no text that merges by position). Type at the end of a text, on disk: Insert one character at the end of a text that merges by position β€” the common keystroke. Each library's own persistence on node:fs, every write awaited. ops/s, log scale, higher is better. Not shown: Yjs (memory only β€” on disk the Yjs row stores every update already); Loro (memory only β€” on disk the Loro row appends every update already); TinyBase (no text that merges by position); PouchDB (no text that merges by position); RxDB (Node.js disk storages are Premium); Fireproof (no text that merges by position); GUN (no text that merges by position); OrbitDB (no text that merges by position). Type at the start of a text, in memory: Insert one character at the start of the text β€” every insert anchors into the same spot. Nothing touches the disk. ops/s, log scale, higher is better. Not shown: TinyBase (no text that merges by position); PouchDB (no text that merges by position); RxDB (no text that merges by position); Fireproof (no text that merges by position); GUN (needs its storage adapter to answer in Node.js); OrbitDB (no text that merges by position). Type at the start of a text, on disk: Insert one character at the start of the text β€” every insert anchors into the same spot. Each library's own persistence on node:fs, every write awaited. ops/s, log scale, higher is better. Not shown: Yjs (memory only β€” on disk the Yjs row stores every update already); Loro (memory only β€” on disk the Loro row appends every update already); TinyBase (no text that merges by position); PouchDB (no text that merges by position); RxDB (Node.js disk storages are Premium); Fireproof (no text that merges by position); GUN (no text that merges by position); OrbitDB (no text that merges by position). Type anywhere in a text, in memory: Insert one character at a random position of the text (seeded). Nothing touches the disk. ops/s, log scale, higher is better. Not shown: TinyBase (no text that merges by position); PouchDB (no text that merges by position); RxDB (no text that merges by position); Fireproof (no text that merges by position); GUN (needs its storage adapter to answer in Node.js); OrbitDB (no text that merges by position). Type anywhere in a text, on disk: Insert one character at a random position of the text (seeded). Each library's own persistence on node:fs, every write awaited. ops/s, log scale, higher is better. Not shown: Yjs (memory only β€” on disk the Yjs row stores every update already); Loro (memory only β€” on disk the Loro row appends every update already); TinyBase (no text that merges by position); PouchDB (no text that merges by position); RxDB (Node.js disk storages are Premium); Fireproof (no text that merges by position); GUN (no text that merges by position); OrbitDB (no text that merges by position). Replay an editing trace, in memory: The automerge-perf trace: 259,778 keystrokes of a real paper (182,315 inserts, 77,463 deletes) replayed into one text as an editor would send them, the text read back whole at the end. Nothing touches the disk. ops/s, log scale, higher is better. Not shown: TinyBase (no text that merges by position); PouchDB (no text that merges by position); RxDB (no text that merges by position); Fireproof (no text that merges by position); GUN (needs its storage adapter to answer in Node.js); OrbitDB (no text that merges by position). Push onto a list, in memory: Add one element at the end of a list of 10,000 that merges by position. Nothing touches the disk. ops/s, log scale, higher is better. Not shown: syncom (the pure build: only its text is typed through openText()); TinyBase (no list that merges by position); PouchDB (no list that merges by position); RxDB (no list that merges by position); Fireproof (no list that merges by position); GUN (needs its storage adapter to answer in Node.js); OrbitDB (no list that merges by position). Push onto a list, on disk: Add one element at the end of a list of 10,000 that merges by position. Each library's own persistence on node:fs, every write awaited. ops/s, log scale, higher is better. Not shown: syncom (the pure build: only its text is typed through openText()); Yjs (memory only β€” on disk the Yjs row stores every update already); Loro (memory only β€” on disk the Loro row appends every update already); TinyBase (no list that merges by position); PouchDB (no list that merges by position); RxDB (Node.js disk storages are Premium); Fireproof (no list that merges by position); GUN (no list that merges by position); OrbitDB (no list that merges by position). Insert into a list, in memory: Insert one element at a random index of the list (seeded). Nothing touches the disk. ops/s, log scale, higher is better. Not shown: syncom (the pure build: only its text is typed through openText()); TinyBase (no list that merges by position); PouchDB (no list that merges by position); RxDB (no list that merges by position); Fireproof (no list that merges by position); GUN (needs its storage adapter to answer in Node.js); OrbitDB (no list that merges by position). Insert into a list, on disk: Insert one element at a random index of the list (seeded). Each library's own persistence on node:fs, every write awaited. ops/s, log scale, higher is better. Not shown: syncom (the pure build: only its text is typed through openText()); Yjs (memory only β€” on disk the Yjs row stores every update already); Loro (memory only β€” on disk the Loro row appends every update already); TinyBase (no list that merges by position); PouchDB (no list that merges by position); RxDB (Node.js disk storages are Premium); Fireproof (no list that merges by position); GUN (no list that merges by position); OrbitDB (no list that merges by position). Remove from a list, in memory: Remove the element at a random index, then push one, so the list keeps its length: one op is both. Nothing touches the disk. ops/s, log scale, higher is better. Not shown: syncom (the pure build: only its text is typed through openText()); TinyBase (no list that merges by position); PouchDB (no list that merges by position); RxDB (no list that merges by position); Fireproof (no list that merges by position); GUN (needs its storage adapter to answer in Node.js); OrbitDB (no list that merges by position). Remove from a list, on disk: Remove the element at a random index, then push one, so the list keeps its length: one op is both. Each library's own persistence on node:fs, every write awaited. ops/s, log scale, higher is better. Not shown: syncom (the pure build: only its text is typed through openText()); Yjs (memory only β€” on disk the Yjs row stores every update already); Loro (memory only β€” on disk the Loro row appends every update already); TinyBase (no list that merges by position); PouchDB (no list that merges by position); RxDB (Node.js disk storages are Premium); Fireproof (no list that merges by position); GUN (no list that merges by position); OrbitDB (no list that merges by position). Set a list element, in memory: Give the element at a random index a new value β€” an element of its own, not a delete and an insert. Nothing touches the disk. ops/s, log scale, higher is better. Not shown: syncom (the pure build: only its text is typed through openText()); Yjs (no element of its own to set); TinyBase (no list that merges by position); PouchDB (no list that merges by position); RxDB (no list that merges by position); Fireproof (no list that merges by position); GUN (needs its storage adapter to answer in Node.js); OrbitDB (no list that merges by position). Set a list element, on disk: Give the element at a random index a new value β€” an element of its own, not a delete and an insert. Each library's own persistence on node:fs, every write awaited. ops/s, log scale, higher is better. Not shown: syncom (the pure build: only its text is typed through openText()); Yjs (no element of its own to set); Yjs (memory only β€” on disk the Yjs row stores every update already); Loro (memory only β€” on disk the Loro row appends every update already); TinyBase (no list that merges by position); PouchDB (no list that merges by position); RxDB (Node.js disk storages are Premium); Fireproof (no list that merges by position); GUN (no list that merges by position); OrbitDB (no list that merges by position). Move a list element, in memory: Move the element at a random index to another random index β€” the element itself, merged once. Nothing touches the disk. ops/s, log scale, higher is better. Not shown: syncom (the pure build: only its text is typed through openText()); Yjs (no move); Automerge (no move); TinyBase (no list that merges by position); PouchDB (no list that merges by position); RxDB (no list that merges by position); Fireproof (no list that merges by position); GUN (needs its storage adapter to answer in Node.js); OrbitDB (no list that merges by position). Move a list element, on disk: Move the element at a random index to another random index β€” the element itself, merged once. Each library's own persistence on node:fs, every write awaited. ops/s, log scale, higher is better. Not shown: syncom (the pure build: only its text is typed through openText()); Yjs (no move); Yjs (memory only β€” on disk the Yjs row stores every update already); Automerge (no move); Loro (memory only β€” on disk the Loro row appends every update already); TinyBase (no list that merges by position); PouchDB (no list that merges by position); RxDB (Node.js disk storages are Premium); Fireproof (no list that merges by position); GUN (no list that merges by position); OrbitDB (no list that merges by position).

In memory

Library Insert a record
ops/s
Update a field
ops/s
Read a record
ops/s
Increment a counter
ops/s
Insert 1,000 at once
records/s
Replicate a store
records/s
Type at the end of a text
ops/s
Type at the start of a text
ops/s
Type anywhere in a text
ops/s
Replay an editing trace
ops/s
Push onto a list
ops/s
Insert into a list
ops/s
Remove from a list
ops/s
Set a list element
ops/s
Move a list element
ops/s
syncom
core + text + sync, memory() Β· v0.0.1
42,800 106,000 848,000 109,000 40,600 73,400 74,200 47,500 37,900 158,000 42,100 25,400 22,500 48,300 19,900
syncom
core + text + sync, typed through openText(), memory() Β· v0.0.1
β€” β€” β€” β€” β€” β€” 322,000 93,900 39,800 170,000 β€” β€” β€” β€” β€”
syncom
/all: signed, chained, memory() Β· v0.0.1
15,400 19,200 821,000 19,400 25,100 22,000 17,900 15,500 13,400 38,000 15,900 13,200 7,870 15,800 11,000
Yjs
Y.Doc, a Y.Map per record Β· v13.6.33
204,000 354,000 3,770,000 β€” 217,000 99,800 342,000 461,000 27,700 52,000 15,100 26,000 2,200 β€” β€”
Yjs
Y.Doc, every update encoded (an update listener) Β· v13.6.33
111,000 211,000 3,540,000 β€” 145,000 82,000 31,600 251,000 26,400 46,000 15,400 25,700 2,270 β€” β€”
Automerge
Automerge.change, Counter Β· v3.5.0
497 1,880 9,860,000 1,830 8,500 21,100 23,400 21,500 20,600 17,300 2,280 5,480 1,470 1,900 β€”
Loro
LoroDoc, a LoroMap per record Β· v1.16.4
33,100 368,000 284,000 307,000 75,400 101,000 609,000 437,000 294,000 471,000 520,000 430,000 230,000 418,000 330,000
Loro
LoroDoc, every commit exported (subscribeLocalUpdates) Β· v1.16.4
11,800 98,200 280,000 99,200 67,200 103,000 107,000 97,600 87,400 97,800 102,000 101,000 48,900 107,000 91,300
TinyBase
MergeableStore Β· v10.0.1
22,300 97,300 1,410,000 β€” 21,600 11,400 β€” β€” β€” β€” β€” β€” β€” β€” β€”
PouchDB
pouchdb-adapter-memory Β· v9.0.0
19,900 8,270 29,400 β€” 69,900 17,300 β€” β€” β€” β€” β€” β€” β€” β€” β€”
RxDB
memory storage Β· v17.6.0
88,900 47,600 281,000 β€” 1,860,000 59,300 β€” β€” β€” β€” β€” β€” β€” β€” β€”
Fireproof
memory:// store Β· v0.24.19
313 350 664 β€” 20,200 β€” β€” β€” β€” β€” β€” β€” β€” β€” β€”
OrbitDB
documents, memory blockstore Β· v4.0.0
995 29.5 78.1 β€” 1,020 206 β€” β€” β€” β€” β€” β€” β€” β€” β€”

On disk

Library Insert a record
ops/s
Update a field
ops/s
Read a record
ops/s
Increment a counter
ops/s
Insert 1,000 at once
records/s
Replicate a store
records/s
Type at the end of a text
ops/s
Type at the start of a text
ops/s
Type anywhere in a text
ops/s
Push onto a list
ops/s
Insert into a list
ops/s
Remove from a list
ops/s
Set a list element
ops/s
Move a list element
ops/s
syncom
core + text + sync, node:fs Β· v0.0.1
5,410 22,100 885,000 22,700 6,310 6,540 30,400 24,500 20,700 16,600 10,900 10,600 14,300 8,560
syncom
core + text + sync, typed through openText(), node:fs Β· v0.0.1
β€” β€” β€” β€” β€” β€” 262,000 93,200 40,700 β€” β€” β€” β€” β€”
syncom
/all: signed, chained, node:fs Β· v0.0.1
4,300 7,000 878,000 7,420 6,350 5,360 12,000 10,600 9,620 7,480 5,700 4,640 6,280 4,650
Yjs
y-leveldb Β· v13.6.33
17,900 20,400 3,780,000 β€” 133,000 49,300 19,400 20,600 12,300 10,500 12,000 2,070 β€” β€”
Automerge
automerge-repo, NodeFS storage Β· v3.5.0
459 1,130 10,100,000 1,140 7,110 2,960 2,870 2,990 2,880 1,510 2,210 850 1,350 β€”
Loro
updates appended to a file Β· v1.16.4
19,800 39,200 291,000 39,800 63,400 95,000 39,500 40,800 40,100 42,300 41,000 20,100 42,200 39,200
TinyBase
MergeableStore, file persister Β· v10.0.1
165 282 1,330,000 β€” 14,100 10,600 β€” β€” β€” β€” β€” β€” β€” β€”
PouchDB
LevelDB (Node default) Β· v9.0.0
22,900 8,310 38,600 β€” 63,100 14,600 β€” β€” β€” β€” β€” β€” β€” β€”
Fireproof
file:// store Β· v0.24.19
289 301 686 β€” 19,500 β€” β€” β€” β€” β€” β€” β€” β€” β€”
GUN
RAD on node:fs Β· v0.2020.1241
3.67 3.76 811 β€” 2,010 β€” β€” β€” β€” β€” β€” β€” β€” β€”
OrbitDB
documents, LevelDB Β· v4.0.0
890 15.5 75.7 β€” 881 183 β€” β€” β€” β€” β€” β€” β€” β€”

Apple M3 Max Β· 16 cores Β· Node v24.14.1 Β· median of 5 Γ— 1 s samples (5 rounds for a batch or a replication), each library in a fresh process Β· 2026-10-31. Medians, rounded to three significant figures; β€” where the library has no such operation, no such storage, or no in-process sync, failed where it did not finish twice (the chart above says why).

Cold start

Cold start: Load the library, open a store of 1,000 records on disk, read one: a fresh process, median of 10 runs. Lower is better.

Bundle size

Bundle size: What a browser app ships to keep records on the device and sync them (esbuild, minified, tree-shaken, the network client left out), min+gzip, WebAssembly included. Lower is better.

Library What is bundled min min+gzip
GUN
gun@0.2020.1241
gun (localStorage; its WebSocket mesh is built in) 42.2 KB 17.1 KB
TinyBase
tinybase@10.0.1
MergeableStore + IndexedDB persister + custom synchronizer 56.4 KB 23.8 KB
Yjs
yjs@13.6.33, y-indexeddb@9.0.12, y-protocols@1.0.7
yjs + y-indexeddb + y-protocols/sync 80.2 KB 24.5 KB
PouchDB
pouchdb-browser@9.0.0
pouchdb-browser 117.8 KB 40.4 KB pouchdb-browser carries its HTTP adapter (the CouchDB client) with it
syncom
@alexify/syncom@0.0.1
the core over OPFS + the sync plugin + the wrpc link adapter 254.2 KB 87.2 KB records, counters and sets, the op log and the sync protocol; texts and schemas are plugins of their own
RxDB
rxdb@17.6.0, dexie@4.4.6, rxjs@7.8.2
rxdb + Dexie storage + replication 282.3 KB 90.1 KB
Fireproof
@fireproof/core@0.24.19
@fireproof/core 784.2 KB 204.0 KB
OrbitDB
@orbitdb/core@4.0.0, helia@6.1.4, libp2p@3.5.1, @libp2p/gossipsub@15.0.23, blockstore-idb@4.0.1
@orbitdb/core + helia + libp2p, IndexedDB blockstore 1185.6 KB 353.7 KB createHelia brings Helia's default libp2p configuration with it
Loro
loro-crdt@1.16.4
loro-crdt (no persistence of its own) 3405.0 KB
WebAssembly 3319.1 KB
1114.8 KB
Automerge
@automerge/automerge@3.5.0, @automerge/automerge-repo@2.6.0-alpha.3, @automerge/automerge-repo-storage-indexeddb@2.6.0-alpha.3
automerge-repo + IndexedDB storage 3744.5 KB
WebAssembly 3558.8 KB
1171.9 KB

syncom's own hot paths

Database: syncom's own benchmarks: The public API end to end on node:fs: a write is an op appended to the log, a record file and a block. ops/s, log scale, higher is better.

Browser usage

The browser export condition selects browser.js automatically β€” no bundler configuration needed.

import { createDatabase, diff, merge } from '@alexify/syncom';

Crypto is asynchronous there (calculateHash, encrypt, decrypt, sign and verify return promises), and storage lives in the origin-private filesystem β€” or in IndexedDB through createDatabase({ fsi: createIdbFsi() }) from @alexify/syncom/idb. browser.d.ts declares those signatures separately. A browser node dials the mesh through @alexify/syncom/wrpc like any other, and hosts for its peers over WebRTC. See Browser.

Status

0.0.1 β€” a pre-release, not published to npm, and there is no release until the package does what it is meant to; the API may still change. Everything on this page works today and is covered by the test suite, including replication over real wrpc WebSockets. Known limits β€” a counter straddling a concurrent reset, encrypted records staying local, no proof of key possession at hello β€” are on the Sync page.

Documentation

Contributing

See CONTRIBUTING.md.

pnpm install
pnpm test              # node --test, recursive over tests/
pnpm test:coverage     # c8 over src/
pnpm test:types        # tsd against the .d.ts files
pnpm lint              # oxlint
pnpm format            # oxfmt (format:check in CI)
pnpm size              # bundle-size report; a browser budget exceeded fails the run
pnpm bench             # every script in bench/ (--save writes bench/results/engine.json)
pnpm bench:compare:install && pnpm bench:compare   # against the alternatives
pnpm bench:report      # the charts and the tables of the docs and this README
pnpm docs:comparison   # the comparison matrix from docs/comparison.json
pnpm docs:dev          # the VitePress site

Changelog

See CHANGELOG.md.

Security

See SECURITY.md. Report vulnerabilities privately β€” do not open a public issue.

License

MIT Β© Alexis Technologies

About

Fast, zero-dependency distributed data sync engine 🌐

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages