Changelog
JavaScript client 0.0.1 (unreleased)
Section titled “JavaScript client 0.0.1 (unreleased)”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)anddelete(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 throughydoc_apply, then pullsydoc_ops_sincefrom 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, whichydoc_applyaccepts 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
rejectedevent,error, and aYdocRejectedErrorfrom every latersync(). Other errors are transient. This holds fortransport.opsSinceduringsync()as well (the document was dropped, access was revoked); then no op was refused andopisnullin the event and on the error. - Safe listeners. A
changeorrejectedlistener that throws no longer breaksinsert,deleteorsync(), or hides theYdocRejectedError, or stops the listeners after it. Every listener is called, and its error is rethrown on a timer (an uncaught exception in Node,window.onerrorin a browser). - Keys are checked like text.
openYdocrefuses a replica key with U+0000 or a lone surrogate, which Postgres cannot store, with aTypeErrorbefore it creates or loads anything. - Transports are yours. Two functions,
apply(doc, op)andopsSince(doc, vv). The docs show node-postgres (docs/examples/node-postgres-transport.mjs) and a browserfetchto 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.mjstoclient/src/doc.js(whichref.mjsre-exports) and freed of Node’sBuffer. 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.
Extension 0.0.1
Section titled “Extension 0.0.1”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.pnoverjsonb, with a versioned envelope{t, v, p, n}. See PN-Counter. - Functions.
pn_empty,pn_increment,pn_decrement,pn_value,pn_merge, the aggregatepn_merge_agg, andpn_is_valid.pn_valueisIMMUTABLE, 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 fromrequest.jwt.claims(sub, or the claim named bycrdt.replica_id_claim), else the settingcrdt.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
crdtschema 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 referencescrdt.ydoc_docs (uid). A document is named by atextid, 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 raises23505for 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 internalbigint. 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_purgeto empty the log of refused waiting ops,ydoc_drop).ydoc_versionraisesP0002for a document that does not exist, like the writers; the readersydoc_value,ydoc_lengthandydoc_basereturn null. The tables have no grants: apps grant them to the owner of theirsecurity definerwrappers. Withcreate extensionthey are marked forpg_dump. Promoted fromexperiments/text-table. See Collaborative text, the guide and the reference. - Collaborative text in a column (
yseq_, experimental). The domaincrdt.yseqoverjsonbholds a whole document (the same YjsMod algorithm and op format asydoc_) 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_setwith a three-way base),yseq_difffor the op of an edit,yseq_ops_since,yseq_mergeand the aggregateyseq_merge_aggfor trusted states, the untrusted write pathyseq_applywith the causal rules V0 to V7, andyseq_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 fromexperiments/text-column, whoseU&'\FFFD'literal stopped the file from installing in a database that is not UTF8 (nowchr(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 (includingdrop extensionandpg_dumpof 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, andyseq_andydoc_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 inresearch/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.