This document describes the data models, schemas, and storage architecture introduced in the latest version of YHub.
YHub uses a dual-storage architecture:
- PostgreSQL for persistent document state
- Redis Streams for real-time message distribution and task queues
All binary content follows a versioned schema approach, enabling future format migrations without breaking compatibility.
- FAST lookups of documents and editing traces
- Better integration of collab into existing backends
- Plugin architecture for persistence, task management, custom callbacks on events
- Future compatibility
- infinitely scalable
- In the future: LOCAL FIRST, sync all organization documents
"rooms" is the concept how we share data. Data in the same "room" is shared. The
websocket provider subscribes to rooms. For most applications we connect to a
single document: room = { org: string, docid: string, branch: string }.
In future releases, we could also subscribe to all documents in a whole
organization (for offline sync): room = { org: string }.
All binary data in YHub has an explicit schema with version information. This approach enables:
- Forward compatibility when introducing new encodings
- Safe migrations between schema versions
- Type-safe serialization using lib0's schema-based encoding
| Schema | Version | Purpose |
|---|---|---|
id:ydoc:v1 |
v1 | Y.js document asset identifier |
id:contentmap:v1 |
v1 | Content map asset identifier |
id:contentids:v1 |
v1 | Content IDs asset identifier |
asset:ydoc:v1 |
v1 | Binary-encoded Y.js update |
asset:contentmap:v1 |
v1 | Content map binary data |
asset:contentids:v1 |
v1 | Content IDs binary data |
asset:retrievable:v1 |
v1 | Reference to external storage (plugin) |
ydoc:update:v1 |
v1 | Y.js update message (Redis) |
awareness:v1 |
v1 | Awareness protocol message (Redis) |
compact |
current | Document compaction task |
CREATE TABLE yhub_ydoc_v1 (
org text,
docid text,
branch text,
t text, -- redis identifier (timestamp)
created INT8, -- Unix timestamp in milliseconds
gcDoc bytea, -- Garbage-collected Y.js update
nongcDoc bytea, -- Non-garbage-collected Y.js update
contentmap bytea, -- Content map binary
contentids bytea, -- Content IDs binary
PRIMARY KEY (org, docid, branch, t)
);This simplified table layout provides several advantages:
-
Persistence Plugin Integration: Each column stores schema-encoded assets that can be intercepted by persistence plugins (e.g., S3) before storage. When a plugin handles an asset, a
asset:retrievable:v1reference is stored instead. -
Partial Non-GC Document Retrieval: By storing non-garbage-collected documents (
nongcDoc) at regular intervals with timestamps, we can query for recent non-GC states without loading years of history. This enables efficient retrieval of document versions with full edit history for recent changes only. -
Multiple Versions Per Document: The composite primary key
(org, docid, branch, t)allows storing multiple snapshots of each document over time, supporting:- Point-in-time recovery
- Incremental compaction
- Audit trails
-
Selective Column Loading: Queries can request only the columns needed (gc, nongc, contentmap, contentids), avoiding unnecessary data transfer.
Asset IDs uniquely identify stored content and encode enough information for retrieval and caching:
// Y.js Document Asset ID
{
type: 'id:ydoc:v1',
org: string, // Organization namespace
docid: string, // Document identifier
branch: string, // Branch name (e.g., 'main')
t: string, // Timestamp clock (e.g., "1704067200000-1")
gc: boolean // Whether this is garbage-collected
}
// Content Map Asset ID
{
type: 'id:contentmap:v1',
org: string,
docid: string,
branch: string,
t: string
}
// Content IDs Asset ID
{
type: 'id:contentids:v1',
org: string,
docid: string,
branch: string,
t: string
}Asset IDs are serialized to strings for use as cache keys and storage paths:
id:ydoc:v1/{org}/{docid}/{branch}/{gc:0|1}/{timestamp}
id:contentmap:v1/{org}/{docid}/{branch}/{timestamp}
id:contentids:v1/{org}/{docid}/{branch}/{timestamp}
The asset ID system enables flexible caching solutions:
- Cache Keys: The deterministic string format creates stable cache keys
- Plugin-Based Caching: A persistence plugin can implement Redis-backed caching by:
- Intercepting
store()calls to cache assets - Intercepting
retrieve()calls to check cache before storage - Using asset ID strings as Redis keys
- Intercepting
- TTL-Based Expiration: Cache entries can use the
createdtimestamp for TTL policies - Branch-Aware Caching: Different branches can have different caching policies
Example cache implementation as a persistence plugin:
{
async store(assetId, asset) {
const key = assetIdToString(assetId)
await redis.setex(key, TTL, encode(asset))
return null // Continue to next plugin
},
async retrieve(assetId, assetInfo) {
const key = assetIdToString(assetId)
const cached = await redis.get(key)
return cached ? decode(cached) : null
}
}The Y.js document (ydoc) is rarely loaded into memory. The system is designed to:
- Stream Updates Directly: Updates flow through Redis streams without instantiating Y.js documents
- Compact Without Full Load: Document compaction merges binary updates without creating Y.js instances when possible
- Defer Parsing: Binary updates are stored and forwarded as-is
The non-garbage-collected document (nongcDoc) is never loaded into memory during normal operations. It exists solely for:
- Historical retrieval of full edit sequences
- Compliance/audit requirements
- Recovery scenarios
By storing non-GC snapshots at regular intervals, clients needing edit history can retrieve only recent non-GC data rather than the complete document history.
YHub uses Redis Streams for distributed task processing:
- Worker Stream:
{prefix}:worker(default:yhub:worker) - Consumer Group:
{prefix}:worker - Consumer Name: UUID per worker instance
Currently, the task queue supports document compaction tasks:
{
type: 'compact',
room: {
org: string,
docid: string,
branch: string
},
redisClock: string // Redis stream message ID for correlation
}- Creation: When a new message arrives for a room with no existing stream, a
compacttask is added to the worker queue - Debounce: Tasks have a configurable delay (default: 10 seconds) before being claimed, allowing message batching
- Processing: Worker claims task, compacts document, persists to PostgreSQL
- Completion: Task removed, Redis stream trimmed
- Continuation: If messages remain after trim, a new task is re-queued
The creation step relies on EXISTS(liveStream) == 0 as the signal to enqueue, which means there is at most one pending task per live room at any time. Operations that remove the live key (notably Stream.quarantine) must leave a NOP entry behind so this invariant is preserved across the operation.
The task queue triggers actions when document events occur:
- Document Compaction: Merge incremental updates into consolidated state
- Callback URLs: Notify external services of document changes
- Custom Handlers: Extensible event processing
Messages distributed via Redis Streams follow versioned schemas:
{
type: 'update:v1',
update: Uint8Array, // Y.js binary update
attributions: Uint8Array | null // Optional attribution data
}{
type: 'awareness:v1',
update: Uint8Array // Awareness protocol binary data
}- Room Streams:
{prefix}:room:{org}:{docid}:{branch}(URL-encoded components) - Quarantined Room Streams:
{prefix}:quarantine_room:{org}:{docid}:{branch}:{qid}(see Quarantine) - Message Field: Each message stored with field
mcontaining the encoded buffer. Entries whose field is something other thanmare skipped by every read path (used for NOP markers — see Quarantine). - Clock Format:
"{timestamp}-{sequence}"(e.g.,"1704067200000-5")
- Messages added to room streams via
XADD - Subscribers receive messages via
XREADwith blocking - Messages retained for minimum lifetime (default: 1 minute)
- Trimmed during compaction based on age
Operational recovery path for rooms whose updates repeatedly fail to compact. Exposed on the Stream instance as quarantine(room), getQuarantineStreams(room), getAllQuarantineStreams(), and unquarantine(room, qid).
- Quarantine key:
{prefix}:quarantine_room:{org}:{docid}:{branch}:{qid}. One key per quarantined snapshot;qidis a fresh UUID, so repeated quarantines on the same room accumulate rather than overwrite. - Invariant preserved: the compact worker queue holds at most one pending task per live room.
quarantineatomically renames the live stream to a quarantine key and inserts a NOP entry (fieldnop, notm) into the now-empty live key. The NOP keepsEXISTS(live) == 1, so a subsequentaddMessagedoes not enqueue a second compact task alongside the pre-quarantine one. Without the NOP, two tasks for the same room would race the worker into duplicatepersistence.storecalls at the samelastClock. - Quarantined streams are read-only by convention: nothing in the system writes to
quarantine_room:*keys.unquarantinerelies on this when it XRANGEs the contents and then DELs the key in a follow-up write — concurrent writers would silently lose data. - NOP entries are ignored by the normal read path (
getMessagesfilters onmessage.m != null) and trimmed by the usualXTRIM MINIDwhen they age pastminMessageLifetime.
interface PersistencePlugin {
pluginid: string;
// Initialize plugin (e.g., create buckets)
init?(api: Api): Promise<void>;
// Store asset, return retrievable reference or null to continue chain
store?(assetId: AssetId, asset: Asset): Promise<RetrievableAsset | null>;
// Retrieve asset from external storage
retrieve?(assetId: AssetId, assetInfo: Asset): Promise<Asset | null>;
// Delete asset from external storage
delete?(assetId: AssetId, assetInfo: Asset): Promise<boolean>;
}The S3PersistenceV1 plugin offloads assets to S3:
- Storage Path: Uses asset ID string as S3 object key
- Branch Filter: Only stores assets from
mainbranch by default - Returns:
{ type: 'asset:retrievable:v1', plugin: 'S3Persistence:v1' }
Multiple plugins can be chained:
- Each
store()call passes through plugins in order - First plugin returning a
RetrievableAssetstops the chain - Remaining plugins see the reference, not the original asset
All schemas follow the pattern {category}:{name}:{version}:
- Category:
id,asset,ydoc,awareness, etc. - Name: Specific type within category
- Version:
v1,v2, etc.
This enables:
- Adding new versions without breaking existing data
- Parallel support for multiple versions during migration
- Clear identification of data format in storage