Skip to content

Backends & clustering

A backend decides where collection state lives, how durable it is, and how it replicates across a cluster. Backends are drop-in — swapping one is a one-line change; nothing above it moves.

There are two backend seams, because the two consistency models are stored differently:

  • collections: — the row collection backend (one per server, a single transaction domain → atomic cross-collection batches).
  • crdtCollections: — the CRDT document collection backend (separate; CRDT never joins a cross-collection atomic batch).

Just want to pick one?

This page is the reference — every capability, every option, every limit. For the decision itself, read choose a collection backend first; it gets you to a name in two questions.

The axes

A row backend is described by three properties. The first is obvious; the other two are declared on the backend itself and decide what it can promise you across nodes.

Durability

Whether state outlives the process: in-memory, a local file, or a central database.

Clustering: relay vs. self

  • relay — each node holds a full replica and replicates writes over the server↔server adapter (Redis/libp2p/RabbitMQ/ZeroMQ). Simple, adapter-driven.
  • self — the backend owns a central Postgres and a per-node Electric-synced replica, and needs no adapter at all. Cross-node convergence rides Postgres + Electric instead of super-line's fan-out.

Don't confuse self clustering with the transport or the adapter — a self backend bypasses the adapter entirely.

Coordination: local vs. cluster

Clustering says how a write reaches other nodes. Coordination says whether the cluster can agree at the moment of the write — which is a stronger and separate guarantee:

  • coordination: 'local' (memory · sqlite) — a conditional write is checked against this node's rows. Two nodes can each find the same condition satisfied and both proceed.
  • coordination: 'cluster' (pglite) — conditional writes are serialized through the central Postgres, so exactly one node wins a contested claim.

Adding an adapter does not upgrade local to cluster: the adapter carries fan-out, not serialization. This distinction is what makes plugin-queue single-node on memory and sqlite, and cluster-wide on pglite.

Row backends

collections-memorycollections-sqlitecollections-pglite
Durabilityprocess memorySQLite file (WAL)central Postgres
clusteringrelayrelayself
coordinationlocallocalcluster
Survives a restartnoyesyes
Converges across nodesvia adaptervia adaptervia Electric
Cluster-wide conditional writesnonoyes
A new node sees pre-existing statenoits own file onlyyes
External infrastructurenonea writable pathPostgres + Electric
Factorysyncsyncasync
Needs the contract collections mapnoyesyes
Query executionJS evaluatorSQL when compilable, else JSSQL narrowing, JS authoritative
Row timestamps in Control Centeryesyesyes
ts
// swap the backend — nothing else changes
import { sqliteCollections } from '@super-line/collections-sqlite'
createSuperLineServer(api, { /* … */, collections: sqliteCollections({ file: './data.db', collections: api.collections }) })

memoryCollections()

Takes no arguments. Holds every collection's rows in nested maps and applies batches atomically through an undo log, so a failed batch leaves nothing behind. Zero dependencies.

sqliteCollections(options)

Synchronous. Sets journal_mode = WAL and synchronous = NORMAL.

OptionTypeNotes
filestringPath to the database file. :memory: for an ephemeral store.
collectionsRecord<string, CollectionDef>Required. The contract's post-plugin-merge collections map — each LWW collection gets its own typed table.

pgliteCollections(options)

Asyncawait it. Writes and strong reads go to the central Postgres; change delivery arrives through this node's Electric-fed local replica.

OptionTypeNotes
pgUrlstringRequired. Central Postgres — source of truth for writes and strong reads.
electricUrlstring?The Electric shape endpoint streaming each table into this node's replica. Required in practice: without a fed replica onChange never fires. Omit only if you pass db and feed the replica yourself.
collectionsRecord<string, CollectionDef>Required. As above.
tablePrefixstring?Prefix for the per-collection tables and the <prefix>meta fingerprint table. Defaults to col_.
dbPGliteWithLive?Advanced/testing: supply the local replica yourself (needs the live extension).

Postgres must run with wal_level=logical for Electric to replicate.

Typed tables

The SQL backends store each LWW collection in its own typed table (col_<name>): scalar schema fields become real columns, everything else a per-field JSON column, derived from the contract — so both factories take the contract's collections map. collections-sqlite compiles the query IR against those columns (an exactly-compilable query runs entirely in SQL); collections-pglite is the self-clustering tier (central Postgres + one Electric shape per table streaming into each node's replica).

Which validators get typed columns

super-line reads a schema's shape through Standard JSON Schema, the companion spec to Standard Schema — so it never assumes a particular library and every implementer plans identically. That covers Zod 4.2+, Zod Mini 4.2+, ArkType 2.1.28+, VineJS 4.3+, Sury 11+, stnl 2.1+, and Valibot 1.2+ (wrap the schema in toStandardJsonSchema() from @valibot/to-json-schema).

A validator whose library hasn't implemented the companion spec still works — it just can't be introspected, so its collection falls back to the key column plus one _sl_data JSON column. Everything is still validated, stored and queried correctly; you only lose SQL-level column typing and pushdown. The same fallback applies per-field to anything JSON Schema can't express, such as a .transform().

What disqualifies a query

