Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
40 KiB
40 KiB
@tldraw/sync-core behavior specification
This document states the rules that @tldraw/sync-core implements. It is written to drive testing: each rule has a stable ID (e.g. D4, RP7), each rule is independently observable through the public API (or the documented internal API where noted), and the unit tests should be an expression of these rules. When a test and this document disagree, one of them is wrong — figure out which and fix it.
Sections marked internal describe supporting machinery that has its own contract worth testing directly, even though SDK users rarely touch it.
1. Model and vocabulary
- The room (
TLSyncRoom, wrapped byTLSocketRoom) is the authoritative server-side copy of a document. The client (TLSyncClient) keeps a localStoresynchronized with it. - The document clock is a monotonically increasing integer owned by the room's storage. It advances at most once per storage transaction that writes.
- A tombstone records the deletion of a document id at a clock value, so reconnecting clients can be told what was deleted. Tombstone history starts at a clock value; clients older than that get a full wipe instead of an incremental diff.
- A network diff (
NetworkDiff) is a compact, one-way map of record id toput/patch/removeops. An object diff (ObjectDiff) is the nested per-key form used insidepatchops. - A forward diff (
TLSyncForwardDiff) is the storage-level shape{ puts, deletes }, where a put is either a full record or a[before, after]tuple. - A push is a client request to apply changes; the server answers with a push result:
commit(applied verbatim),discard(no effect), orrebaseWithDiff(applied with modifications). - Speculative changes are local changes the client has applied optimistically but the server has not yet confirmed.
- Presence records (cursors etc.) live in the room's in-memory
PresenceStore, never in document storage. - A session is one client connection to the room, keyed by session id, with a lifecycle of
AwaitingConnectMessage→Connected→AwaitingRemoval.
2. Protocol constants and errors (PV)
- PV1
getTlsyncProtocolVersion()returns 8. - PV2
TLSyncErrorCloseEventCodeis 4099. It is the WebSocket close code used for fatal, non-retriable sync errors on modern sessions; the close reason carries aTLSyncErrorCloseEventReasonstring. (Legacy sessions are rejected without it — see SES4.) - PV3
TLRemoteSyncErrorhasname: 'RemoteSyncError',message: 'sync error: <reason>', and exposes the reason as.reason.
3. Computing diffs: diffRecord (D)
- D1
diffRecord(prev, next)returns anObjectDiffdescribing how to turnprevintonext, ornullwhen there is nothing to change (including whenprev === next). - D2 A key present in
prevbut missing fromnextproduces['delete']. A key present innextbut missing fromprevproduces['put', value]. - D3
propsandmetaare the only nested keys at the top level: changes inside them are expressed as['patch', ...]ops. Any other top-level key whose values are not both arrays or both strings is compared with deep equality and produces a whole-value['put', next]on change — even when both values are plain objects. - D4 Inside a nested diff (within
props/metaor deeper), object values are recursively patched;nulland primitive values are put. - D5 When both values are strings (at any level, including top-level keys) and
nextstarts withprev, the diff is['append', addedSuffix, prev.length]. Other string changes are puts. WithlegacyAppendModeenabled, string appends become puts instead; array appends (D7) are unaffected bylegacyAppendMode. - D6 Same-length arrays: if no items changed, no op. If at most
max(length/5, 1)items changed, the op is['patch', { [index]: op }]where each changed index gets a recursive diff when both old and new items are truthy objects, and a put otherwise. If more items changed, the whole array is put. - D7 Different-length arrays: when the shared prefix is unchanged and the array grew, the op is
['append', addedItems, prev.length]. Any change in the shared prefix (including truncation) puts the whole array.
4. Applying diffs: applyObjectDiff (AD)
- AD1
applyObjectDiff(object, diff)never mutates its input. It returns the original object (same reference) when no op had an effect, otherwise a shallow-cloned copy with the ops applied. Unchanged nested values keep their identity in the copy. - AD2 A
putis applied only when the new value is not deep-equal to the current value. - AD3 An
appendis applied only when the current value is an array/string of the matching type whose length equals the op's offset. On any mismatch the op is silently ignored. - AD4 A
patchis applied only when the current value is a truthy object; it recurses with AD1 semantics. Patching a missing or primitive value is silently ignored. - AD5 A
deleteremoves the key when present. - AD6 Patching a non-object (
null, primitives) returns the input unchanged. - AD7 Arrays are cloned as arrays; ops keyed by numeric strings index into them.
5. Converting diffs (ND)
- ND1
getNetworkDiff(recordsDiff)mapsaddedtoputops,updatedtopatchops (computed withdiffRecord, entries that compute to no diff are omitted), andremovedtoremoveops. It returnsnullwhen the result would be empty. - ND2
toNetworkDiff(forwardDiff)maps plain-record puts toputops,[before, after]puts topatchops (omitted whendiffRecordfinds no difference), and deletes toremoveops. Unlike ND1 it always returns an object, possibly empty.
6. Validation helpers (RV) — internal
- RV1
diffAndValidateRecord(prev, next, type)returnsundefinedwhen the records produce no diff — and in that case does not validate at all. - RV2 When there is a diff, the new state is validated; a validator throw is rethrown as
TLSyncErrorwith reasonINVALID_RECORD. - RV3
applyAndDiffRecord(prev, patch, type)applies the patch (AD rules); if the result is reference-identical toprevit returnsundefined. Otherwise it returns[actualDiff, newState]whereactualDiffis recomputed fromprevto the patched state (it may differ from the input patch, e.g. ops that had no effect are dropped). Validation follows RV2. - RV4
validateRecord(state, type)validates, wrapping throws asTLSyncError/INVALID_RECORD.
7. Message chunking (CH)
- CH1
chunk(msg, maxSize)returns[msg]whenmsg.length < maxSize(strictly less — a message exactly atmaxSizeis chunked). - CH2 Chunks are prefixed
<n>_wherencounts down the chunks remaining after this one; the first chunk carries the highest number and the start of the message, and concatenating the chunk bodies in order reconstructs the message. - CH3 Each chunk's total length is at most
maxSize, except that every chunk carries at least one character of content even when the prefix alone exceedsmaxSize, and except for CH9. - CH9 A UTF-16 surrogate pair is never split across two chunks (
WebSocket.sendwould turn each lone half into U+FFFD): the boundary moves by one character, shrinking the chunk when it has more than one character of content and otherwise growing it by one, so a chunk may exceed the CH3 bound by one character. - CH4
JsonChunkAssembler.handleMessage: input starting with{while idle is parsed immediately and returned as{ data, stringified }. Invalid JSON in this case throws synchronously (callers treat a throw as a fatal session error). - CH5 Input starting with
{mid-sequence returns{ error: 'Unexpected non-chunk message' }; the partial sequence and the JSON message are both discarded and the assembler resets to idle. - CH6 Chunk inputs accumulate, returning
nulluntil the final (0_) chunk arrives, then the joined body is JSON-parsed and returned; a parse failure is returned as{ error }. Either way the assembler resets to idle. - CH7 A chunk whose countdown number is inconsistent with its position returns
{ error: 'Chunks received in wrong order' }. A non-JSON, non-chunk message returns anInvalid chunkerror. Both reset to idle. - CH8 Chunk bodies may contain any character, including line terminators like U+2028/U+2029 (the chunk regex uses dot-all).
- CH10 A partial assembly is bounded. A first chunk declaring more than
MAX_CHUNK_COUNTchunks is rejected before anything is buffered, and an assembly whose accumulated bodies exceedMAX_ASSEMBLED_MESSAGE_CHARSUTF-16 characters is rejected as soon as the limit is passed. Both return{ error }, discard what was buffered, and reset to idle; the size rejection's error is aMessageTooLargeError, since unlike the other assembly errors it is fatal rather than recoverable by reconnecting. The sender chooses the chunk count, and nothing obliges it to send the final chunk, so without these bounds one connection can hold an arbitrarily large receiver-side buffer open for the life of the socket. The bound covers the chunked path only: a single un-chunked{…}frame is left to the transport's own frame limit.
8. interval (IN)
- IN1
interval(cb, ms)invokescbeverymsmilliseconds until the returned dispose function is called.
9. MicrotaskNotifier (MN) — internal
- MN1
notify(...args)defers delivery to a microtask; each registered listener is called with the notification's arguments, in registration order. - MN2 Registration is itself deferred by one microtask: a listener does not receive notifications issued before
registerwas called, but does receive notifications issued after it (even in the same synchronous block). - MN3 The returned unsubscribe is synchronous and immediate; calling it before the registration microtask runs prevents the registration entirely; calling it twice is safe. Listeners are held in a
Set, so registering the same function twice deduplicates: it fires once per notification, and either registration's unsubscribe removes it. - MN4 A listener that throws is caught and logged; remaining listeners still run.
10. Storage contract (SS)
These rules hold for both InMemorySyncStorage and SQLiteSyncStorage. The shared test suite runs against both implementations.
- SS1 When constructed without a snapshot (and, for SQLite, with no pre-existing data), storage seeds from
DEFAULT_INITIAL_SNAPSHOT: onedocumentrecord, onepagerecord, clock 0. - SS2
transaction(callback, opts?)runscallback(txn)synchronously and returns{ documentClock, didChange, result, changes? }, whereresultis the callback's return value. - SS3
didChangeis true exactly when the document clock advanced during the transaction. - SS4 A callback that returns a promise causes the transaction to throw.
- SS5 A callback that throws rolls back every write made in the transaction — documents, tombstones, schema, and the clock — and rethrows; no change notification fires.
- SS6 After a transaction that advanced the clock,
onChangelisteners are notified on a microtask with{ id, documentClock }, whereidis the transaction'sopts.id(undefined when not given). Read-only transactions do not notify. Unsubscribing stops future notifications. AnonChangecallback passed to the constructor is registered the same way. - SS7 The
changesfield is populated only whenemitChanges: 'always'is passed, and contains the forward diff of everything that changed during the transaction. These implementations apply changes verbatim, soemitChanges: 'when-different'never emits. - SS8
txn.getClock()returns the clock at transaction start; after the first write it returns the incremented value. The storage-levelgetClock()always returns the committed clock. - SS9 The clock increments exactly once per writing transaction, no matter how many writes occur.
- SS10
txn.get(id)returns the record, orundefinedwhen absent. - SS11
txn.set(id, record)assertsid === record.id, stores the record withlastChangedClockset to the transaction's clock, and clears any tombstone with that id. - SS12
txn.delete(id)of an existing record removes it and writes a tombstone at the transaction's clock. Deleting an absent id is a complete no-op: no tombstone, no clock advance. - SS13
txn.entries()/keys()/values()iterate the documents. Consuming any of these iterators after the transaction has ended throws. - SS14
txn.getSchema()/setSchema(schema)read and write the persisted serialized schema. - SS15
txn.getChangesSince(c)returnsundefinedwhencequals the current clock. Acgreater than the current clock is treated as-1(everything changed). The result'swipeAllis true whenc < tombstoneHistoryStartsAtClock; in that caseputscontains every document anddeletesis empty. Otherwiseputscontains documents withlastChangedClock > c(strict) anddeletesthe tombstone ids with clock> c. - SS16
getSnapshot()returns{ documentClock, tombstoneHistoryStartsAtClock, documents, tombstones, schema }reflecting all committed transactions. - SS17 Snapshot fallbacks at construction:
documentClock ?? clock ?? 0;tombstoneHistoryStartsAtClock ?? documentClock. - SS18 Deleting a record schedules a throttled (1s, trailing-only) tombstone prune; nothing else schedules one, so a storage constructed with an oversized tombstone set stays unpruned until the next delete. When the prune runs and the tombstone count exceeds
MAX_TOMBSTONES(5000), it deletes the oldest tombstones — the overflow plusTOMBSTONE_PRUNE_BUFFER_SIZE(1000) more, never splitting a clock value — and advancestombstoneHistoryStartsAtClockto the oldest surviving tombstone's clock.
11. InMemorySyncStorage specifics (IM)
- IM1 At construction the document clock is clamped up to the maximum
lastChangedClock/tombstone clock found in the snapshot, even when the snapshot'sdocumentClockis lower. - IM2
tombstoneHistoryStartsAtClockis clamped down to the document clock. - IM3 Snapshot tombstones are discarded at construction when
tombstoneHistoryStartsAtClockequals the document clock (no usable history). - IM4 Stored records are dev-frozen, both when loaded from a snapshot and on
set. - IM5 When the snapshot has no schema, the earliest tldraw schema version is assumed (
createTLSchema().serializeEarliestVersion()). - IM6 Duplicate document ids in a snapshot: the last entry wins.
12. SQLiteSyncStorage specifics (SQ)
- SQ1 Construction creates/migrates the
documents,tombstones, andmetadatatables, honoring the wrapper'stablePrefix. Data persists across re-instantiation over the same database. - SQ2 Constructed with a snapshot, existing data is wiped and replaced. Constructed without one: pre-existing data is kept untouched; an empty database seeds from
DEFAULT_INITIAL_SNAPSHOT. AStoreSnapshotis accepted and converted (SL1). - SQ3 Unlike IM1–IM3, the snapshot's
documentClockandtombstoneHistoryStartsAtClockare taken verbatim (no clamping against document/tombstone clocks), and tombstones are loaded even when the history window is empty. - SQ4
SQLiteSyncStorage.hasBeenInitialized(wrapper)is true exactly when the metadata table exists and holds a non-empty schema string; it respects the table prefix and returns false (rather than throwing) on a missing table. - SQ5
SQLiteSyncStorage.getDocumentClock(wrapper)returns the persisted clock, ornullwhen storage is uninitialized. - SQ6 Document state is stored as JSON-encoded BLOBs. Databases created by the v1 schema (TEXT column) are migrated to BLOB preserving data; fresh databases start at migration version 2.
13. computeTombstonePruning (TP) — internal
- TP1 Returns
nullwhen the tombstone count is at or belowmaxTombstones. - TP2 The cutoff starts at
pruneBufferSize + count − maxTombstonesand is extended forward while it would split tombstones sharing a clock value;idsToDeleteis the firstcutoffentries (input must be sorted by clock ascending). - TP3
newTombstoneHistoryStartsAtClockis the clock of the oldest surviving tombstone, ordocumentClockwhen everything is deleted.
14. SQLite wrappers (NW, DO)
- NW1
NodeSqliteWrapperpassesexecandpreparethrough to the underlying database (node:sqliteDatabaseSyncorbetter-sqlite3); prepared statements supportall,iterate, andrunwith bindings and can be reused. - NW2
NodeSqliteWrapper.transactionwraps the callback inBEGIN/COMMIT, returns the callback's result, and on a throw issuesROLLBACKand rethrows the original error. - DO1
DurableObjectSqliteSyncWrapper.preparereturns a statement that re-executes the stored SQL with the given bindings on everyiterate/all/runcall;execforwards tosql.exec;transactiondelegates to the Durable Object'stransactionSync.
15. Snapshot loading and conversion (SL)
- SL1
convertStoreSnapshotToRoomSnapshotpasses aRoomSnapshot(anything with adocumentskey) through by reference; aStoreSnapshotbecomes a room snapshot with clock 0, every document atlastChangedClock0, and no tombstones. - SL2
loadSnapshotIntoStoragerequires the (converted) snapshot to have a schema and throws otherwise. - SL3 Documents deep-equal to the stored state are skipped (their
lastChangedClockis preserved); changed and new documents are written; stored documents absent from the snapshot are deleted (tombstoned). - SL4 The snapshot's schema is persisted and
schema.migrateStoragethen migrates all loaded records up to the room's current schema version.
16. ServerSocketAdapter (SA)
- SA1
isOpenis true exactly when the wrapped socket'sreadyStateis 1. - SA2
sendMessage(msg)JSON-stringifies the message and sends it; when configured,onBeforeSendMessage(msg, stringified)is invoked before the send. - SA3
close(code?, reason?)passes through to the wrapped socket.
17. ClientWebSocketAdapter (CW)
- CW1 Constructed with a
getUrifunction (sync or async); a connection attempt starts immediately.http(s)URIs are converted tows(s).getUriis re-invoked for every attempt, so it can return fresh auth tokens. - CW2
connectionStatusstarts as'offline'; when the socket opens it becomes'online'and status listeners receive{ status: 'online' }. - CW3 A socket close with code
TLSyncErrorCloseEventCode(4099) produces status'error'with the close reason (or'UNKNOWN_ERROR'when empty); all other closes and socket errors produce'offline'. - CW4 Status listeners are notified only on actual status changes; an
'error'arriving while already'offline'is suppressed. - CW5 A close with code 1006 on a socket that never opened logs a one-time warning about the URL likely not supporting WebSockets.
- CW6
sendMessage: when online, the message is JSON-stringified, chunked (CH1–CH3), and each chunk sent; when a socket exists but is not online, the message is dropped with a console warning; with no socket it is silently dropped. Afterclose()it throws. A message whose stringified length exceedsMAX_ASSEMBLED_MESSAGE_CHARSis not sent at all: the server would reject the session for it (SR5) and the client would reconnect and re-send the same message, so the adapter reportsMESSAGE_TOO_LARGEthrough the same error path a server rejection takes. - CW7 Incoming socket messages are JSON-parsed and forwarded to
onReceiveMessagelisteners; listeners can unsubscribe. - CW8
restart()closes the current socket (notifying'offline') and starts a reconnection attempt. - CW9
close()disposes the adapter:restart,sendMessage, and listener registration then throw;closeitself is idempotent. - CW10 Events from orphaned sockets (a socket replaced after
restart/offline handling) are ignored — they cannot change status.
18. ReconnectManager (RM) — internal
- RM1 Reconnection delays follow exponential backoff with exponent 1.5, bounded by
ACTIVE_MIN_DELAY(500ms) andACTIVE_MAX_DELAY(2s) when the tab is visible, andINACTIVE_MIN_DELAY(1s) andINACTIVE_MAX_DELAY(5min) when hidden. - RM2 A successful connection resets the intended delay to
ACTIVE_MIN_DELAY. - RM3 The window
offlineevent closes the active socket (which triggers the reconnect cycle). - RM4 Reconnect hints (window
online, the document becoming visible) callmaybeReconnected: a socket that is OPEN is left alone; one that is CONNECTING for less thanATTEMPT_TIMEOUT(1s) is rechecked later; one CONNECTING for longer is closed and retried; otherwise the backoff is reset and a reconnect attempt happens immediately (honoring the minimum delay). - RM5
close()cancels all timers and event listeners.
19. TLSyncClient — connection lifecycle (CL)
- CL1 If the socket is already online at construction, a connect message is sent immediately; otherwise it is sent when the socket first reports
'online'. - CL2 The connect message carries a fresh unique
connectRequestId, the store's serialized schema, protocol version 8, andlastServerClock(−1 before any server contact, afterwards the last seen server clock). - CL3
onLoadfires on the first message received from the server, of any type. - CL4 A
connectresponse whoseconnectRequestIddoes not match the latest request is ignored. - CL5 On a
connectresponse withhydrationType: 'wipe_presence', the client reverts its speculative changes, removes all presence records, applies the server's diff, then re-applies the speculative changes on top and pushes them as a new push request. - CL6 With
hydrationType: 'wipe_all', all document records are additionally wiped before the server's diff is applied; speculative changes still re-apply on top afterwards. - CL7 After connecting,
onAfterConnectis called with{ isReadonly }from the connect message, and the current presence state (if any) is pushed. - CL8 When the socket goes
'offline', the client resets: presence records are removed from the store, pending and unsent pushes are dropped, and the client waits to reconnect. When the socket reports'error',onSyncError(reason)fires and the client closes permanently. - CL9 While connected, a ping is sent every 5 seconds; the client warns and restarts the socket only when nothing has been heard from the server for 10 seconds AND a ping has been outstanding and unanswered for at least 10 seconds (
PONG_TIMEOUT). Staleness without an unanswered ping (e.g. a throttled hidden tab whose own ping loop stopped) never resets. In a throttled hidden tab a genuinely dead socket is therefore detected within about two 1-minute wakes rather than one. - CL10 A
pong(or any server message) refreshes the server-interaction timestamp. - CL11 When
didCancelis provided and returns true, the next event causes the client to close instead of processing. - CL12
close()disposes all listeners and timers and removes thewindow.tlsyncdebugging reference (which construction installs). - CL13 An
incompatibility_errorserver message is legacy: it is logged as an error and otherwise ignored. - CL14 The unanswered-ping marker advances only when the previous ping was answered — any server message received at or after its send time counts, per CL10 (oldest-outstanding semantics), so a half-open socket that still accepts sends cannot defer detection indefinitely. The marker clears on connection reset.
20. TLSyncClient — pushing changes (CP)
- CP1 Store changes with source
'user'and scope'document'are folded into the speculative diff immediately. Remote and non-document changes are not pushed. - CP2 While connected, those changes also accumulate into an unsent diff which is sent as a push request on the network throttle; multiple changes coalesce into a single push containing the squashed diff. Changes that cancel out (create then delete) result in no push at all.
- CP3 While not connected, changes accumulate only in the speculative diff; nothing is sent, and the accumulated changes are pushed after the next successful connect (CL5).
- CP4 Each sent push increments the client clock by exactly one and carries it as
clientClock. - CP5 Presence pushes: the first send is
['put', record], subsequent sends are['patch', diff]against the last pushed state; an unchanged presence sends nothing; document and presence changes ride in the same push request when both are pending. After a reconnect, presence is re-put in full. - CP6 In
'solo'presence mode the presence signal is not pushed at all (document changes still are); the network throttle drops from 30fps to 1fps. - CP7 When the store reports possible corruption, pushes stop.
21. TLSyncClient — receiving and rebasing (CR)
- CR1
data,patch, andpush_resultevents are buffered and processed on the throttle; they are dropped entirely when the client is not connected to the room. - CR2 Rebase processes the buffer atomically inside a remote-change batch: speculative changes are reverted (without running store side effects), buffered server events are applied, then any still-pending and unsent local changes are re-applied on top and become the new speculative diff. Changes applied during a rebase do not generate new push requests.
- CR3 A
push_resultmust match the oldest pending push'sclientClock:commitapplies that push's diff as confirmed;discarddrops it;rebaseWithDiffapplies the server's modified diff instead. - CR4 A
push_resultwith no pending pushes, or with a mismatchedclientClock, is an error: the store is checked for usability and the connection resets. - CR5
lastServerClockadvances to the last buffered event'sserverClockand is used for the next reconnect. - CR6 Applying a network diff is value-aware: a
putequal to the stored record is a no-op for listeners, apatchfor a missing record is skipped, and aremoveof a missing record is skipped. - CR7
customevents invokeonCustomMessageReceived(data)withthisbound to null.
22. TLSyncRoom — construction (RC)
- RC1 The room serializes its schema with a JSON round-trip (so it contains no
undefinedvalues). - RC2 A schema with more than one
presence-scoped type throws at construction. - RC3 Construction runs
schema.migrateStoragein a storage transaction, migrating any pre-existing storage data up to the room's schema version. Re-running on already-migrated data changes nothing. - RC4 Storage
onChangenotifications carrying a foreign transaction id make the room broadcast the new changes to all connected clients. The room's own transactions (id'TLSyncRoom.txn') do not re-broadcast this way. - RC5 If an external change leaves the storage unable to produce an incremental diff (
wipeAll), the room closes everyConnectedsession so clients reconnect and re-hydrate. Sessions still awaiting their connect message are left alone: their handshake hydrates them from their ownlastServerClock. - RC6 The idle timeout defaults to
SESSION_IDLE_TIMEOUT(20s) and is configurable viaclientTimeout. A finite positive timeout starts a periodic prune interval ofmin(2000, timeout/4)ms;Infinityor 0 disables the interval (pruning then only happens on message activity or via the follow-up prune scheduled when a socket close/error cancels a session, per SES2). - RC7
close()marks the room closed, stops background work and pending per-session flush timers, closes every session's socket (a socket that throws on close does not stop the others) and forgets the sessions without emittingsession_removed, so late socket close/error events for them are no-ops and cannot emit lifecycle events on a closed room. No prune timers are scheduled afterwards. A session added afterclose()(viahandleNewSessionorhandleResumedSession) is rejected: its socket is closed and it is not stored.isClosed()reports it.
23. TLSyncRoom — connect handshake (HS)
- HS1
handleNewSessionregisters the session inAwaitingConnectMessagestate and assigns a presence id; a session re-registered under the same id keeps its previous presence id. - HS2 Protocol negotiation accepts versions 5 through 8: 5 and 6 are accepted with
requiresLegacyRejection(6's close protocol), 8 natively. Every version below 8 — including 7 — is accepted withsupportsStringAppend: false. Anything below 5 (or missing) is rejectedCLIENT_TOO_OLD; anything above 8 is rejectedSERVER_TOO_OLD. - HS3 A connect message without a schema, with a schema the server cannot migrate from, or whose migrations include any non-record-scope or down-less migration, is rejected
CLIENT_TOO_OLD. - HS4 The connect response echoes
connectRequestIdandisReadonly, carries the server's schema and current clock, andhydrationType: 'wipe_all'when storage cannot produce an incremental diff since the client'slastServerClock(including when that clock is in the future), else'wipe_presence'. - HS5 The connect response diff contains every other session's presence record — the connecting session's own presence is excluded — plus the document changes since the client's
lastServerClock(the full document set in thewipe_allcase), all down-migrated when the client's schema is older. - HS6 A successful handshake moves the session to
Connected.
24. TLSyncRoom — push handling (RP)
- RP1 Pushes from sessions that are not
Connectedare ignored. - RP2 A
putwhose record type is not a known document type rejects the session withINVALID_RECORD. A record that fails schema validation also rejects withINVALID_RECORD(delivered per SES4: code 4099 for modern sessions, the legacy message for protocol ≤ 6). - RP3 A
putof a new id stores the record and broadcasts aput; aputover an existing record stores the new state but broadcasts apatchcontaining only the computed difference; aputequal to the stored record changes nothing. - RP4 A
patchfor a missing (e.g. concurrently deleted) record is silently ignored. - RP5 A
patchis applied to the stored record (AD rules) and the result validated; the broadcast patch is the recomputed effective diff, not the client's input. - RP6 A
removeof an existing record deletes it (creating a tombstone) and broadcasts the removal; aremoveof a missing record is ignored. - RP7 The push result is
commitwhen the applied network diff equals the requested document diff exactly (a presence-only push therefore commits),discardwhen a requested document diff produced no change, and{ rebaseWithDiff }carrying the actual (per-session-migrated) diff otherwise. - RP8 Applied changes are broadcast as a
patchto every other connected session, never echoed to the pusher (who gets the push result instead). - RP9 For read-only sessions the document portion of a push is ignored wholesale (resulting in
discard); the presence portion is still processed. - RP10 Presence ops are applied to the in-memory presence store under the session's presence id, with
idandtypeNameforced server-side; they are broadcast to other sessions and never touch document storage or the document clock.onPresenceChangefires on a microtask after presence changes. - RP11 A push (or ping) updates the session's
lastInteractionTime; a ping is answered withpong. - RP12 A
TLSyncErrorthrown while handling a message (validation, migration) rejects only that session with the error's reason. - RP13 When the storage layer reports that it modified the transaction's changes (
emitChanges: 'when-different'), the room broadcasts the storage's actual changes and the pusher receives them asrebaseWithDiff. (The built-in storages never do this; the rule is observable with a custom storage.)
25. TLSyncRoom — messaging and broadcast (RB)
- RB1 Data messages (
patch,push_result) to a session are debounced: the first is sent immediately wrapped as{ type: 'data', data: [msg] }; messages within the followingDATA_MESSAGE_DEBOUNCE_INTERVAL(1000/60 ms) are buffered and flushed together as onedatamessage. The array handed to the socket is not mutated afterwards, so sockets may serialize lazily. - RB2 Non-data messages flush any buffered data messages first, preserving order — except
pong, which skips the flush. - RB3 Sending to a session whose socket is closed cancels that session — on the debounced flush path as well as the immediate one.
- RB4 Broadcasts are migrated per session (MG1); a migration failure rejects only the affected session, and the broadcast proceeds for the others.
- RB5
sendCustomMessagedelivers{ type: 'custom', data }to a connected session; sending to an unknown or not-yet-connected session logs a warning and does nothing.
26. TLSyncRoom — session lifecycle (SES)
- SES1
pruneSessions: aConnectedsession idle longer than the idle timeout, or whose socket is closed, is cancelled. AnAwaitingConnectMessagesession older thanSESSION_START_WAIT_TIME(10s), or whose socket is closed, is removed immediately. AnAwaitingRemovalsession older thanSESSION_REMOVAL_WAIT_TIME(5s) is removed. - SES2 Cancelling (also via
handleClose) moves the session toAwaitingRemoval— keeping its presence id and meta for a quick reconnect — closes the socket, and schedules a follow-up prune. - SES3 Removal deletes the session, closes the socket (with code 4099 and the reason when fatal), deletes the session's presence record and broadcasts that deletion to everyone, emits
session_removed, and emitsroom_became_emptywhen it was the last session. - SES4
rejectSessionwith a reason: legacy sessions (protocol ≤ 6) receive a deprecatedincompatibility_errormessage (reason mapped:CLIENT_TOO_OLD→clientTooOld,SERVER_TOO_OLD→serverTooOld,INVALID_RECORD→invalidRecord, anything else →invalidOperation) and are then removed without a close code; modern sessions are closed with code 4099 and the reason string, truncated on a code-point boundary and ended with... (+N bytes)(N being the bytes dropped) to fit the 123 UTF-8 bytes a close frame allows, so the socket'sclose()does not throw. Without a reason it is a plain removal. - SES5
getCanEmitStringAppend()is false when any connected session hassupportsStringAppend: false; pushes handled in that state use legacy append mode (D5) so broadcast diffs avoid string-append ops. - SES6
handleResumedSessionregisters a session directly inConnectedstate (no handshake):requiresDownMigrationsis recomputed from the supplied schema, and a supplied presence record is restored into the presence store. - SES7 A message from an unknown session id logs a warning and is ignored.
27. Migrations over the wire (MG)
- MG1 Down-migration of outgoing diffs: full-record puts are migrated down;
[before, after]updates migrate both sides and send the re-computed patch between the migrated versions; deletes pass through. A failure rejects the sessionCLIENT_TOO_OLD. - MG2 Up-migration of incoming pushes: put records are migrated up before storing. Patches from older clients are applied to the down-migrated stored record, and the patched result is migrated back up. Migration failures reject the session
CLIENT_TOO_OLD. - MG3 Sessions whose serialized schema deep-equals the server's share the server's schema object and skip migration entirely; same-version clients exchange minimal patches.
28. TLSocketRoom (SR)
- SR1 Providing both
storageandinitialSnapshotthrows. With neither, anInMemorySyncStorageseeded fromDEFAULT_INITIAL_SNAPSHOTis created;initialSnapshot(deprecated) accepts both room and store snapshots. - SR2 The deprecated
onDataChangecallback is wired tostorage.onChange(fires on a microtask after document changes, including programmatic ones). - SR3
logdefaults to{ error: console.error }only when thelogkey is absent from the options object; an explicitly passedlog: undefinedleaves the room without a logger. The same logger is handed to the innerTLSyncRoom, so authorizer failures are logged under the default too. - SR4
handleSocketConnectregisters the session (readonly defaults to false), attachesmessage/close/errorlisteners when the socket supportsaddEventListener, and creates a chunk assembler for the session. - SR5
handleSocketMessageassembles chunks (CH rules), then for each complete message: invokesonAfterReceiveMessage, forwards to the room, and runs a prune pass (after handling, so a session is never evicted by its own message). Assembly errors close the socket via the error path, except an over-size error (CH10), which rejects the session withMESSAGE_TOO_LARGEbecause reconnecting would re-send the same message; a thrown exception rejects the session withUNKNOWN_ERROR. - SR6
handleSocketErrorandhandleSocketClosecancel the session (grace period applies per SES2) and clear any pending session-snapshot timer. - SR7
getNumActiveSessions()counts all sessions including those awaiting connect/removal;getSessions()reports{ sessionId, isConnected, isReadonly, meta }. - SR8
getRecord(id)returns a deep clone of the stored record (safe to mutate), orundefined. - SR9
getCurrentDocumentClock()returns the storage clock;getCurrentSnapshot()delegates tostorage.getSnapshotand throws when the storage doesn't support it. - SR10
loadSnapshotreplaces the room contents per SL2–SL4 inside one transaction; connected clients receive the resulting changes (or are force-reconnected on a wipe per RC5). - SR11
close()closes the room, clears session-snapshot timers, and disposes subscriptions;closeSession(sessionId, fatalReason?)behaves as SES4. - SR12 With
onSessionSnapshotconfigured, a session snapshot is delivered 5 seconds after that session's last message; further messages reset the timer; socket close/error cancels it. - SR13
getSessionSnapshotreturns null unless the session isConnected; the snapshot carries the serialized schema, readonly flag, legacy/append flags, presence id, and the presence record with large fields stripped (scribbles: [],chatMessage: '',selectedShapeIds: [],brush: null). - SR14
handleSocketResumerestores a session from a snapshot directly intoConnectedstate without attaching socket listeners (hibernation environments deliver events via methods); the resumed session handles pings and pushes normally. - SR15
getPresenceRecords()returns a map of presence id to presence record for every presence currently in the room.
29. updateStore (US) — deprecated
- US1 The updater receives an isolated context over a point-in-time snapshot:
getreturns a deep clone (mutating it does nothing withoutput),nullfor missing or deleted ids. - US2
putvalidates the record against the schema (unknown types and invalid records throw). Aputdeep-equal to the snapshot state cancels any pending put for that id; anyputclears a pending delete. - US3
deleteaccepts a record or id, cancels any pending put, and records a delete only for ids that exist in the snapshot. - US4
getAllreturns pending puts plus all snapshot records that are neither deleted nor shadowed, as clones. - US5 After the updater resolves (it may be async), all changes commit in a single storage transaction: the document clock advances at most once and
onChangefires only when something actually changed. - US6 Context methods throw after the updater completes (
'StoreUpdateContext is closed');updateStoreon a closed room rejects.