Local development, collaboration examples, permissions, storage, and protocol details. See the project README for the overview and Docker quick start. Run commands from the repository root unless a section says otherwise.
Use this setup to develop the Phoenix backend and run the browser examples with Next.js. It uses a separate database and ports from Docker staging.
- Elixir 1.18.3 and Erlang/OTP 27.3.3, pinned in
.tool-versionsfor asdf. - A C compiler and
makefor Argon2 password hashing, such as the Xcode Command Line Tools on macOS or build-essential on Ubuntu. - Hex and Rebar, installed once with
mix local.hex --forceandmix local.rebar --force. - Docker with Compose v2, through OrbStack or Docker Desktop.
With the asdf Erlang and Elixir plugins installed, asdf install installs the
versions in .tool-versions. Run the commands below from the project directory.
-
Start OrbStack or Docker Desktop. On macOS with OrbStack, use
orbctl start. -
Start PostgreSQL and wait for it to accept connections:
docker compose up -d --wait db
compose.yaml runs PostgreSQL 17.11 at
localhost:5432with usernamepostgresand passwordpostgres. These are local development credentials matchingconfig/dev.exsandconfig/test.exs. -
Fetch dependencies and create the development database:
mix setup
This creates
synixir_devand its document, account, session, and membership tables. For an existing checkout, runmix ecto.migrateafter pulling new migrations. -
Run the checks below, then start Phoenix:
mix phx.server
The server listens at localhost:4000, with a collaboration
WebSocket at /socket/websocket. Open the Next.js example below for the development
UI; the Docker release serves the packaged example at /. Use
iex -S mix phx.server for an interactive shell.
Use docker compose ps to check PostgreSQL and docker compose logs db to read
its logs. Stop it with docker compose stop db; start it again with the command
above. Database files live in the synixir_postgres_data Docker volume and
survive container removal with docker compose down.
If another PostgreSQL server already uses port 5432, either use that server and
skip Compose, or stop it before starting this container. When using your own
server, adjust config/dev.exs and config/test.exs to match its credentials.
The configured role must be able to create databases.
The examples use Next.js App Router, React, TypeScript, shadcn/ui, and Tailwind CSS. Elixir/Phoenix remains the backend for accounts, permissions, persistence, and WebSockets. The production build exports static pages for Phoenix to serve; no Node server is needed in the release. See the frontend guide.
The browser example uses Node.js 24.12.0, pinned in
examples/collaboration/.nvmrc. Complete local setup above first. Restart
Phoenix after updating the application's supervision tree or configuration.
In a second terminal:
cd examples/collaboration
nvm install
nvm use
cd ../..
npm ci
npm run dev --workspace synixir-collaboration-exampleIf you manage Node.js another way, install the pinned version and skip the two
nvm commands. Open 127.0.0.1:5173 in two tabs.
Create an account with a public username and a password of 15–128 characters.
Create a room from Your rooms. Its creator becomes the first owner.
- Open the room in another tab signed into the same account, or create another account in a separate browser profile.
- To add a different account, enter its username under Manage access and choose Editor, Viewer, or Owner. Sharing the URL alone grants no access. The other account must already exist.
- Owners and editors can type, select, replace, undo, and redo their own changes. Viewers receive the document and presence but cannot edit. Their status reads View only rather than claiming that they saved changes.
- Click the connection status in the footer and choose Disconnect. Make an offline edit, then open the status dialog and choose Connect. Wait for Saved before closing the tab. Offline edits survive only while the tab remains open.
- Restart Phoenix and open the same room to restore its saved state.
- Owners can change roles or remove members. Affected channels close and the example requires a reload to obtain current permissions. Copy any unsaved local draft before reloading. Revocation cannot erase text already received.
Signing out invalidates the current session, closes its room channels, and clears document state in tabs sharing that browser session. Signing in as another account creates fresh editors and documents. Browser storage carries only a session-change notification, never credentials or document content.
Room IDs are case-sensitive, with 1 to 128 ASCII letters, digits, underscores
or hyphens, starting with a letter or digit. For example,
?room=design-notes selects a room.
Opening another room navigates to a new page. Reconnect and wait for Saved
before switching if you want to keep local edits. The page asks before leaving
with unconfirmed edits when the browser supports an unload prompt. This is a
reminder, not offline storage. Keep the tab open until the edits are saved.
The editor uses CodeMirror 6 and
y-codemirror.next to
bind editing operations to the existing doc.getText("content"). Existing saved
documents remain compatible. Its Yjs undo manager tracks local editor changes
and leaves remote edits intact. Undo history lasts for this page visit only.
The example consumes @synixir/client through its document, awareness, lifecycle
methods, and state subscription. The SDK uses
y-phoenix-channel
internally to exchange Yjs binary sync messages with SynixirWeb.DocumentChannel
on document:<room_id>. Browser broadcast-channel sync is disabled so updates
travel through Phoenix. The SDK owns fresh access grants, recovery, chunked
transfers, and save tracking; account forms and editor controls stay in the app.
See the SDK interface and integration guide for installation, connection and save states, cancellation, cleanup, and custom access callbacks.
Open 127.0.0.1:5173/kanban with the same
development servers running. Sign in and create a board, or open an existing room.
The text editor and board link to each other and share account and room access.
The production build also includes /kanban.
- Add cards to Backlog, In progress, or Done. Open a card to edit its title, description, color, and status. Changes save as you type.
- Drag cards within or between columns using a mouse or touch. Focus a card's move handle and press Space to sort with the keyboard, then Escape to cancel. The status selector remains available for moving a card to another column.
- Open the same board in two tabs to see changes and card activity. To collaborate with another account, grant its username access under Manage access.
- Undo and redo affect this tab's local changes. Deleted cards can be restored with Undo. Viewers can inspect cards and publish presence, but cannot edit them.
- Offline changes remain in the open tab. Reconnect and wait for Saved before closing it. Saved boards recover through the existing PostgreSQL document log.
The board model stores each card as a
nested Y.Map under kanban:cards:v1. Column and order are a single placement value,
so simultaneous moves converge to one location without duplicating a card.
Edits to different fields merge. Concurrent edits to the same title, description,
color, or placement resolve through Y.Map's conflict rules to one value; these
fields do not provide character-level text merging. A deletion wins over a
concurrent edit to that card. Fixed columns support card reordering through
@dnd-kit/react. Fractional ranks include stable card IDs to resolve ordering
ties without rewriting a column. Existing numeric orders remain readable.
See the kanban integration notes.
The shared React application owns account, room, connection, permission, and cleanup UI for all examples. Each editing surface consumes the public SDK. Kanban's data uses a separate shared type, so opening a text room as a board does not change its text content.
npm test includes model checks for concurrent creation, moves, deletion, and
local undo. The browser suite includes Kanban sync, presence, dragging, offline
recovery, server restart, permissions, room navigation, and mobile layout.
Run only the Kanban browser checks with:
npm test --workspace synixir-collaboration-example -- kanban.spec.tsOpen 127.0.0.1:5173/whiteboard with the same development servers running. Sign in and create a room, or open an existing one. Links between the examples retain the room. Each example stores its data separately inside that room's Yjs document.
- Draw shapes, freehand lines, text, and arrows with Excalidraw's native tools. Select, resize, group, and reorder objects, or pan and zoom around the scene.
- Teammates see cursors, selections, and live changes. Presence labels are client-supplied and do not establish identity or lock objects.
- Undo and redo affect this tab's changes. A pointer gesture creates one undo item. Shape changes synchronize during the gesture.
- Viewers can explore the scene and publish cursors, but cannot edit it.
- Fonts load from local assets included in development and production builds. Image insertion, embedded content, and scene-file import are disabled because this example has no attachment store. Local drawing export is available.
The whiteboard model stores creation
records in whiteboard:elements:v2 and property changes in whiteboard:fields:v2.
Position and size are separate atomic pairs, so moving an object does not overwrite
another participant's text or color edits. Concurrent changes to the same field
resolve through Yjs conflict rules. Text does not merge at the character level.
Deletion wins over a concurrent property edit.
Saved whiteboard:objects:v1 scenes remain readable through a deterministic
adapter, including their sticky-note labels. Opening an old room does not write
migration updates. See the Excalidraw integration notes
for the scene model, asset setup, and package constraints.
Offline edits remain in the open tab until reconnecting. Wait for Saved before
closing it. The production build includes /whiteboard and uses the same
room permissions, PostgreSQL persistence, and recovery flow as the other examples.
npm test includes whiteboard concurrency and undo checks. Run its browser
checks separately from mix test with:
npm test --workspace synixir-collaboration-example -- whiteboard.spec.tsOpen 127.0.0.1:5173/rich-text with the same development servers running. Sign in and create a room, or open an existing one. The plain-text editor remains available as its own example.
- Write paragraphs, three levels of headings, bullet and numbered lists, and quotes.
- Apply bold, italic, underline, and links to selected text. The link dialog accepts HTTP and HTTPS URLs and can update or remove an existing link.
- See collaborators' carets and selected text in their participant colors. Undo and redo affect edits made in this tab, leaving other participants' edits intact.
- Viewers can read and select text. Editing, formatting, and history commands are disabled for viewers and when access is revoked.
- Edits made while disconnected remain in the open tab and merge when it reconnects. Wait for Saved before closing the tab. Saved formatting and content recover after everyone leaves or the server restarts.
The rich-text editor uses Tiptap with
its Yjs collaboration extension.
It binds to the rich-text:content:v1 Y.XmlFragment in the SDK's document and uses
Synixir for synchronization and persistence. It does not require a separate
collaboration service. Tiptap's ordinary history is disabled in favor of Yjs undo.
A small awareness adapter uses richTextCursor so plain-text and rich-text cursor
positions never get mixed when both examples are open in the same room.
This is a text document example, with no images, file uploads, comments, or version history. Each example has separate content within the room, while access and storage quotas apply to the whole room.
Run its browser checks separately from mix test with:
npm test --workspace synixir-collaboration-example -- rich-text.spec.tsOpen 127.0.0.1:5173/multiplayer-form with the same development servers running. Create a room or open an existing one, then open the form in another tab to collaborate on a project brief.
- Edit the project name, goal, and audience together. Text changes merge at the character level, including changes made to the same field while disconnected.
- Choose a team, priority, target date, and launch channels. Separate fields and separate checkboxes merge independently. Concurrent changes to the same choice resolve to one value through Y.Map's conflict rules.
- See who is editing or viewing each field, with participant-colored outlines and shared text carets. These indicators do not lock fields. Presence clears when focus leaves the form, the window loses focus, or the peer disconnects.
- Track completion of the four required fields. Validation shows missing values and text length limits without discarding the shared draft.
- Review brief opens a live preview once required fields are complete. The preview updates when teammates edit. This example does not submit the form to an external service or record a finalized submission.
- Undo and redo affect this tab's edits across text and choice fields. Viewers can read, select text, and review the draft; they cannot edit or undo it.
The form model stores text in
separate top-level Y.Text fields named multiplayer-form:<field>:v1, with choices
in multiplayer-form:properties:v1. The text controls reuse the existing
CodeMirror/Yjs binding for shared selections, composition input, and undo.
React Hook Form tracks local touched fields and review state, with Zod validation
through @hookform/resolvers. Incomplete shared drafts remain in Yjs while users
edit. See the form integration notes.
The multiplayerForm awareness field identifies the active form field. All data
is separate from the other examples, with the same room permissions and storage.
Offline changes remain in the open tab until reconnecting. Wait for Saved before closing it. Saved fields recover after all tabs close or the server restarts.
npm test includes form merge and validation checks. Run the browser checks
separately from mix test with:
npm test --workspace synixir-collaboration-example -- multiplayer-form.spec.tsOpen 127.0.0.1:5173/flowchart with the same development servers running. Create a room or use an existing one, then open another tab to build a workflow together.
- Add process, decision, and start/end nodes. React Flow provides dragging, resize handles, pan/zoom controls, and a minimap. Arrow keys move selected nodes; the inspector edits labels, colors, and sizes.
- Select a source node and choose a destination in Connect to, or use Pick on canvas and click another node. You can also drag between node connection handles. Label branches such as Yes and No.
- Select an arrow or its entry in Connections to edit its label or delete it. Deleting a node removes its attached arrows. One undo restores both.
- Live cursors, collaborator selections, and drag previews show where teammates are working. Arrows follow the displayed node positions during dragging.
- Undo and redo affect this tab's changes. Viewers can explore and select nodes and connections, while edits and history controls require editing permission.
The flowchart model stores nested
Y.Maps under flowchart:nodes:v1 and flowchart:edges:v1. Fields merge independently;
concurrent edits to the same field resolve to one value using Y.Map's conflict
rules. Connections use a key derived from their source and target so concurrent
creation of the same arrow converges to one connection. Self-connections are
excluded; reverse connections and multiple outgoing branches are supported.
Arrows with deleted endpoints stay hidden, including ones created offline during
a deletion. Undo can restore their endpoints.
The flowchart awareness field carries temporary cursor, selection, and drag
positions. The React Flow adapter interpolates remote drag previews and commits
one position change per completed drag. Other examples keep separate data and cursor
fields within the same room. This is a diagram editor; it does not execute workflows
or automatically route arrows around intervening nodes.
Offline changes remain in the open tab until reconnecting. Wait for Saved before closing it. Saved nodes and arrows recover after a server restart.
npm test includes connection merge, deletion, and undo checks. Run the browser
checks separately from mix test with:
npm test --workspace synixir-collaboration-example -- flowchart.spec.tsOpen 127.0.0.1:5173/table with the same development servers running. Create a room or use an existing one, then open another tab to work on the same table.
- New tables show three empty rows and Task, Owner, Status, and Due date columns. These are all text cells, so you can rename columns for a different use case.
- Click a cell to select it. Press Enter, double-click, or choose Edit cell to edit. Typing on a selected cell replaces its contents. Arrow keys move between selected cells; within an editor they move the caret. Tab moves to the next cell, Enter moves down, and Escape returns to cell selection.
- Collaborator-colored outlines show active cells with editing/viewing labels. When two people edit the same cell, they also see each other's text carets and selections. Text edits merge at the character level, including offline edits.
- Add rows and columns, rename a selected column, clear cells, or delete rows and columns. Undo restores deleted content and affects this tab's changes.
- Paste plain tab-separated text into a selected cell to fill a rectangle. The table adds rows and columns as needed, and one undo reverses the entire paste. Multi-cell paste also works while editing a cell. Paste uses literal text; quoted CSV parsing and spreadsheet formulas are not included.
- Viewers can select and copy cells, but cannot change data, paste, or use undo. On mobile, the table scrolls horizontally while the row numbers stay visible.
The table model uses separate Y.Maps for
row order, column order, column names, and deletion markers. Default row and column
IDs are stable and require no initialization writes. Each cell uses a top-level
Y.Text named collaborative-table:cell:[row-id,column-id]:v1, with the IDs encoded
as a JSON array. Two users can edit an untouched cell without competing to create
its shared text. Concurrent rows and columns sort by order, then by stable ID.
Column names use Y.Map conflict resolution, while deletion markers hide rows and
columns independently of concurrent renames or text edits.
Deleted cells remain in the document so undo can restore them. The UI allows
adding up to 100 rows and 12 columns, and pasting at most 50,000 characters at a
time. These are example interaction limits, not server-enforced quotas;
concurrent additions can exceed them. react-data-grid provides navigation,
resizable columns, and virtualization. Column widths are local to each tab.
The active cell and cells with remote editors mount CodeMirror bindings so peers'
carets remain visible. Other visible cells render plain text. See the
table integration notes.
The collaborativeTable awareness field carries the selected row and column;
text caret positions use the existing CodeMirror/Yjs awareness binding.
Data is separate from the other examples in the same room. Offline edits remain in the open tab until reconnecting. Wait for Saved before closing it. Saved cells and table structure recover after a server restart.
npm test includes table concurrency, deletion, and paste/undo checks. Run the
browser checks separately from mix test with:
npm test --workspace synixir-collaboration-example -- table.spec.tsEach account has a stable username; each editor visit gets a cursor color. The participant list counts connected editor visits, including multiple tabs for one account. Names and cursor positions are temporary, client-supplied awareness data; they do not establish identity or room permissions. Awareness is scoped to the room, removed when the channel leaves, and announced again after reconnecting. Focusing another control clears the local cursor. Nothing in awareness is written to the document update log.
Connecting means the initial sync is in progress. Connected means the channel has joined and synced. Reconnecting appears after a connection is lost. Each automatic reconnect fetches a fresh grant and creates a fresh channel. Disconnected means you clicked Disconnect. Owners and editors can keep editing offline, and the save status reports any unconfirmed changes. Rejected joins show Access expired or denied or Room unavailable and stop retrying that join. Connect requests a fresh authorized room token and retries without discarding local edits. Access requests and channel joins time out after 10 seconds. If the role changed while disconnected, reload with the new permissions after copying any unsaved draft. Reconnect also checks the current account before reusing a document and waits for the previous socket to finish closing.
Accounts use stable UUIDs and unique, case-insensitive public usernames. Usernames contain 3–32 ASCII letters, digits, underscores or hyphens and start with a letter or digit. They are handles, not verified email addresses. Passwords use Argon2id with the package's production defaults; only tests reduce the hashing cost. Self-service password recovery, email verification, MFA, and external identity providers are outside this implementation.
The browser stores an opaque random session token inside Phoenix's signed,
HttpOnly, host-only session cookie. PostgreSQL stores its SHA-256 digest and a
seven-day expiration. The cookie uses SameSite=Lax and Secure in production,
which requires HTTPS. Login renews the cookie and rotates CSRF state; logout
deletes the session before returning success. Every authenticated HTTP request
looks up the session, and room operations check it again at their authorization
boundary. Authentication attempts are limited to 20 per remote IP per minute in
one node, before password hashing. This limiter uses the connection's remote IP.
The local Docker gateway overwrites forwarded client addresses; production trusts
those headers, so keep the app listener private to the gateway. Room and channel
quotas are described in the operations guide.
All JSON API routes fetch the session and use CSRF protection. First fetch
GET /api/session, retain its csrf_token in memory, and send it as
x-csrf-token for state-changing requests. Retain the returned cookies. Responses
use Cache-Control: no-store. The Next.js development server proxies /api and /socket to Phoenix.
The Docker release serves the frontend, API, and WebSockets through one HTTPS
origin. Production WebSocket origin checks include the scheme and port. Socket
connections alone carry no identity: each room join must present an authorized
bearer grant.
| Method and path | Purpose |
|---|---|
GET /api/session |
Current account or null, plus CSRF token |
POST /api/accounts |
Register with username and password, then sign in |
POST /api/session |
Sign in with username and password |
DELETE /api/session |
Revoke the current session and sign out |
GET /api/rooms |
List the account's active memberships |
POST /api/rooms |
Create room_id and its initial owner atomically |
GET /api/rooms/:room_id |
Room info and member usernames/roles for current room members |
POST /api/rooms/:room_id/token |
Issue a grant using current session and membership |
GET /api/rooms/:room_id/members |
Owner-only membership list |
PUT /api/rooms/:room_id/members/:username |
Owner grants or changes role |
DELETE /api/rooms/:room_id/members/:username |
Owner revokes membership |
Room endpoints return {data: ...} on success and {error: ...} on failure.
Token responses contain token, user_id, role, and the session's expires_at.
Client-supplied user IDs or roles cannot change the authenticated account.
Membership management accepts only owner, editor, and viewer.
| Permission | Owner | Editor | Viewer |
|---|---|---|---|
| Join and receive document/presence | Yes | Yes | Yes |
| Publish valid ephemeral awareness | Yes | Yes | Yes |
| Save document changes | Yes | Yes | No |
| List and manage members | Yes | No | No |
Each room has one membership per account, and at least one owner. Administration locks the room row so concurrent role changes cannot remove the final owner. Multiple owners are allowed. A membership version increments on every role change or revocation; re-adding an account never makes an old grant valid again.
Synixir.RoomAccess.issue(room_id, session_hash) is a trusted server API. HTTP
clients obtain grants through the authenticated token endpoint. Signed grants
bind the room, account, session, and membership version. They expire for new
joins after 15 minutes; existing channels remain subject to the underlying
session expiration and current membership. Tokens from the old development
issuer are invalid. The /api/demo/room-token route has been removed.
Inside the document worker, an authorized update locks its session and room, checks current membership, applies Yex, and commits the raw update bytes in one transaction before replying Saved. Logout conflicts on the session lock; role changes conflict on the room lock. A write authorized first can finish before revocation commits. After revocation commits, queued or newly completed chunk uploads cannot write using stale access. Storage or commit failure stops the mutated worker before queued broadcasts run. Save telemetry also waits for that commit.
Channels subscribe to session and membership notifications before observing a room and recheck access after observation. Durable revocation closes affected channels and discards incomplete transfers. Permission checks also run before accepting messages and before forwarding queued document or awareness data, so correctness does not depend on receiving a PubSub notification. Idle channels check session expiry every 30 seconds; messages always recheck current access. Data already sent over the network cannot be recalled.
Viewers receive the server's sync response without a reverse upload request.
Their client disables editing, undo/redo, and save tracking. The server rejects
all update encodings with read_only, including raw saves, protocol updates,
and reassembled transfers. UI controls are not an authorization boundary.
Synixir.Documents.open/1 still finds or starts the single supervised Yex process
for a room, using UTF-16 offsets. Its direct sync/2, save_update/2, storage,
and compaction functions are trusted server APIs. Browser channels use the
separate authenticated path. Ownership remains local to one Phoenix node.
Migrate the database before running the updated server, then restart it. Existing document logs and snapshots remain intact. They have no implicit owner: public room creation refuses IDs that already contain legacy data, preventing an account from claiming someone else's document. After the intended owner registers, an operator can adopt a specific document from trusted server code:
user = Synixir.Repo.get_by!(Synixir.Accounts.User, username: "alice")
{:ok, _room} = Synixir.RoomAccess.adopt_legacy_room("design-notes", user)No documents are adopted automatically. The chosen owner can then grant other members access through the example or API. See the authentication research for the library guidance and transaction design.
The document_updates table stores incoming Yjs v1 update bytes, indexed by
room and insertion order. A SHA-256 digest makes repeated delivery of identical
bytes within a room a no-op while those bytes remain in the log. After compaction,
retrying an older update is still safe because Yjs updates are idempotent. Room
startup loads the latest snapshot and replays its remaining log before accepting
joins, and normal Yjs state-vector sync supplies missing changes to
reconnecting clients.
The room process validates and applies an update, commits its incoming bytes, then acknowledges the request and broadcasts the change. It stores incoming bytes even when Yex is waiting for a missing dependency. Saving only emitted document changes would lose those pending updates on a crash. A failed database write stops the modified in-memory process without a saved acknowledgement or broadcast. A load failure refuses the join instead of serving an empty document.
Before an update reaches the room process, Synixir.Documents.Protocol checks
its size and validates it using a disposable Yex document in the caller. Invalid
updates, state vectors, and awareness messages are rejected without modifying
the shared document or disconnecting its other collaborators. Applying an update
to the live document still happens before its bytes are committed.
Synixir.Documents.Document uses Yex.DocServer with synchronous requests for
this ordering. It reuses Yex.Sync.SharedDoc callbacks for observers and
awareness, but does not use its asynchronous update entry point or rely on a
shutdown hook to save data. Awareness remains temporary and is not stored.
The channel supports these messages:
| Event | Binary payload | Successful reply |
|---|---|---|
yjs or yjs_sync |
A Yjs v1 sync or awareness message | {saved: true} for committed updates; {saved: false} for handshake or awareness messages |
save_update |
A raw Yjs v1 document update | {saved: true} after the database write commits |
Replies use Phoenix's ok or error status. yjs pushes still carry the
standard binary sync messages expected by y-phoenix-channel. For example,
an explicit save request is:
channel.push("save_update", update.slice().buffer)
.receive("ok", ({ saved }) => { /* saved === true confirms durability */ })
.receive("error", () => { /* retain local changes and retry */ })
.receive("timeout", () => { /* outcome unknown; retrying is safe */ });The SDK sends incremental updates through this save path and a full update when it reconnects. Its chunk transport adapter splits large uploads, handshake responses, and server broadcasts into bounded messages. Only the final chunk can confirm a durable save. Duplicate delivery through the standard provider is safe. Its save tracker waits for acknowledgements covering every local edit, including deletions, and ignores late replies from old connections. Connected reports sync progress. Saved confirms database persistence; Saving, Unsaved changes, and Save failed do not. A timeout may mean the write committed but its reply was lost. Reconnect to retry and obtain confirmation.
The defaults in config/config.exs are:
| Limit | Default |
|---|---|
| Binary channel payload | 1 MiB, including the Yjs protocol envelope when present |
| Awareness update inside a protocol message | 16 KiB |
| Chunk data | 256 KiB, plus a 13-byte header |
| Reassembled transfer | 64 MiB |
| Partial transfer lifetime | 30 seconds from its first chunk |
| Message rate per joined channel | 120 messages/second, with a burst of 240 |
| WebSocket frame or assembled fragmented message | 2 MiB |
The message budget includes updates, awareness, sync requests, and unsupported
events. It refills over time; excess requests receive rate_limited without
entering the document process. Another collaborator has a separate budget.
Rejoining starts a new budget. Separate admission quotas bound joined channels
per account, room and node, along with resident documents and rooms owned by an
account. The local Docker gateway also limits active connections and HTTP request
rates per IP. These are staging defaults to tune for the deployed workload; see
the gateway configuration.
Payload sizes and rate settings use the :collaboration_limits application
configuration. The transport limits are configured separately in the endpoint
and Bandit HTTP options. Restart Phoenix after changing them. Keep payload
limits below the transport ceiling, allowing space for Phoenix's envelope.
Awareness states must be JSON objects. Optional user and cursor fields must
match the example's format: names and shared type names are at most 128 UTF-8
bytes, colors use six- or eight-digit hex notation, and cursor positions contain
valid Yjs IDs and integer offsets. Other awareness fields remain available for
application metadata. Awareness data still does not establish user identity.
Channel failures return {reason: "message_too_large"}, rate_limited,
invalid_message, invalid_chunk, read_only, unauthorized,
unsupported_message, storage_unavailable, or
document_unavailable. The example explains oversized edits, rejected updates,
rate limits, and missing acknowledgements next to its save indicator. It keeps
unconfirmed text in the tab. A transport limit closes the socket before channel
processing, so it cannot return a channel error reply.
The 1 MiB cap applies to individual messages, not accumulated document history. The example supports larger documents through chunked transfers. The separate 64 MiB transfer cap bounds reassembly memory and is the practical ceiling for encoded reconnect state in this version. Deleting visible text does not remove all CRDT history, and log compaction does not guarantee a smaller encoded state. An edit or reconnect state beyond the transfer cap needs a smaller document or an explicitly configured higher cap with sufficient server and browser memory.
A client opts in with chunked_sync: 1 in its authorized join parameters. The
join reply includes transfer with max_message_bytes, chunk_bytes,
max_transfer_bytes, and transfer_timeout_ms. The SDK's internal
chunked-transport.ts wraps the
existing Phoenix channel provider. Clients without this option keep the original
protocol and per-message size limit.
Large client messages use the binary transfer_chunk event. Each payload has a
13-byte header followed by data. Header fields are an unsigned one-byte event
kind (0 for yjs, 1 for yjs_sync, 2 for save_update), then unsigned
32-bit big-endian transfer ID, byte offset, and total reassembled byte length.
Offsets must be contiguous. A chunk at offset zero starts or replaces the
channel's one partial upload. Later chunks must match its kind, ID and total.
Each fragment consumes the existing per-channel message budget. The example
sends fragments sequentially with acknowledgements and pacing.
Intermediate replies are {saved: false} and do not change the shared document
or database. After the last chunk, the server validates the complete original
message in a disposable document, then applies and commits its raw update bytes
through the same save path as an ordinary message. Only that successful commit
returns {saved: true}. An incomplete transfer expires after 30 seconds and is
also discarded when the channel exits. Reconnecting retries from the beginning.
Malformed or discontinuous fragments return invalid_chunk; expired transfers
must start again at offset zero. Payload and awareness validation remain active.
Large server messages arrive as yjs_chunk with the same header and kind 0.
The adapter reassembles a whole message before delivering it to the Yjs provider.
It bounds incoming memory and outgoing queued bytes, resets on connection
changes, and preserves the save tracker's stale-reply protection. A protocol
response beyond the negotiated cap fails sync; it cannot mark a partial document
as connected. The 2 MiB WebSocket limits still apply to every transport message.
Documents are created on demand and unload after 60 seconds without an observer. A new join cancels the idle timer; the final observer leaving or crashing starts a new grace period. Rooms opened by server code without an observer also expire after inactivity. Idle workers stop normally and leave the Registry; the next join starts a worker and restores its committed snapshot and remaining updates. After a document crash or server restart, recovery uses the same path. Channels attached to a failed process close so clients can rejoin. Document processes use temporary supervision to avoid an automatic restart loop during a database outage; other rooms keep running.
Use a new room ID for a fresh document. Restarting Phoenix no longer resets saved content. Offline changes remain only in that tab until acknowledged as saved; closing or reloading an offline tab loses unsent edits. Existing content from the earlier memory-only implementation is not imported automatically.
Incoming updates first enter the append-only document_updates log. The room
queues compaction after 256 save submissions or 4 MiB of submitted update bytes
since its last successful compaction. Compaction runs serially in the room
worker; large merges can delay that room's requests. On restoration, the remaining log counts
toward those thresholds. Settings live in :document_lifecycle as
compact_after_updates, compact_after_bytes, and idle_timeout_ms.
Trusted server code can also request Synixir.Documents.compact(document_pid).
Compaction merges the previous snapshot with the raw committed update bytes via
Yex.merge_updates/1. The document_snapshots row stores that replay update,
its SHA-256 checksum, and the last covered update ID. Snapshot replacement and
deletion of covered log rows happen in one PostgreSQL transaction. Append,
restore, and compaction take the same per-room transaction lock, so restoration
cannot observe a snapshot and tail from different compaction states. A process
crash rolls back an unfinished transaction; a failed compaction retains the
previous snapshot and log and is retried after another save. Saved edits do not
depend on compaction or graceful shutdown.
Yex 0.10.5's encode_state_as_update/1 omits pending inserts and delete sets
waiting for missing dependencies. Storage snapshots use raw update merging to
retain those dependencies and deletions,
even across repeated compaction. A checksum mismatch, invalid replay data, or
storage read error refuses restoration. Awareness remains ephemeral. The
research and probe results explain the pinned
library behavior.
These snapshots consolidate CRDT updates and duplicate history; they do not provide user-visible versions, retention, or garbage collection of all deleted history. Multi-node room ownership is not implemented.
Yex uses precompiled native binaries on supported platforms. The Docker release pilot exercises both Yex and Argon2 inside the final image. It has passed locally on ARM64 and emulated AMD64, and CI runs it on native AMD64.
These events use :telemetry.execute/3; SynixirWeb.Telemetry.metrics/0 defines
the corresponding counters, summaries, and active-document gauge:
| Event | Measurements | Metadata |
|---|---|---|
[:synixir, :channel, :join] |
duration, count |
result |
[:synixir, :channel, :message] |
duration, count, bytes |
event, result |
[:synixir, :document, :save] |
duration, count, bytes, inserted |
result |
[:synixir, :document, :restore] |
duration, count, bytes, updates |
result |
[:synixir, :document, :compact] |
duration, count, bytes, updates |
result |
[:synixir, :document, :unload] |
count |
none |
[:synixir, :documents] |
active |
none |
Durations use native time units. The metric definitions convert them to
milliseconds. Message duration includes validation, document queueing, and the
operation itself. Save duration covers the database write; inserted: 0 with
result: :ok identifies duplicate bytes already in the log. Restore counts and
bytes describe the replayed log. Failed restores report zero counts. Compaction reports removed log rows and the
resulting snapshot size. Idle unloads increment a counter. Active
documents are sampled every 10 seconds and include rooms with no participants.
These events contain no document content, tokens, room IDs, or user IDs. Labels
use a fixed set of event names and outcomes. The Prometheus exporter uses a
separate metric list in Synixir.Operations.Metrics, converts durations to seconds,
and normalizes labels through finite allowlists. Enable /metrics with
SYNIXIR_METRICS_TOKEN; health checks, alert rules and backup commands are in the
operations guide. No dashboard or alert receiver is installed.
To inspect save outcomes locally in iex -S mix phx.server:
:telemetry.attach(
"inspect-document-saves",
[:synixir, :document, :save],
fn event, measurements, metadata, _config ->
IO.inspect({event, measurements, metadata})
end,
nil
)
# Remove the handler when finished:
:telemetry.detach("inspect-document-saves")MIX_ENV=test mix deps.get --check-locked
MIX_ENV=test mix compile --warnings-as-errors
mix format --check-formatted
mix testmix test creates and migrates synixir_test before checking room ownership,
authorization, save acknowledgements, recovery of pending updates, and database
failure behavior. It also checks malformed and oversized payloads, message
budgets, and telemetry outcomes. Authentication checks cover CSRF, session expiry,
role boundaries, legacy document ownership, queued writes and broadcasts after
revocation, and logout concurrent with a real committing write. Lifecycle checks cover repeated compaction with
pending dependencies and deletions, corrupt snapshots, idle observer races,
chunk validation and expiry, and killing a compactor after its uncommitted
snapshot write. The crash test uses real commits in a unique room and cleans up
only that room's data. PostgreSQL is required. Test partitions append
MIX_TEST_PARTITION to the test database name.
Install JavaScript dependencies from the root workspace lockfile, then check the SDK and browser examples:
npm ci
npm test
npm run test:package
npm run build
cd examples/collaboration
npx playwright install --only-shell chromium
npm testThe SDK unit tests cover lifecycle cancellation, stale access callbacks, account
boundaries, and subscriptions through its public entry point. The package check
installs a real npm tarball into an isolated consumer and verifies ESM imports,
shared Yjs identity, TypeScript NodeNext/bundler declarations, and a Vite build.
It does not publish the package. Run browser tests separately from mix test;
both use synixir_test.
Playwright migrates the test database and starts its own Phoenix server on port
4010 and Next.js on port 5174, then shuts them down. Those ports must be free;
your development servers can keep running on ports 4000 and 5173. Each test uses
unique room IDs so saved data from previous runs does not affect the assertions.
The fixture sets SYNIXIR_BROWSER_TEST=true for its subprocesses, selecting a
regular four-connection database pool. ExUnit keeps SQL Sandbox. Browser tests
check that open rooms release database connections between operations so new
accounts can still register. Failed tests also print the recent Phoenix server
log to make HTTP and channel failures diagnosable in CI.
Browser tests fail on unhandled page exceptions. SDK-specific browser checks
cover synchronization before save acknowledgement, a shared Y.Map consumer,
fresh-grant recovery, overlapping disconnect/connect, destruction, and suppression
of programmatic viewer writes. The browser tests also check room isolation, typing and selection replacement,
multiline and Unicode edits, local undo/redo, remote selections following edits,
participant departure and reconnect, and cancelling navigation with unsaved
changes. They also check concurrent offline edits. The
durability test waits for Saved, closes all original clients, kills its
Phoenix process with SIGKILL, and recovers the text in a fresh browser context.
It also covers a Unicode deletion and offline edits across another restart.
This runs against real PostgreSQL commits, outside the ExUnit SQL sandbox.
Failure tests hold actual WebSocket save replies to verify acknowledgement
ordering and timeout handling, reject grants on initial join and rejoin, and
check that an oversized edit is neither shown as saved nor recovered by a new
client. The backend still performs the real writes during reply-loss tests.
Browser permission tests cover signup, owner management, read-only viewers, role
changes while connected or offline, and clearing document state across logout and
account changes, including cookie changes without a storage notification. The
collaboration regression tests now provision real accounts and memberships through
the public API. Lifecycle browser tests exercise documents above 1 MiB, chunked broadcasts,
offline concurrent edits and deletions, snapshot recovery after SIGKILL,
interrupted uploads, and missing final chunk acknowledgements. Transport tests send oversized frames and fragmented messages over a real
WebSocket connection and check that both close with code 1009. They also verify
that the WebSocket handshake rejects an untrusted browser origin.
The CI workflow runs these checks on pull requests
and pushes to main. It uses the versions in .tool-versions and the same
PostgreSQL image as Compose. Each run gets a fresh database and runs the browser
test in Chromium. CI checks formatting without modifying files, installs
JavaScript dependencies from the lockfile, and fails if Elixir dependency
resolution would change mix.lock. It also verifies backup restoration,
concurrent quotas, SDK load and replay, and Prometheus configuration and alerts.
A separate job builds the production Docker image on native AMD64 and runs the
HTTPS staging pilot in a disposable Compose project.
Production runtime configuration is in config/runtime.exs. It reads
DATABASE_URL, SECRET_KEY_BASE and an HTTPS SYNIXIR_PUBLIC_URL, with optional
PORT and POOL_SIZE settings. The local Docker staging runbook
builds a production release with the browser assets, a private PostgreSQL database,
an HTTPS gateway and Prometheus. It includes a packaged browser pilot, verified
backups and single-node replacement. A separate CI job runs that pilot against
the final AMD64 image. Public hosting remains a separate deployment decision.
Generated with the Phoenix 1.8.13 generator, using PostgreSQL and disabling HTML, assets, LiveDashboard, Gettext, and mailer generation.