-
Per-room compaction disable API. Three new methods on
Streamfor operationally freezing a room's Redis stream (e.g. for inspection or maintenance) without taking the room offline:stream.disableCompaction(room)— atomically removes the room's pending compact task from the worker queue and adds the room to the{prefix}:compaction_disabledset. While disabled, workers never pick up the room and writes don't enqueue new compact tasks (theaddMessagescript checks the set), so the room's stream is neither persisted nor trimmed; live update distribution to connected clients is unaffected.stream.enableCompaction(room)— removes the room from the disabled set and re-enqueues a compact task if the room's stream exists. No-op for rooms that aren't disabled.stream.getDisabledCompactionRooms()— lists all rooms with disabled compaction.
-
redis.clientOptionsconfig. Additional options passed through to the node-redis client, e.g.{ pingInterval: 10000 }for keepalive PINGs. y/hub still controlsurl;redis.socketis merged into the final socket config;clientOptions.scriptsare merged with y/hub's Lua scripts. (API docs,src/stream.js,src/types.js)
- A late-completing worker can no longer spawn a duplicate compact-task chain. When a worker runs longer than
taskDebounce, its compact task is reclaimed by another worker; both eventually finish and calltrimMessageswith the same task id. The XACK guard already prevented a duplicate re-enqueue, but the stream trim and the delete-when-empty ran unconditionally — so the late worker could DEL the room stream key while the reclaiming worker's successor task was still pending, and the next write (EXISTS == 0) would enqueue a second compact task. The result was two concurrent task chains for the same room: redundant compactions and recurring duplicate-key errors on persist (the hazard described in thequarantine()comment).trimMessagesnow gates all stream mutations (trim, delete, successor re-enqueue) on winning the XACK; a late completion is a pure no-op. (src/stream.js)
- Experimental native merge via yrs (
@y-crdt/yn). y/hub can optionally delegatemergeUpdatesto y-crdt/yn — a thin Node.js binding over yrs, the Rust port of Yjs — instead of running it in JavaScript. Off by default and not production-ready; intended for benchmarking the merge hot path. Enable withUSE_Y_NATIVE=1(or--use-y-native), read vialib0/environment.hasConf. Server and worker evaluate the flag independently. Only the threeY.mergeUpdatescall sites are affected — the inline fast path (src/compute.js), the worker-thread merge task (src/compute-worker.js), and the WebSocket sync fan-out (src/server.js); everything else (sync protocol, attribution metadata, delta/changeset computation, awareness, snapshots, undo) continues to run on@y/y. When the flag is off, behavior is unchanged. Caveats:@y-crdt/ynexposes onlyapplyUpdates(gc, updates)(no v2 update encoding), and protocol compatibility between yrs and@y/y14's attribution-laden updates is not verified. See the README for details. (src/y-utils.js)
- Consolidated
mergeUpdatesandmergeUpdatesAndGc. The compute pool's two merge entry points are now a singlemergeUpdates(gc, updates, logContext)wheregcselects whether deleted content is garbage-collected. The shared merge implementation lives insrc/y-utils.jsand is used by both the main thread and the worker pool, so the native/JS switch applies uniformly. (src/compute.js,src/compute-worker.js)
yhub.agentTask(room, opts, handler)— new import-API method for running LLM agent tasks against a room. The handler receives a freshly hydratedY.Doc(gc'd snapshot of the room's current state) and anAwarenessinstance bound to it; edits to either are streamed live to all connected clients with attribution. Options:author(user-id, mapped toinsert/deletecontent attributes),displayedAuthor(awarenessuser.name, defaults toauthor, never recorded in the contentmap),promptBy(sugar forcustomAttributions: [{ k: 'promptBy', v: promptBy }]),customAttributions(fullArray<{ k, v }>matching the WS/REST shape), andclearAwareness(seconds —0= clear immediately on exit,false= leave in place; errors always clear immediately). The returned promise resolves only after the awareness disconnect has been broadcast. Errors from the handler or from stream forwarding are surfaced to the caller. (src/agents.js, API docs)PATCH /ydoc/{org}/{docid}awareness support. Body shape is now{ update?, awareness?, customAttributions? }with bothupdateandawarenessoptional (at least one required).awarenesscarries bareencodeAwarenessUpdate(...)bytes — the same format the WS path puts on the stream — and is distributed to all connected clients through the same Redis channel.customAttributionsonly applies toupdate. (API docs,src/server.js)GET /ydoc/{org}/{docid}?awareness=true. Returns{ doc, awareness? }withawarenessas the merged room awareness in bare-bytes format — round-trippable through PATCH and directly consumable byapplyAwarenessUpdate. Omitted when the room has no awareness state. Default response shape (no flag) is unchanged. (API docs,src/server.js)
- Strip phantom local client in
mergeAwarenessUpdates. The y-protocolsAwarenessconstructor seeds its ownclientIDviasetLocalState({}), which leaked as a phantom empty-state client to every consumer of the merged bytes (WS initial sync, GET/ydoc?awareness=true). The merger now removes its own clientID before encoding, so thebyteLength > 3"empty awareness" check on the WS initial-sync path is now actually correct. (src/protocol.js)
- Stream quarantine API. Three new methods on
Streamfor operationally isolating a room whose updates repeatedly fail to compact, without taking the room offline:stream.quarantine(room)— atomically renames the live Redis stream to{prefix}:quarantine_room:{org}:{docid}:{branch}:{qid}and inserts a NOP entry into the (now empty) live key. The NOP uses a non-mfield so every read path ignores it; its purpose is to keep the live key non-empty so a subsequent write doesn't enqueue a duplicate compact task alongside the pre-quarantine one. Returns the generatedqid, ornullif there is no live stream to quarantine.stream.getQuarantineStreams(room)— returns the list of qids currently parked for a room.stream.getAllQuarantineStreams()returns{room, qid}pairs across every room.stream.unquarantine(room, qid)— re-injects every message from the quarantined stream back into the live stream via the standardaddMessagepath (re-enqueueing the compact task if the live stream had been drained) and deletes the quarantine key. Returns the number of messages re-injected. The read + re-inject + delete is batched in a singleMULTI/EXEC; quarantined streams are read-only by convention, so nothing writes between the XRANGE and the DEL.
- Switched to Pino logging. All logging now uses Pino instead of
lib0/logging. Log output is structured JSON by default; usepino-prettyfor human-readable output during development. All npm scripts now pipe throughpino-pretty. redis.tlsCaCertreplaced byredis.socket. Theredis.tlsCaCertconfig field has been replaced with a genericredis.socketobject that is merged into the Redis client socket config. See node-redis socket options for available options.decodeContentMapsAPI change. ThedecodeContentMapsfunction signature/return type has changed.
- Bumped Yjs to rc.2. Updated
@y/yto^14.0.0-rc.2andlib0to^1.0.0-rc.5. - Better error handling in WebSocket open handler. Errors during the WebSocket
opencallback are now caught and handled gracefully instead of crashing the connection. - Improved worker failure logging. Worker failures now produce more detailed log output for easier debugging.
- Reduce log verbosity. Avoid logging large objects and binary data in stream and worker logs. Log counts and summaries instead.
- Fixed rollback. Resolved a rollback bug introduced alongside the Yjs rc.2 upgrade.
- Redis TLS support (
tlsCaCert). Added an optionalredis.tlsCaCertconfig field that accepts a PEM-encoded CA certificate string for TLS connections (rediss://).
- Compute worker thread pool. All CPU-intensive Yjs operations (merge, rollback, changeset, activity, patch) are now offloaded to a pool of worker threads, keeping the main event loop free for I/O. Workers are created lazily up to
maxPoolSize(defaults tocpus - 1). Stale workers running longer than 30 minutes are automatically terminated and replaced. Dead workers (e.g. from uncaught exceptions) are detected and recycled. - Smart
mergeUpdates. Small merges (≤ 5kb or single update) run synchronously to avoid worker overhead; larger merges are offloaded to a worker thread.
- Fix
unsafePersistDocattribute names. Content attribute names (insert/insertAt/delete/deleteAt) were incorrect. (Thanks @PabloSzx — #43) - Catch all floating promises. Added
.catch()handlers to previously unawaited promises in the Redis stream, S3 persistence, worker startup, and HTTP request handlers, preventing silent failures. - Fix worker hang on
--inspect. Worker threads no longer inherit--inspectflags from the parent process, which caused them to fail when binding to the same debugger port. - Fix dead worker recovery. Workers that crash from uncaught exceptions are now correctly marked as dead before draining the task queue, preventing tasks from being sent to terminated threads.
- Activity API:
contentIdsfilter. Pass a base64-encodedY.ContentIdsto restrict activity results to changes that touch a specific set of Yjs content (e.g. a single YType attribute). Encode viabuffer.toBase64(Y.encodeContentIds(ids)).
yhub.unsafePersistDoc— new import-API method to write and attribute a Yjs update directly to the database without going through Redis/WebSocket. Useful for server-side migration scripts.- S3 reliability fixes — keepalive connections, automatic retry on transient failures, and graceful handling of nonexistent resources.
- Rollback API now uses the standard undo/redo model for KV (map) entries, matching the behaviour users expect from collaborative editors.
- Faster update merging — bumped
@y/ydependency for more efficient Yjs update merging.
- Fixed a remaining infinite-recursion crash in the activity API under certain document shapes.
- S3 multipart uploads — large documents are now uploaded to S3 in parallel chunks, avoiding timeouts and memory pressure on the server.
- Fixed infinite recursion in the activity API when
delta=truewas requested on certain documents.
- KeyDB support — KeyDB can now be used as a drop-in Redis alternative.
- Activity API:
customAttributionsresponse field — passingcustomAttributions=truenow returns the list of custom attribution key-value pairs associated with each activity entry (deduplicated when grouping is enabled).
- Activity & WebSocket: filter by custom attributions — the
/activityendpoint and the WebSocket connection both now accept awithCustomAttributionsquery parameter (key:valuepairs) to limit results to changes that carry matching attributions.
This release focused on performance and the new custom attributions feature. Y/hub now avoids loading YDocs into memory during sync, making it possible to handle very large documents (300MB+) and thousands of concurrent WebSocket connections without breaking a sweat. REST API responses are now cached via Redis for efficient repeated access.
- Documents are never loaded into memory during sync. Both WebSocket and REST endpoints now operate directly on binary-encoded updates, avoiding costly YDoc instantiation on every request. This drastically reduces memory usage and CPU overhead. (
src/server.js,src/index.js) - Support for very large documents (300MB+). Syncing huge Yjs documents works reliably for both WebSocket and REST clients.
- Thousands of concurrent WebSocket connections. Improved connection handling and error recovery allow the server to sustain high connection counts without degradation.
- Smart caching for REST API responses. The
/changesetand/activityendpoints cache computed results in Redis. Cache TTL adapts to computation time:cacheTtl + computeTime * 2. Configurable viaredis.cacheTtl(default: 5 seconds). (src/stream.js) - Optimized WebSocket initial sync. The server now sends
syncStep1after retrieving the document, improving sync reliability and reducing round-trips. The WebSocket provider timeout has been increased accordingly.
Custom key-value attributions can now be attached to changes and used to filter rollbacks and changesets. See the API documentation for full details.
- PATCH /ydoc - Accepts an optional
customAttributionsfield (Array<{ k: string, v: string }>) in the request body. Custom attributions are stored alongside standard attributions asinsert:<key>/delete:<key>attributes. (API docs,src/server.js) - POST /rollback - Two new optional body fields:
customAttributions- attach custom attributions to the rollback (undo) changes themselves.withCustomAttributions- filter which changes to undo by matching custom attribution key-value pairs. (API docs,src/server.js)
- GET /changeset - New
withCustomAttributionsquery parameter usingkey:value,key:valueformat to filter changesets by custom attributions. (API docs,src/server.js) - Rollback safety. The rollback endpoint now returns a
400error when called without any filter (from,to,by,contentIds, orwithCustomAttributions), preventing accidental full-document reverts.
- Fixed S3 persistence race condition when handling concurrent file transfers. (
src/plugins/s3.js) - Fixed task cleanup ordering in the worker by sorting redis clock values correctly before determining the last persisted clock. (
src/persistence.js) - Improved WebSocket error handling. Client message processing is now wrapped in try/catch, and connections are properly cleaned up on errors. (
src/server.js) - Handle unacknowledged worker tasks. Ghost tasks in the Redis worker stream are now detected and cleaned up automatically. (
src/stream.js)
- Bumped
redisclient to^5.10.0. - Bumped
@y/protocolsto^1.0.6-3.