Skip to content

API reference ​

Everything below is exported from the package root, except four subpaths: the wrpc adapter, @alexify/syncom/wrpc, the smart contracts, @alexify/syncom/contracts, the IndexedDB backend, @alexify/syncom/idb (createIdbFsi), and the text index, @alexify/syncom/search (createSearch). The browser build exports the same names — see Browser for the five signatures that differ.

js
const syncom = require('@alexify/syncom');

Storage ​

open(options?) ​

Returns Promise<Storage>. Options: path (default 'syncom'), name (alias for path), fsi and durable (see Backends), lock ('fail' | 'wait' | false — see One instance per directory), cache, integrity and catalog (see The catalog and the cache), chain (false keeps no chain), actor (see Operations), schema and validator (see Collections), search (the text index behind $text, see Full-text search), getId (must mint a valid id), logger, encrypted, sync (see Sync options), telemetry (see OpenTelemetry), hooks (see Hooks and events).

Storage ​

await new Storage(options) — open() is the shorthand. Extends Emitter.

MethodReturnsNotes
insert(data, options?)Promise<string>Generates an id via getId (a UUID by default) and writes; rejects with code: 'INVALID_ID' when the generator mints one that is not a valid id
get(id)Promise<unknown>null when absent or deleted; verifies against the chain; a copy — editing it changes nothing
has(id)Promise<boolean>false for a tombstone
list()Promise<string[]>Every live id, sorted — from the catalog, no I/O
check(entity, data)Promise<unknown>Runs the entity's validator as a write would and returns its value; rejects with ValidationError; writes nothing
migrate({ entities, batch, actor }?)Promise<{ migrated, counters, sets }>Rewrites records of an older schema version through the lens; idempotent; actor rides on the ops it writes (see Reading across versions)
set(id, data, options?)Promise<void>Replaces the record (a write op); keeps $type unless the data names the same. Every writer rejects an invalid id with TypeError code: 'INVALID_ID'
update(id, delta, { actor }?)Promise<void>Sets the listed fields (a set op); throws if absent, or on a counter or set field
inc(id, key, value = 1, { actor }?)Promise<void>Adds to a counter field (an inc op); throws on a register or set field
add(id, key, elements, { actor }?)Promise<void>Adds JSON values to a set field (an add op); creates the field and the record when absent; throws on a register or counter field
remove(id, key, elements, { actor }?)Promise<void>Removes from a set field the elements this node has seen added (a remove op; none seen: no op); throws on a register or counter field
swap(id, changes, prev, { actor }?)Promise<boolean>CAS, compared inside the mutation queue — false, and no write, on mismatch; rejects when absent
repair(id)Promise<{ ops, alive }>Rebuilds a record whose file fails its integrity check from the log's entries of it (see Repair); rejects with code: 'UNRECOVERABLE' when the log holds no whole-record op for it
delete(id, { actor }?)Promise<void>Leaves a tombstone
purge(id, { actor }?)Promise<void>Forgets the record for good: a purge op and a scrub of its log entries, here and on every peer (see Purge)
record(id)Promise<Record>Cached per id
saveData(id, data, options?)Promise<void>{ encrypted: true } seals the record for this node only; { actor } names the writer
loadData(id)Promise<unknown>Alias of get(); throws if the record no longer matches its block
validate(options?)Promise<boolean>{ last, from }; no argument walks the whole chain; rejects with code: 'NO_CHAIN' under open({ chain: false })
validate(id, data, blockHash)Promise<boolean>One record against its block
getCachedData(id)unknown | nullSynchronous cache peek, raw (no lens)
hasRecord(id)booleanWhether the id is a live record — from the catalog, no I/O
stats(){ closed, queue, cache: { size, max, pinned }, catalog: { entries, dirty }, log: { length, floor, next, epoch, segments, latestTs }, chain: { tail } | null, lock: { mode } | null }The node's health, from memory — no I/O
applyRemote(ops, { via, scopes, peer, adoptFrom, bound }?)Promise<{ applied, skipped, duplicates, gaps, malformed, unsigned, forged, outOfScope }>Applies a peer's ops, in order per origin; peer is { url, node } for the hooks; malformed ops (shape, id, a stamp beyond sync.maxSkew) and ops refused for their signature (forged; unsigned under sync.signatures: 'require') are counted and dropped; an advance is adopted only when its origin is adoptFrom; under bound (a host's push passes the peer's granted scopes) an op whose record is outside them, before or after it, is counted outOfScope and dropped
snapshot({ cursor, limit, scopes }?)Promise<{ records, cursor, eventId, vector }>One page of state, tombstones included
mergeSnapshot(records, { via, from }?)Promise<void>Joins a page (as merge ops of this node)
adoptVector(vector, { via, from }?)Promise<SyncOp | null>Records what a joined snapshot covers (an advance op)
close()Promise<void>Stops sync and seals the log; further writes are refused

