Files
upstream-tldraw/packages/sync-core/SPEC.md
upstream-mirror a774ac673a
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
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

290 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# @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 by `TLSocketRoom`) is the authoritative server-side copy of a document. The **client** (`TLSyncClient`) keeps a local `Store` synchronized 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 to `put`/`patch`/`remove` ops. An **object diff** (`ObjectDiff`) is the nested per-key form used inside `patch` ops.
- 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), or `rebaseWithDiff` (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** `TLSyncErrorCloseEventCode` is 4099. It is the WebSocket close code used for fatal, non-retriable sync errors on modern sessions; the close reason carries a `TLSyncErrorCloseEventReason` string. (Legacy sessions are rejected without it — see SES4.)
- **PV3** `TLRemoteSyncError` has `name: 'RemoteSyncError'`, `message: 'sync error: <reason>'`, and exposes the reason as `.reason`.
## 3. Computing diffs: `diffRecord` (D)
- **D1** `diffRecord(prev, next)` returns an `ObjectDiff` describing how to turn `prev` into `next`, or `null` when there is nothing to change (including when `prev === next`).
- **D2** A key present in `prev` but missing from `next` produces `['delete']`. A key present in `next` but missing from `prev` produces `['put', value]`.
- **D3** `props` and `meta` are 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`/`meta` or deeper), object values are recursively patched; `null` and primitive values are put.
- **D5** When both values are strings (at any level, including top-level keys) and `next` starts with `prev`, the diff is `['append', addedSuffix, prev.length]`. Other string changes are puts. With `legacyAppendMode` enabled, string appends become puts instead; array appends (D7) are unaffected by `legacyAppendMode`.
- **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 `put` is applied only when the new value is not deep-equal to the current value.
- **AD3** An `append` is 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 `patch` is 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 `delete` removes 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)` maps `added` to `put` ops, `updated` to `patch` ops (computed with `diffRecord`, entries that compute to no diff are omitted), and `removed` to `remove` ops. It returns `null` when the result would be empty.
- **ND2** `toNetworkDiff(forwardDiff)` maps plain-record puts to `put` ops, `[before, after]` puts to `patch` ops (omitted when `diffRecord` finds no difference), and deletes to `remove` ops. Unlike ND1 it always returns an object, possibly empty.
## 6. Validation helpers (RV) — internal
- **RV1** `diffAndValidateRecord(prev, next, type)` returns `undefined` when 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 `TLSyncError` with reason `INVALID_RECORD`.
- **RV3** `applyAndDiffRecord(prev, patch, type)` applies the patch (AD rules); if the result is reference-identical to `prev` it returns `undefined`. Otherwise it returns `[actualDiff, newState]` where `actualDiff` is recomputed from `prev` to 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 as `TLSyncError`/`INVALID_RECORD`.
## 7. Message chunking (CH)
- **CH1** `chunk(msg, maxSize)` returns `[msg]` when `msg.length < maxSize` (strictly less — a message exactly at `maxSize` is chunked).
- **CH2** Chunks are prefixed `<n>_` where `n` counts 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 exceeds `maxSize`, and except for CH9.
- **CH9** A UTF-16 surrogate pair is never split across two chunks (`WebSocket.send` would 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 `null` until 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 an `Invalid chunk` error. 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_COUNT` chunks is rejected before anything is buffered, and an assembly whose accumulated bodies exceed `MAX_ASSEMBLED_MESSAGE_CHARS` UTF-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 a `MessageTooLargeError`, 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)` invokes `cb` every `ms` milliseconds 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_ `register` was 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`: one `document` record, one `page` record, clock 0.
- **SS2** `transaction(callback, opts?)` runs `callback(txn)` synchronously and returns `{ documentClock, didChange, result, changes? }`, where `result` is the callback's return value.
- **SS3** `didChange` is 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, `onChange` listeners are notified on a microtask with `{ id, documentClock }`, where `id` is the transaction's `opts.id` (undefined when not given). Read-only transactions do not notify. Unsubscribing stops future notifications. An `onChange` callback passed to the constructor is registered the same way.
- **SS7** The `changes` field is populated only when `emitChanges: 'always'` is passed, and contains the forward diff of everything that changed during the transaction. These implementations apply changes verbatim, so `emitChanges: 'when-different'` never emits.
- **SS8** `txn.getClock()` returns the clock at transaction start; after the first write it returns the incremented value. The storage-level `getClock()` 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, or `undefined` when absent.
- **SS11** `txn.set(id, record)` asserts `id === record.id`, stores the record with `lastChangedClock` set 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)` returns `undefined` when `c` equals the current clock. A `c` greater than the current clock is treated as `-1` (everything changed). The result's `wipeAll` is true when `c < tombstoneHistoryStartsAtClock`; in that case `puts` contains every document and `deletes` is empty. Otherwise `puts` contains documents with `lastChangedClock > c` (strict) and `deletes` the 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 plus `TOMBSTONE_PRUNE_BUFFER_SIZE` (1000) more, never splitting a clock value — and advances `tombstoneHistoryStartsAtClock` to 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's `documentClock` is lower.
- **IM2** `tombstoneHistoryStartsAtClock` is clamped _down_ to the document clock.
- **IM3** Snapshot tombstones are discarded at construction when `tombstoneHistoryStartsAtClock` equals 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`, and `metadata` tables, honoring the wrapper's `tablePrefix`. 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`. A `StoreSnapshot` is accepted and converted (SL1).
- **SQ3** Unlike IM1–IM3, the snapshot's `documentClock` and `tombstoneHistoryStartsAtClock` are 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, or `null` when 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 `null` when the tombstone count is at or below `maxTombstones`.
- **TP2** The cutoff starts at `pruneBufferSize + count − maxTombstones` and is extended forward while it would split tombstones sharing a clock value; `idsToDelete` is the first `cutoff` entries (input must be sorted by clock ascending).
- **TP3** `newTombstoneHistoryStartsAtClock` is the clock of the oldest surviving tombstone, or `documentClock` when everything is deleted.
## 14. SQLite wrappers (NW, DO)
- **NW1** `NodeSqliteWrapper` passes `exec` and `prepare` through to the underlying database (`node:sqlite` `DatabaseSync` or `better-sqlite3`); prepared statements support `all`, `iterate`, and `run` with bindings and can be reused.
- **NW2** `NodeSqliteWrapper.transaction` wraps the callback in `BEGIN`/`COMMIT`, returns the callback's result, and on a throw issues `ROLLBACK` and rethrows the original error.
- **DO1** `DurableObjectSqliteSyncWrapper.prepare` returns a statement that re-executes the stored SQL with the given bindings on every `iterate`/`all`/`run` call; `exec` forwards to `sql.exec`; `transaction` delegates to the Durable Object's `transactionSync`.
## 15. Snapshot loading and conversion (SL)
- **SL1** `convertStoreSnapshotToRoomSnapshot` passes a `RoomSnapshot` (anything with a `documents` key) through by reference; a `StoreSnapshot` becomes a room snapshot with clock 0, every document at `lastChangedClock` 0, and no tombstones.
- **SL2** `loadSnapshotIntoStorage` requires the (converted) snapshot to have a schema and throws otherwise.
- **SL3** Documents deep-equal to the stored state are skipped (their `lastChangedClock` is 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.migrateStorage` then migrates all loaded records up to the room's current schema version.
## 16. `ServerSocketAdapter` (SA)
- **SA1** `isOpen` is true exactly when the wrapped socket's `readyState` is 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 `getUri` function (sync or async); a connection attempt starts immediately. `http(s)` URIs are converted to `ws(s)`. `getUri` is re-invoked for every attempt, so it can return fresh auth tokens.
- **CW2** `connectionStatus` starts 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. After `close()` it throws. A message whose stringified length exceeds `MAX_ASSEMBLED_MESSAGE_CHARS` is 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 reports `MESSAGE_TOO_LARGE` through the same error path a server rejection takes.
- **CW7** Incoming socket messages are JSON-parsed and forwarded to `onReceiveMessage` listeners; 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; `close` itself 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) and `ACTIVE_MAX_DELAY` (2s) when the tab is visible, and `INACTIVE_MIN_DELAY` (1s) and `INACTIVE_MAX_DELAY` (5min) when hidden.
- **RM2** A successful connection resets the intended delay to `ACTIVE_MIN_DELAY`.
- **RM3** The window `offline` event closes the active socket (which triggers the reconnect cycle).
- **RM4** Reconnect hints (window `online`, the document becoming visible) call `maybeReconnected`: a socket that is OPEN is left alone; one that is CONNECTING for less than `ATTEMPT_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, and `lastServerClock` (−1 before any server contact, afterwards the last seen server clock).
- **CL3** `onLoad` fires on the first message received from the server, of any type.
- **CL4** A `connect` response whose `connectRequestId` does not match the latest request is ignored.
- **CL5** On a `connect` response with `hydrationType: '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, `onAfterConnect` is 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 `didCancel` is provided and returns true, the next event causes the client to close instead of processing.
- **CL12** `close()` disposes all listeners and timers and removes the `window.tlsync` debugging reference (which construction installs).
- **CL13** An `incompatibility_error` server 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`, and `push_result` events 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_result` must match the oldest pending push's `clientClock`: `commit` applies that push's diff as confirmed; `discard` drops it; `rebaseWithDiff` applies the server's modified diff instead.
- **CR4** A `push_result` with no pending pushes, or with a mismatched `clientClock`, is an error: the store is checked for usability and the connection resets.
- **CR5** `lastServerClock` advances to the last buffered event's `serverClock` and is used for the next reconnect.
- **CR6** Applying a network diff is value-aware: a `put` equal to the stored record is a no-op for listeners, a `patch` for a missing record is skipped, and a `remove` of a missing record is skipped.
- **CR7** `custom` events invoke `onCustomMessageReceived(data)` with `this` bound to null.
## 22. `TLSyncRoom` — construction (RC)
- **RC1** The room serializes its schema with a JSON round-trip (so it contains no `undefined` values).
- **RC2** A schema with more than one `presence`-scoped type throws at construction.
- **RC3** Construction runs `schema.migrateStorage` in 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 `onChange` notifications 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 every `Connected` session so clients reconnect and re-hydrate. Sessions still awaiting their connect message are left alone: their handshake hydrates them from their own `lastServerClock`.
- **RC6** The idle timeout defaults to `SESSION_IDLE_TIMEOUT` (20s) and is configurable via `clientTimeout`. A finite positive timeout starts a periodic prune interval of `min(2000, timeout/4)` ms; `Infinity` or 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 emitting `session_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 after `close()` (via `handleNewSession` or `handleResumedSession`) is rejected: its socket is closed and it is not stored. `isClosed()` reports it.
## 23. `TLSyncRoom` — connect handshake (HS)
- **HS1** `handleNewSession` registers the session in `AwaitingConnectMessage` state 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 with `supportsStringAppend: false`. Anything below 5 (or missing) is rejected `CLIENT_TOO_OLD`; anything above 8 is rejected `SERVER_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 `connectRequestId` and `isReadonly`, carries the server's schema and current clock, and `hydrationType: 'wipe_all'` when storage cannot produce an incremental diff since the client's `lastServerClock` (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 the `wipe_all` case), 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 `Connected` are ignored.
- **RP2** A `put` whose record type is not a known document type rejects the session with `INVALID_RECORD`. A record that fails schema validation also rejects with `INVALID_RECORD` (delivered per SES4: code 4099 for modern sessions, the legacy message for protocol ≤ 6).
- **RP3** A `put` of a new id stores the record and broadcasts a `put`; a `put` over an existing record stores the new state but broadcasts a `patch` containing only the computed difference; a `put` equal to the stored record changes nothing.
- **RP4** A `patch` for a missing (e.g. concurrently deleted) record is silently ignored.
- **RP5** A `patch` is 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 `remove` of an existing record deletes it (creating a tombstone) and broadcasts the removal; a `remove` of a missing record is ignored.
- **RP7** The push result is `commit` when the applied network diff equals the requested document diff exactly (a presence-only push therefore commits), `discard` when a requested document diff produced no change, and `{ rebaseWithDiff }` carrying the actual (per-session-migrated) diff otherwise.
- **RP8** Applied changes are broadcast as a `patch` to 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 `id` and `typeName` forced server-side; they are broadcast to other sessions and never touch document storage or the document clock. `onPresenceChange` fires on a microtask after presence changes.
- **RP11** A push (or ping) updates the session's `lastInteractionTime`; a ping is answered with `pong`.
- **RP12** A `TLSyncError` thrown 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 as `rebaseWithDiff`. (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 following `DATA_MESSAGE_DEBOUNCE_INTERVAL` (1000/60 ms) are buffered and flushed together as one `data` message. 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** `sendCustomMessage` delivers `{ 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`: a `Connected` session idle longer than the idle timeout, or whose socket is closed, is cancelled. An `AwaitingConnectMessage` session older than `SESSION_START_WAIT_TIME` (10s), or whose socket is closed, is removed immediately. An `AwaitingRemoval` session older than `SESSION_REMOVAL_WAIT_TIME` (5s) is removed.
- **SES2** Cancelling (also via `handleClose`) moves the session to `AwaitingRemoval` — 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 emits `room_became_empty` when it was the last session.
- **SES4** `rejectSession` with a reason: legacy sessions (protocol ≤ 6) receive a deprecated `incompatibility_error` message (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's `close()` does not throw. Without a reason it is a plain removal.
- **SES5** `getCanEmitStringAppend()` is false when any connected session has `supportsStringAppend: false`; pushes handled in that state use legacy append mode (D5) so broadcast diffs avoid string-append ops.
- **SES6** `handleResumedSession` registers a session directly in `Connected` state (no handshake): `requiresDownMigrations` is 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 session `CLIENT_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 `storage` and `initialSnapshot` throws. With neither, an `InMemorySyncStorage` seeded from `DEFAULT_INITIAL_SNAPSHOT` is created; `initialSnapshot` (deprecated) accepts both room and store snapshots.
- **SR2** The deprecated `onDataChange` callback is wired to `storage.onChange` (fires on a microtask after document changes, including programmatic ones).
- **SR3** `log` defaults to `{ error: console.error }` only when the `log` key is absent from the options object; an explicitly passed `log: undefined` leaves the room without a logger. The same logger is handed to the inner `TLSyncRoom`, so authorizer failures are logged under the default too.
- **SR4** `handleSocketConnect` registers the session (readonly defaults to false), attaches `message`/`close`/`error` listeners when the socket supports `addEventListener`, and creates a chunk assembler for the session.
- **SR5** `handleSocketMessage` assembles chunks (CH rules), then for each complete message: invokes `onAfterReceiveMessage`, 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 with `MESSAGE_TOO_LARGE` because reconnecting would re-send the same message; a thrown exception rejects the session with `UNKNOWN_ERROR`.
- **SR6** `handleSocketError` and `handleSocketClose` cancel 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), or `undefined`.
- **SR9** `getCurrentDocumentClock()` returns the storage clock; `getCurrentSnapshot()` delegates to `storage.getSnapshot` and throws when the storage doesn't support it.
- **SR10** `loadSnapshot` replaces 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 `onSessionSnapshot` configured, a session snapshot is delivered 5 seconds after that session's last message; further messages reset the timer; socket close/error cancels it.
- **SR13** `getSessionSnapshot` returns null unless the session is `Connected`; 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** `handleSocketResume` restores a session from a snapshot directly into `Connected` state 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: `get` returns a deep clone (mutating it does nothing without `put`), `null` for missing or deleted ids.
- **US2** `put` validates the record against the schema (unknown types and invalid records throw). A `put` deep-equal to the snapshot state cancels any pending put for that id; any `put` clears a pending delete.
- **US3** `delete` accepts a record or id, cancels any pending put, and records a delete only for ids that exist in the snapshot.
- **US4** `getAll` returns 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 `onChange` fires only when something actually changed.
- **US6** Context methods throw after the updater completes (`'StoreUpdateContext is closed'`); `updateStore` on a closed room rejects.