Not every query compiles to SQL — a few operators can't be translated without diverging from the JS evaluator's semantics, so collections-sqlite falls back to a full table scan (JS-filtered, and JS-sorted if orderBy is affected too):

  • like / ilike — SQLite's LIKE case rules don't match the evaluator's regex semantics.
  • neq on a JSON-backed column — a non-scalar schema field (record/union/nested/optional+nullable), where SQL's 1/0 vs true/false collide under IS NOT.
  • Any text range comparison (lt/lte/gt/gte against a string) or text orderBy — SQLite orders text by UTF-8 bytes, the JS evaluator by UTF-16 code units, and the two disagree on astral-plane characters.

None of this affects correctness — the JS evaluator stays authoritative either way — but it's a silent perf cliff: a filter or sort that touches one of these falls back to scanning and sorting the whole table in JS instead of letting SQLite do it.

collections-pglite works differently: it compiles a superset-safe WHERE to narrow what it reads from Postgres, then always re-applies the full query in JS. The SQL is an optimization that never has to be exact, so there is no equivalent cliff — but also no full pushdown of sorting or limits.

CRDT backends

collections-crdt-memorycollections-crdt-libsqlcollections-crdt-pglite
Durabilityprocess memorylibsql / Turso, snapshot per doccentral Postgres Yjs op-log
clusteringrelayrelayself
Survives a restartnoyesyes
Converges across nodesvia adaptervia adaptervia Electric
External infrastructurenonea file, or Turso/sqldPostgres + Electric
Factorysyncasyncasync
Boot costnonerehydrates every documentreplica sync
Write persistencedebounced snapshotop-log append

CRDT backends expose clustering but no coordination flag. There are no conditional writes to serialize — merge is the conflict-resolution model — so the axis doesn't apply.

Validate-before-commit isn't a backend axis either: all three behave identically, validating at the ingress node while relay nodes trust an already-checked delta. Whether a document is validated at all is declared on the contract per collection — crdt: { validate: false } makes the server pass no validator, so the backend skips the merge-and-check entirely. See turning ingress validation off.

collections-crdt-memory also exports the universal crdtCollectionsClient() — one client engine pairs with every backend tier (the client only merges opaque deltas).

crdtMemoryCollections()

Takes no arguments. The Yjs merge engine every other tier wraps or mirrors.

crdtLibsqlCollections(options)

Async — it rehydrates every document (history-preserving) before returning a ready backend, so boot time and memory both scale with total document count. Persistence is a debounced onChange subscriber, keeping the apply hot path synchronous.

OptionTypeNotes
urlstringRequired. file:x.db, :memory:, libsql:// (Turso), or http(s):// (sqld).
authTokenstring?Auth token for Turso Cloud.
tablestring?Table this backend owns. Defaults to crdt_docs.
debounceMsnumber?Coalesce rapid edits into one snapshot write. Defaults to 250.
docOptions(collection: string) => DocOptions | undefinedNeeded to rehydrate each document's Yjs state on boot. Derive it from the contract: (n) => contract.collections[n]?.crdt.

A clean close() flushes pending edits; a hard kill loses at most debounceMs of writes.

crdtPgliteCollections(options)

Async. Client writes are validated at the ingress node, then appended as opaque Yjs deltas to an append-only op-log in Postgres, which Electric streams to each node's replica.

OptionTypeNotes
pgUrlstringRequired. Central Postgres — the op-log and existence source of truth.
electricUrlstring?Shape endpoint. Required in practice: without a fed replica no document change reaches a subscriber.
tablestring?Creates <table> (existence + materialized snapshot) and <table>_updates (the op-log). Defaults to crdt_docs.
docOptions(collection: string) => DocOptions | undefinedMust agree with the collection's crdt options on the contract.
compactfalse | { everyNUpdates?, debounceMs? }Op-log compaction: fold the log, materialize a baseline, trim superseded rows. Bounds op-log growth. false disables it.
dbPGliteWithLive?Advanced/testing: supply the local replica yourself.
onError(err, ctx) => voidCalled when a background op-log append fails. Server co-writes are synchronous and can't reject to the caller, so this is the only place that failure is observable. Defaults to console.error.

See validate-before-commit for why op-log compaction demands tolerant schemas.

Cold start & convergence

relay clustering fans out live batches over the adapter. It performs no state transfer to a node that joins — a node starts from whatever its own local store holds and converges only forward from that point:

  • memory · crdt-memory — a restarted node comes back empty and never receives anything written before it booted.
  • sqlite · crdt-libsql — a restarted node comes back with its own file, missing everything written while it was down.

That is fine on a single node, and fine for state that is naturally re-created (presence, ephemeral sessions, caches). It is a real hazard for long-lived rows across a multi-node relay deployment.

One file per relay node

Don't point two nodes at the same SQLite file on a shared volume. A relay backend expects a node-local store: each node applies every relayed batch to its own copy, so a shared file receives the same batch once per node. If you want a genuinely shared database, that is the self tier.

The self tier has no cold-start gap. Each node's replica is fed from the central Postgres, so a fresh node syncs current state on connect and reads stay strongly consistent against the centre.

Advisory foreign keys

references on a contract collection is metadata; opt into an existence check with checkReferences: true on the server. It's advisory:

  • best-effort under relay clustering (no global serialization point),
  • no cascades,
  • doesn't resolve intra-batch parent-then-child references.

For strict referential integrity, use a self backend (a central Postgres serialization point) or route the write through a request handler that checks explicitly. The metadata still feeds the Control Center schema graph and the TanStack adapter's join hints regardless of whether the check is on.

Next

Released under the MIT License.