Skip to content

Changelog

The first version of pg-crdt-client, in client/: a JavaScript library for live editors of collaborative text. Plain ESM with JSDoc types, no dependencies and no build step, for Node.js 20+ and browsers. Not published to npm yet. See JavaScript client.

  • Open and edit. openYdoc({transport, doc, replica}) loads a document into a local replica that writes under an entry key (bob:laptop). insert(index, text) and delete(index, length) take UTF-16 positions and convert them to code points, refusing a position inside a surrogate pair and text that Postgres cannot store (a lone surrogate, U+0000).
  • Sync. sync() sends the queued ops through ydoc_apply, then pulls ydoc_ops_since from the local version. Edits between two syncs travel as one op, under the 256-run limit. One sync runs at a time, and a failed one is retried by sending the same ops again, which ydoc_apply accepts as a no-op when the server had applied them.
  • Change events. on('change') reports local and remote changes as {from, to, insert} ranges in UTF-16 units of the previous text, as CodeMirror’s changes are.
  • Refused ops. An error with a SQLSTATE of class 22, 42, 54 or P0 stops the client: one rejected event, error, and a YdocRejectedError from every later sync(). Other errors are transient. This holds for transport.opsSince during sync() as well (the document was dropped, access was revoked); then no op was refused and op is null in the event and on the error.
  • Safe listeners. A change or rejected listener that throws no longer breaks insert, delete or sync(), or hides the YdocRejectedError, or stops the listeners after it. Every listener is called, and its error is rethrown on a timer (an uncaught exception in Node, window.onerror in a browser).
  • Keys are checked like text. openYdoc refuses a replica key with U+0000 or a lone surrogate, which Postgres cannot store, with a TypeError before it creates or loads anything.
  • Transports are yours. Two functions, apply(doc, op) and opsSince(doc, vv). The docs show node-postgres (docs/examples/node-postgres-transport.mjs) and a browser fetch to your API.
  • One engine. The client’s engine is the JavaScript reference that the extension is differential-tested against, moved from experiments/text-ref/ref.mjs to client/src/doc.js (which ref.mjs re-exports) and freed of Node’s Buffer. The fixtures and the automerge-paper trace regenerate byte for byte.
  • Tests. Unit tests, a 1,000-seed convergence fuzz with lost requests and responses over an in-memory server that validates like ydoc_apply, and an integration test on Postgres 16, 17 and 18. See the test suite.

The first release adds the PN-Counter and collaborative text in the schema crdt. The PN-Counter is a counter that goes up and down, which several writers can change independently and merge later. It is SQL and PL/pgSQL only, needs no superuser, and works with any Postgres 16, 17 or 18 client, driver or API layer.

  • Type. The domain crdt.pn over jsonb, with a versioned envelope {t, v, p, n}. See PN-Counter.
  • Functions. pn_empty, pn_increment, pn_decrement, pn_value, pn_merge, the aggregate pn_merge_agg, and pn_is_valid. pn_value is IMMUTABLE, so it works in generated columns and expression indexes. See PN-Counter functions.
  • Untrusted writers. pn_apply_delta(state, delta, replica) applies a delta from a writer you do not trust. It rejects entries that belong to another replica and malformed deltas, and treats stale entries as no-ops, so retries succeed. See Accept writes from untrusted clients.
  • Identity. current_replica_id() is the default identity: a JWT claim from request.jwt.claims (sub, or the claim named by crdt.replica_id_claim), else the setting crdt.replica_id. Nothing assumes a platform or UUID ids. A replica id is any non-empty text without :, and a key may carry a device suffix (alice:phone). See Replicas and identity.
  • Install. One SQL file, as a plain script, through pg_tle or with the control file. The install refuses a crdt schema that another role controls. See Install.
  • Collaborative text (ydoc_). A plain-text sequence CRDT (YjsMod order) whose documents live in the extension’s tables, one row per run of characters; an app’s table references crdt.ydoc_docs (uid). A document is named by a text id, like a replica id: ydoc_create(id) takes the id the client chooses (a UUID, a UUIDv7 or ULID, a room name, an app key; at most 256 bytes, no control characters) or makes a random UUID, and raises 23505 for an id that exists instead of returning the existing document, so a predictable id cannot be taken over. Every function takes the text id; the tables keep an internal bigint. Local edits by position (ydoc_insert, ydoc_delete), whole-text saves with a three-way diff against a base (ydoc_set, ydoc_base), sync between copies (ydoc_ops_since, ydoc_merge), an untrusted write path that accepts only the writer’s own ids and enforces causal rules V0 to V7 (ydoc_apply), and maintenance (ydoc_compact, ydoc_rejected_purge to empty the log of refused waiting ops, ydoc_drop). ydoc_version raises P0002 for a document that does not exist, like the writers; the readers ydoc_value, ydoc_length and ydoc_base return null. The tables have no grants: apps grant them to the owner of their security definer wrappers. With create extension they are marked for pg_dump. Promoted from experiments/text-table. See Collaborative text, the guide and the reference.
  • Collaborative text in a column (yseq_, experimental). The domain crdt.yseq over jsonb holds a whole document (the same YjsMod algorithm and op format as ydoc_) in a column of your table: yseq_empty, yseq_new (an empty document with a random document id, for a column default), yseq_value (IMMUTABLE, so a generated column or a full-text index can use it), yseq_length, yseq_version, local edits that return the new state (yseq_insert, yseq_delete, yseq_set with a three-way base), yseq_diff for the op of an edit, yseq_ops_since, yseq_merge and the aggregate yseq_merge_agg for trusted states, the untrusted write path yseq_apply with the causal rules V0 to V7, and yseq_is_valid. No tables and no grants: the privileges on your table decide who writes the column. Experimental because every edit rewrites the whole value (in the benchmarks, 3.4 kB of WAL per keystroke at 1 kB of text and 78.7 kB at 100 kB, with TOAST churn at keystroke rate), tombstones are never collected, equivocation is not detected, and merge, apply and ops_since cost O(document). Needs a UTF8 database. Promoted from experiments/text-column, whose U&'\FFFD' literal stopped the file from installing in a database that is not UTF8 (now chr(65533)). See Collaborative text and the reference.
  • Tests. The specification in test/spec/ covers the merge laws, convergence under shuffled, duplicated and stale delivery, concurrency, the install paths (including drop extension and pg_dump of the text tables) and the write paths for untrusted writers, on Postgres 16, 17 and 18. For both text shapes it adds differential tests against a JavaScript reference implementation (71 fixtures, 14 of them malicious, and the automerge-paper editing trace). See the test suite.
  • Docs: benchmarks. Each type page ends with a Benchmarks section: pn_ on PN-Counter, and yseq_ and ydoc_ each at the end of their part of Collaborative text. Each has a bottom line, tables of size, latency, WAL and bloat, and the setup and what was not measured, copied from the benchmark reports in research/benchmarks/.

Known limits: the counter’s state grows with the number of replicas, no compaction for delta tables, text is plain text and keeps its tombstones, the yseq_ column’s cost grows with the document, and no upgrade path yet. See Limits.

Dropping the extension, or the schema of a direct install, with cascade drops every column of type crdt.pn or crdt.yseq, with its data. Dropping the extension drops every ydoc_ document. See Install.