Properties: schema, schemaInfo ({ version, hash, previous, held }, null without a schema — see Schema versions), sync, nodeId (this node's Ed25519 public key, base64url — what its ops are verified against), signatures ('verify', 'require' or false), path, log, vector, logger (the writer built from open({ logger }) — debug/info/warn/error), telemetry (the writer built from the option — opaque). Statics: Storage.Collection, Storage.Record, Storage.generateId() — the default getId, synchronous in both halves.

Events — listeners are contained, a throwing one is logged and never fails the operation (see Hooks and events):

EventPayloadWhen
change(op, via)Every op applied here; via is null for a local write, otherwise { url, node } of the peer it came from (url null on the hosting side)
apply({ via, peer, ops, applied, skipped, duplicates, gaps, malformed, unsigned, forged })A peer's batch is durable
error(error) with error.code and error.op, plus error.rejected (OP_REFUSED) or error.entity + error.issues (OP_INVALID); error.ids for UNPERSISTEDA peer's op refused by the field-kind rule, skipped as invalid under this node's schema, skipped because this node cannot read the record's file (OP_UNREADABLE), or refused for a stamp beyond sync.maxSkew (SKEW); signatures refused (BAD_SIGNATURE, UNSIGNED); logged ops whose record files could not be written (UNPERSISTED)
conflict({ kind: 'unique', entity, fields, id, other, op })A peer's op left a unique key shared by two records; both are kept
schema({ kind: 'drift', peer, version, hash, local }) / ({ kind: 'ahead', op, held })A peer runs this schema version with another snapshot hash (see Schemas) / a peer's op is from a newer version, applied or held
log({ kind: 'roll', sealed, dropped, floor, segments })The log sealed a segment and maybe dropped old ones; floor is where a resume is still possible
close—After close() sealed the log

Hooks (open({ hooks })): beforeWrite, afterWrite, beforeRead, afterRead, beforeApply, afterApply — see Hooks and events.

Record ​

Extends Emitter. Emits update with (data, delta) — on a staged assignment, and when the record changes in storage (null data once deleted).

id, data(), delta(), save({ actor }?), inc(key, value?, { actor }?), add(key, elements, { actor }?), remove(key, elements, { actor }?), delete({ actor }?), plus a live property per field of the loaded object.

Collection ​

Created per schema entity and exposed as storage.<EntityName>; a name the storage already answers to (get, close, log, sync…) is refused at open() with a TypeError.

name, insert(data, options?), get(id) (null for a record of another collection), set(id, data, options?) (stamped as the collection's), update(id, delta, { actor }?) (refuses a delta naming $type), delete(id, { actor }?), record(id) — update, delete and record reject with NOT_FOUND for a record of another collection — select(query?) → a cursor, count(query?). Writes through a collection with a schema are validated — see Validation; unique keys are refused with a ValidationError whose issue has code: 'unique' and conflict.

Cursor ​

collection.select(query) — see Queries; $text in the query searches the entity's search fields, best first unless ordered.

MemberNotes
order(field), desc(field)Sort keys, in order; nothing first, then booleans, numbers, strings; stable
limit(n), offset(n)Applied after the sort
filter(fn)A predicate over the loaded row
projection(fields)The fields each row carries, plus id
fetch()Promise<QueryRow[]> — { id, ...data } as the record reads
count()Promise<number>
one()Promise<QueryRow | null>
watch(listener, { onError }?)Runs now and after every change to the collection; returns the stop function
[Symbol.asyncIterator]for await (const row of cursor)

Sync ​

SyncManager ​

storage.sync; await new SyncManager(storage, options) builds one by hand. Extends Emitter. See Sync.

MethodReturnsNotes
addNode({ url, token?, scopes? })Promise<void>Registers a peer to dial; dials it at once when started
removeNode(url)Promise<void>Hangs up too
enableNode(url, enabled = true)Promise<void>Keep a node without dialing it
listNodes()Node[]{ url, token, scopes, enabled, lastSync, cursor, remote, acked }; remote is { id, epoch, schema } after a hello; acked is how far the node holds this node's own streams (vector keys → seq), what the log keeps them for
start()Promise<void>Dials every enabled node; needs sync.connect
stop()Promise<void>Closes every link
sync(url?)Promise<Array<{ url, pushed }>>Pushes what the peers lack now
peers()Array<{ url, node, connected, subscribed, cursor, lastSync, pending, behind, stuck, lastError }>Live view; behind after a failed push until the next recovery, stuck once a page failed three times in a row (see Protocol), lastError { code, message, at } or null
hosted()Array<{ node, scopes, since, cursor }>Hosting side: every peer with a live changes feed here (scopes null means every scope)
unit({ access, authorize, tracked, pageSize, pageBytes, limits, meta }?)router unitThe procedures a host mounts: hello, push, snapshot, changes — see Hosting

Properties: storage, started.

Events (contained, like the storage's): peer ({ url, connected, node?, reconnected?, schema? }), sync ({ url, pushed }) after a push and ({ url, received, applied, skipped, duplicates, gaps, malformed, unsigned, forged }) after a received page, bootstrap ({ url, records }), error (with .url; code: 'PAGE_FAILED' / 'STUCK' with cursor and attempt for a page of the feed that could not be applied, 'HANDSHAKE_LOOP' for a host that keeps answering 428), start, stop, node ({ url, action: 'add' | 'remove' | 'enable' | 'disable' }), connect({ url }) once a link is accepted (before hello), and on the hosting side hello ({ node, scopes, schema }) once a peer is authorized and refused({ node, reason, count }) when a push carried ops it would not take: reason'scope' for ops outside the scopes that hello granted — the scope an op declares, or the scope of the record it writes, before or after it (see Scopes) — 'forged' and 'unsigned' for signatures refused (see Signatures). The dialer's error event also carries 'REWOUND' and 'UNDELIVERABLE' (see the codes below).

Hooks (open({ sync: { hooks } })): beforeConnect, beforeBootstrap — see Hooks and events.

SyncomError ​

What syncom rejects with when an operation cannot be done: name'SyncomError', a code from the table below, the fields the code lists, and cause when an underlying error exists. A TypeError is the other kind — a wrong argument, option or hook result, a bug in the calling code — and carries no code, except INVALID_ID, TYPE_IMMUTABLE and NO_CHAIN, whose value is data or configuration. HookError and ValidationError are SyncomErrors. Exported from the package root; the union of codes is the SyncomErrorCode type.

CodeRaised byFields
CLOSEDany write or applyRemote() after close()—
IN_HOOKa write issued from inside a write or apply hook (the cause of the HookError the operation rejects with)—
NOT_FOUNDupdate(), swap() on a record that is absent, deleted or purged; a collection's update(), delete(), record() on a record of another collectionid, entity (the collection's)
FIELD_KINDupdate()/set() on a counter or set field, inc() on a register or set field, add()/remove() on a register or counter fieldfield, kind ('register', 'counter' or 'set': what the field is)
ENCRYPTEDinc(), add() or remove() on an encrypted record; { encrypted: true } on a replicated one—
INTEGRITYa read (get(), has(), a cursor) of a record whose file fails its chain block check, under integrity: 'verify', or cannot be parsed at all (a file torn by a crash; repair(id) rebuilds it)id, cause (the parse error)
CORRUPTopen() when node.json, schema.json or .blockchain.json cannot be readcause
DOWNGRADEopen() with ahead: 'hold' and a schema version below the one the directory ran—
LOCKEDopen() while another instance holds the directorypath
KEYS_INCOMPLETEopen(), loadKeys(), loadSigningKeys() when one file of a pair (RSA or signing) is missing—
IDENTITY_MISMATCHopen() when node.json names another signing key than keys/sign-public.pem — a key pair replaced or restored under a signed identityid (what node.json names), key (the pair on disk)
BAD_SIGNATUREthe storage's error event, once per page: a peer's op whose signature does not verify against its origin, or whose payload its digest does not match (sync.signatures 'verify' or 'require')op (the first refused), count
UNSIGNEDthe storage's error event, once per page, under sync.signatures: 'require': a peer's op with no signature, or whose origin is no signing keyop, count
BAD_ENVELOPEdecrypt() of a ciphertext this version does not read—
BLOCK_NOT_FOUNDBlockchain.readBlock(), SmartContract.load()hash
NOT_CONTRACTSmartContract.load() of a block that is not a contracthash
NODE_EXISTSaddNode() of a registered urlurl
NODE_NOT_FOUNDremoveNode(), enableNode() of an unknown urlurl
BAD_HELLOthe sync error event: the host's hello answer has no node idurl
HANDSHAKE_LOOPthe sync error event: five fresh hellos answered 428 in a rowurl, cause
PAGE_FAILED, STUCKthe sync error event: a page of the feed could not be applied; the third in a rowurl, cursor, attempt, cause
STREAMS_UNSUPPORTEDthe sync error event: the host streamed a snapshot and the link cannot read streamsurl
UNRECOVERABLErepair() when the log holds no whole-record op for the idid
UNPERSISTEDthe storage's error event: ops were logged — the write stands, and replicates — but their record files (or chain block) could not be written; the records are held in memory and the next open replays the batch onto the filesids, cause
REWOUNDthe sync error event, at a hello: the host holds more of this node's own ops than its log does — a directory restored from an older copy, or another instance with this identity. The node moves its streams past the host's and bootstraps from it; writes made since the restore and before this hello are lost to the hosturl, local, remote (vector keys of this node → seq)
UNDELIVERABLEthe sync error event, once per hello: the host lacks ops of this node's own that its log no longer retains (written before any node was registered, and compacted since); they are not senturl, missing (key → { host, retained })
SKEW, OP_UNREADABLE, OP_REFUSED, OP_INVALIDthe storage error event — see Storageop, plus rejected / entity + issues
HOOK_FAILEDa HookErrorhook, cause
VALIDATION_FAILEDa ValidationErrorentity, issues, cause

A refusal the host answers on the wire is not a SyncomError: it is a plain error with the numeric code wrpc transports (400 BAD_HELLO or PROTOCOL, 403 UNAUTHORIZED, SCOPES or UNSIGNED (a sync.signatures: 'require' host greeted by a node id that is no signing key), 409 SELF, 413OPS_PER_PUSH or BYTES_PER_PUSH, 428 HELLO_FIRST, 429OPS_PER_SECOND) and that constant as reason on the host's side — in the sync.hello.refused log line and on the thrown error; wrpc carries the code and the message. The dialer sees the numeric code through its error event.

HookError ​

What an operation rejects with when one of its hooks threw: name'HookError', code 'HOOK_FAILED', hook (the hook's name), cause (the original error). A SyncomError; exported from the package root.

ValidationError ​

What a write rejects with when the record would not pass its entity's validator — nothing was stamped, logged or written: name'ValidationError', code 'VALIDATION_FAILED', entity (the collection), issues ([{ path, message, code? }]), and cause when the validator threw something of its own. A SyncomError; exported from the package root.

Structural check for the link contract.

isSharedLog(value) ​

Structural check for the reserved SharedLog contract (append, read, parseId — the shape of wrpc's BrokerLog): what a broker-backed hub mode will take after this version. open({ sync: { sharedLog } }) is refused.

Operation log and model ​

OpLog ​

await new OpLog(basePath, { rollAfter, retention, logger, telemetry, sealApplied }) — storage.log is the one you want. With sealApplied (what the storage asks for) a roll seals only the batches marked applied.

MemberNotes
epoch, floor, next, vector, length, lastEventIdSee The log
append(ops, { skipped, held }?)Promise<LogEntry[]> — entries with their seqs, once on disk; skipped ops are flagged, held ones with the version they wait for
held()LogEntry[] — the entries held for a newer schema version
droppedHeld, resetDroppedHeld()Held entries compaction dropped since the last reset
scrub(id)Promise<number> — strips the payload of every entry of id and the nonce that salted its digest (scrubbed: true), in memory and on disk; the signature and the digest stay, so the header still verifies
read(eventId, { scopes, limit }?){ entries, last } — the entries in scopes after the id, and the seq of the last entry examined (in scope or not; where a feed's cursor may move), or null when it cannot be resumed
since(eventId, { scopes, limit }?)read(...).entries, or null when it cannot be resumed
entriesOf(id)Every retained entry of the record, oldest first — what repair() replays
advance(vector)Adopts a vector (after a snapshot)
tail()The unsealed entries a reopen replays
applied(from, to?)The batches within from..to are reflected in the records; under sealApplied a roll stops short of the oldest batch not yet applied
flush()Seals the open files now (what applied allows)
close()Seals what can be sealed and writes the index

applyOp(entry, op) / mergeEntry(a, b) / isAlive(entry) ​

The record-level model: apply one op to an entry ({ entry, changed, delta, rejected }), join two entries, and whether an entry is a live record or a tombstone. Pure functions; entry may be null.

CRDT ​

FunctionReturns
diff(source, target)The delta turning source into target
merge(source, delta)A new object with the delta applied
apply(source, history)The state after replaying an operation log

See CRDT deltas for the merge rules — numbers add rather than replace.

Chain ​

Blockchain ​

await new Blockchain(basePath).

MethodReturns
addBlock(data)Promise<string> — the new tail hash
readBlock(hash)Promise<Block> — throws if unknown
writeBlock(block)Promise<string>
writeChain()Promise<void> — persists the tail pointer
validate(options?)Promise<boolean> — { last, from }; an unknown from is false

Properties: path, tailHash. A block is { prev, timestamp, data }.

calculateHash(data) ​

SHA-256 over JSON.stringify(data), hex-encoded. Synchronous in Node.js, Promise<string> in the browser.

Contracts (subpath) ​

js
const { SmartContract, DataReader } = require('@alexify/syncom/contracts');

Off the package root on purpose: SmartContract.load runs code from the chain, and a process that never imports the subpath never does.

SmartContract ​

new SmartContract(name, proc, { storage, chain }).

  • execute(args) — runs proc(reader, args), saves the result under args.id, records failures as chain blocks and re-throws.
  • SmartContract.save(name, chain, proc) — Promise<string> (the block hash).
  • SmartContract.load(hash, { storage, chain }) — Promise<SmartContract>.

DataReader ​

new DataReader(storage); get(id) returns Promise<unknown>. The only storage access a contract has.

Crypto ​

FunctionNode.jsBrowser
generateKeys()Promise<KeyPair>Promise<KeyPair>
loadKeys(basePath)Promise<KeyPair>Promise<KeyPair>
encrypt(data, publicKey)stringPromise<string>
decrypt(data, privateKey)unknownPromise<unknown>
calculateHash(data)stringPromise<string>
generateSigningKeys()Promise<SigningKeyPair>Promise<SigningKeyPair>
loadSigningKeys(basePath)Promise<SigningKeyPair>Promise<SigningKeyPair>
sign(message, privateKey)stringPromise<string>
verify(message, signature, publicKey)booleanPromise<boolean>

KeyPair is { publicKey, privateKey }, PEM-encoded RSA-2048. encrypt returns a JSON envelope — AES-256-GCM under a fresh key, that key wrapped with RSA-OAEP (SHA-256) — identical on both platforms. loadKeys reads public.pem / private.pem from basePath, generating and writing them (private.pem owner-only where the backend has permissions) when neither exists, and rejecting with code: 'KEYS_INCOMPLETE' when only one does.

SigningKeyPair is { publicKey, privateKey, id }: a PEM-encoded Ed25519 pair and id, the raw public key in base64url (43 characters) — the node id of a storage that loaded it. loadSigningKeys reads sign-public.pem / sign-private.pem the way loadKeys reads the RSA pair. sign takes a string (UTF-8) and gives the 64-byte signature in base64url; verify takes the message, that signature and an id, and answers false — never a throw — for an id that is no key or a signature of the wrong shape. The op-level use of both is Signatures in the Sync guide.

Backends ​

A backend — an fsi — is five functions over "a directory and a file name": read(dir, file) → Promise<string>, write(dir, file, content, options?) (options.mode is a permissions hint the node:fs adapter honours), exists(dir, file) → Promise<boolean>, remove(dir, file), list(dir) → Promise<string[]> (file names, sorted; an absent directory is empty). write creates the directory; remove is a no-op when the file is absent. An optional sync(dir, file) is fsync.

The package root exports the platform's own — node:fs/promises on the server, OPFS in the browser — as those five functions, and:

ExportNotes
open({ fsi })Any object with the five functions; the storage, its log, chain, keys and registry all go through it. storage.fsi is what was given (wrapped when durable)
open({ durable: true })Every write is followed by the backend's sync; a backend without one (OPFS, memory) logs durable.noop at debug
create(dir, file, content) (optional)Exclusive create (EEXIST when the file is there) — node:fs and the memory backend have it; the directory lock uses it to make a race deterministic
isFsi(value)Whether value has the five functions
createMemoryFsi()A backend in memory: size, dump(); two storages opened over one share it
loadKeys(base, { fsi }), new Blockchain(base, { fsi }), new OpLog(base, { fsi })The pieces take the backend too

@alexify/syncom/idb is the IndexedDB backend: createIdbFsi({ name, store, indexedDB }?) returns an fsi over one object store, with a close() of its own (see Browser).

Search (subpath) ​

js
const { createSearch, normalize, trigrams } = require('@alexify/syncom/search');
ExportNotes
createSearch({ minOverlap }?)For open({ search }): a function that makes a fresh trigram index per searchable entity. minOverlap (0 to 1, default 0.3) is the share of a query's distinct trigrams a record must hold to match; anything else is a TypeError
normalize(text)What the index sees: decomposed, combining marks stripped, lower case, letters and digits only, one space between words
trigrams(text)The per-word, space-padded trigrams of the normalized text

An index is a TextIndex — put(id, text), remove(id), query(text) → [{ id, score }], every match, best first, ties by id — plus size, the records it holds. Any object with the three methods can stand in for it. The ranking and the costs are in Full-text search.

wrpc adapter ​

js
const { createWrpcLink, isSyncLink, SYNC_INTROSPECTION } = require('@alexify/syncom/wrpc');

createWrpcLink(client, { unit = 'syncom' }?) wraps a WrpcClient into a link; see wrpc transport.

Released under the MIT License.