Files
zeekayandHanzo Dev 72d038cb61 A 0.1.x database opens with its documents, and a DSN path is refused by name
0.1.x kept each document as one row of docs(doc_id, state), every message it
received concatenated. The frame store never read that table, so the first
start of a later image on a 0.1.x volume served every document empty.

NewSQLite now moves docs into doc and frame, in one transaction, and drops
it. A state that splits into whole y-websocket messages keeps its content
messages (sync step 2 and update) as frames, in order: what Append stores for
the same messages. A state that does not split is kept whole as one frame,
which is what 0.1.x replayed. Migrated frames follow any the document already
has. Each row is deleted before its frames are written, so they reuse its
pages; if they still outgrow COLLAB_STORE_BYTES, the transaction rolls back,
docs is left as it was, and startup fails with "migrate 0.1 docs".

pkg/frame holds the y-websocket framing: Content, which the room already used
to decide what to store, and Split, which the migration uses.

COLLAB_SQLITE_PATH is a file path; the store sets its own pragmas. 0.1.1 took
a driver DSN there, and a DSN given to this store became "file:file:..." and
failed as "unable to open database file: out of memory (14)". A "file:" URI
or a path with a query is now refused before anything opens, naming it.

Tests seed a database with 0.1.x's own DDL and upsert: its Yjs update and
step 2 come back as frames, a non-Yjs state comes back whole, a document with
frames keeps them first, a migration larger than half the cap fits, one that
cannot fit leaves docs byte for byte, and reopening migrates nothing twice.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-23 16:22:09 -07:00

4.9 KiB

collab

collab

Hanzo realtime collaboration relay. Replaces Huly's collaborator Node service with a single static Go binary.

clients (Y.js) ──WS──> collab ──Append──> SQLite
                  └─ fan-out to peers in the same room

API

Method Path Notes
GET /v1/health {"status":"ok","version":"..."}
WS /v1/collab/<doc_id> Subprotocol yjs. Binary frames relayed + persisted.
GET /v1/metrics Prometheus exposition.

doc_id MUST be <orgID>:<workspaceID>:<docID>. The IAM JWT's owner claim must equal <orgID> or the upgrade is rejected with 403.

Auth

Bearer JWT in Authorization: Bearer <token>, or, from a browser (whose WebSocket API cannot set headers), as a bearer.<token> entry in the subprotocol list beside yjs:

new WebSocket(url, ["yjs", "bearer." + token])

A ?token= query parameter is not read: the edge writes query strings to its access log. Tokens are verified against the IAM JWKS at IAM_JWKS_URL (default https://hanzo.id/v1/iam/.well-known/jwks); when a refresh fails, the keys of the last good fetch keep verifying.

Sync protocol

The server is a dumb relay — it does NOT merge Y.js updates. Every binary frame is relayed to the other peers in the room. A frame is stored when its y-websocket header says it carries document content (messageSync followed by syncStep2 or syncUpdate); sync step 1 and awareness are relayed only. Applying the stored updates in order rebuilds the document, so persistence is append-only frames. We revisit this only when cross-region merge or server-side validation is required.

On peer join the server writes each stored frame as its own binary message, in order, before relaying live updates.

Limits

Limit Value On breach
Frame size 1 MiB connection closed, 1009
Frame rate per connection 50/s, burst 200 connection closed, 1008
Stored bytes per document 16 MiB frame relayed, not stored (store_full)
Database file COLLAB_STORE_BYTES frame relayed, not stored (store_append)

Each connection has at most one append in flight: it reads its next frame only after the last one is stored. The database runs a rollback journal, which holds only the pages one transaction changes, so the files on disk never exceed twice COLLAB_STORE_BYTES. Size the volume from that, not the other way round.

Upgrading from 0.1.x

0.1.x kept each document as one row of docs, every message it received concatenated. On open, collab moves those rows into the frame tables in one transaction and drops docs: a state that splits into y-websocket messages keeps its content messages as frames, in order; one that does not is kept whole as one frame. If the frames do not fit under COLLAB_STORE_BYTES, the transaction rolls back, docs is left as it was, and collab exits with migrate 0.1 docs; raise the cap and start again.

0.1.x took COLLAB_SQLITE_PATH as a driver DSN (file:...?_pragma=...). Now it is a file path and the pragmas are collab's own, so a deployment moving off 0.1.x sets the path in the same change as the image. A file: URI is refused at startup, by name.

Run

go run ./cmd/collab
Env Default
COLLAB_ADDR :3078 (Huly drop-in port)
COLLAB_SQLITE_PATH collab.db (a file path; a file: URI is refused)
COLLAB_STORE_BYTES 67108864 (64 MiB database file cap)
IAM_JWKS_URL https://hanzo.id/v1/iam/.well-known/jwks

Build

go build ./...
go test  ./...

Docker (CI/CD only — never build on a laptop, see hanzoai/.github):

docker build --build-arg VERSION=v0.1.0 -t ghcr.io/hanzoai/collab:v0.1.0 .

Deploy

Image: ghcr.io/hanzoai/collab:<semver>. Pin a vX.Y.Z or sha-<sha7> tag — never :latest, :main, :dev.

Port 3078 is exposed (matches Huly's legacy collaborator port for drop-in WS path replacement).

Declared in universe as charts/app/values/collab/collab.yaml, with SQLite on one ReadWriteOnce volume, one replica, Recreate.

Layout

.
├── cmd/collab/main.go        # boot + signal + env
├── pkg/auth/                 # IAM JWKS verifier
├── pkg/server/               # HTTP + WS handlers
├── pkg/room/                 # in-memory broadcast registry
├── pkg/frame/                # y-websocket message framing
├── pkg/store/                # Store interface + sqlite, 0.1.x migration
└── pkg/metrics/              # Prometheus registry

Licensed under MIT OR Apache-2.0, per HIP-0137.