Skip to content

Entry points ​

syncom is a core and blocks. The core — @alexify/syncom — keeps records with registers, counters and sets (fields by path among them), the op log, the three write paths, transactions, the catalog, the present's patches and the changes every op reports, and the ops a peer sends or lacks (applyRemote(), outbound()). Everything beyond that is a subpath you import when you need it, and nothing is registered process-wide: a storage runs with the plugins its open() was given, and another storage in the same process may run with others.

The blocks ​

ImportWhat it isRegistered how
@alexify/syncomthe core—
@alexify/syncom/textthe list and text kinds: Fugue runs, marks, cursors, splice … moveopen({ plugins: [text] })
@alexify/syncom/treethe tree kind: a movable tree per field, treeMove, treeRemoveopen({ plugins: [tree] })
@alexify/syncom/signingthe identity as an Ed25519 key, every own op signed, a peer's verified (sync.signatures); the key functionsopen({ plugins: [signing] })
@alexify/syncom/chainthe hash-linked chain: a block per applied batch, the check on a cold read, validate(); Blockchainopen({ plugins: [chain] })
@alexify/syncom/encryptionrecords sealed for this node (encrypted: true), and atRest(), a backend wrapper that encrypts every fileopen({ plugins: [encryption] }); open({ fsi: atRest(fsi, …) })
@alexify/syncom/syncthe sync manager: nodes, links, pushes, the feed, the bootstrap, the hosting unit; the core keeps applyRemote() and outbound()open({ plugins: [sync] })
@alexify/syncom/schemaentity definitions: collections, validation, indexes, migrations and the lens, migrate(), check()open({ plugins: [schema], schema })
@alexify/syncom/telemetrythe OpenTelemetry adapter: { api } or { tracer, meter } into the writer the engine records through; createTelemetry()open({ plugins: [telemetry], telemetry })
@alexify/syncom/querythe cursor behind select(), count() and watch() on a collection; the catalog stays in the coreopen({ plugins: [query] })
@alexify/syncom/versionsthe past: get(id, { at }), history(), diff() between versions, restore(), the tags; applyPatches() and compact() stay in the coreopen({ plugins: [versions] })
@alexify/syncom/allthe core with every shipped plugin registered — the API as one importrequire('@alexify/syncom/all').open()
@alexify/syncom/undo, /branch, /orderthe undo stack, forks and merges, fractional indexesfunctions over a storage
@alexify/syncom/presencewho is here and what they point at: a state per node, signed, relayed by a host's rooms, never loggedcreatePresence(storage); carried by storage.sync.presence()
@alexify/syncom/editor, /prosemirror, /codemirroran editor bound to a text field: the generic binding and its deltas, and adapters for ProseMirror and CodeMirror 6 (their modules injected)functions over a storage
@alexify/syncom/storea query, a record or a presence as one store — Svelte's contract and React's useSyncExternalStoretoStore(source)
@alexify/syncom/inspecta record's anatomy, raw, and an anonymized copy for a bug reportfunctions over a storage
@alexify/syncom/memory, /layeredmemory(), the backend in memory; layered([top, …, bottom]), one backend over several as a multi-level cacheopen({ fsi })
@alexify/syncom/search, /idb, /wrpc, /contractsthe text index behind $text, the IndexedDB backend, the wrpc link adapter, smart contractsinjected: open({ search }), open({ fsi }), sync.connect

Who imports what ​

A collaborative text (the use a document CRDT is picked for):

js
const { open } = require('@alexify/syncom');
const { memory } = require('@alexify/syncom/memory');
const { text } = require('@alexify/syncom/text');

const storage = await open({ fsi: memory(), plugins: [text] });
const id = await storage.insert({ kind: 'doc' });
await storage.splice(id, 'body', 0, 0, 'hello');

A local-first database opens the core with schema and query — the collections, and the cursor over them — and no text or tree fields: the lists, texts and trees ship nothing to it. The core alone has no key: the node id is what getId mints (a UUID by default), its ops go out unsigned, no chain is written. Signed and audited — the level of security the package had as one piece — is three plugins:

js
const { open } = require('@alexify/syncom');
const { signing } = require('@alexify/syncom/signing');
const { chain } = require('@alexify/syncom/chain');
const { encryption } = require('@alexify/syncom/encryption');

const storage = await open({ path: 'data', plugins: [signing, chain, encryption] });
storage.nodeId; // the Ed25519 public key; every op signed, every write chained

Everything is /all:

js
const { open } = require('@alexify/syncom/all'); // every plugin: text, tree, signing, chain, encryption, sync, schema, telemetry, query, versions

What a plugin changes, and what it does not ​

  • A method of a plugin the storage was not opened with — splice() without text, treeMove() without tree — rejects with a TypeError whose code is PLUGIN_MISSING and whose message names the subpath. So does a patch of its action in applyPatches(), and a reopen of a directory that ran with the plugin before (node.json names the kinds it ran with; more kinds than before is fine).
  • The op types and their codes on the wire are the protocol's, not a plugin's: a node without the plugin admits, logs and relays a peer's op of that kind and refuses to apply it (OP_REFUSED, reason: 'kind'). A hub that runs the core alone carries a text between two nodes that have the plugin. A snapshot it serves lacks the field, which is why…
  • …a node never bootstraps from a host whose hello named fewer kinds than its own (KINDS_MISMATCH, reported once; the feed resumes as it can). The rule for a mesh: every node that holds a record needs the kinds its peers write to it, and a hosting unit that serves snapshots should have every kind its peers use.
  • The kinds' precedence — what wins when two nodes make one field as two kinds — is the registry's declared ranks: the register, the counter, the set, then every plugin's kinds, unique. It reads kinds alone, so it commutes whatever the set of plugins.
  • Security is opt-in, not opt-out of a level: without signing a node's ops are taken by a 'verify' peer as unsigned ops always were, and a 'require' host refuses its hello (its id is no key). The configuration that resists a stripped signature is 'require' with signing on every node — see Signatures. What the options ask for and no plugin provides is refused at open() with PLUGIN_MISSING: sync.signatures: 'require' without signing, chain: true without chain, encrypted: true without encryption. A directory keeps the key it was given as its id when opened without signing; one born without it is rekeyed once, with a warning, when the plugin arrives.

Sizes ​

pnpm size measures every entry on its own, min+gzip, against a budget:

Entrymin+gzipEntrymin+gzip
the core51.4 KB/all117.1 KB
/text33.9 KB/tree8.6 KB
/sync16.5 KB/versions34.8 KB
/signing4.6 KB/chain2.1 KB
/encryption3.6 KB/schema5.9 KB
/query6.4 KB/telemetry3.3 KB
/undo8.6 KB/branch4.6 KB
/presence4.3 KB/inspect5.0 KB
/editor6.0 KB/store1.0 KB
/prosemirror9.1 KB/codemirror7.8 KB
/memory0.5 KB/layered1.5 KB

A block measured alone carries the core modules it stands on, so the rows do not add up: in an app bundle those are counted once, and the script checks it — the core and every block bundled together weigh what /all weighs, within a rounding. The editor adapters carry the generic binding under them (/editor, 6 KB), and neither bundles its editor.

Released under the MIT License.