snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
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

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

662
packages/sync-core/DOCS.md Normal file
View File

@@ -0,0 +1,662 @@
# @tldraw/sync-core
The `@tldraw/sync-core` package provides the foundational infrastructure for real-time collaboration and synchronization in tldraw applications. It implements a robust client-server protocol for sharing drawing state across multiple users, handling network reliability, conflict resolution, and maintaining data consistency in distributed environments.
## 1. Introduction
**Sync-core** is the engine that powers real-time collaboration in tldraw. It enables multiple users to work on the same drawing simultaneously, automatically synchronizing changes while gracefully handling network issues and conflicts.
You create collaborative tldraw applications by connecting clients to sync rooms, where each room manages the shared state for a document. Changes made by any user are automatically distributed to all other connected users in near real-time.
```ts
import { TLSyncClient } from '@tldraw/sync-core'
// Connect to a collaborative room
const syncClient = new TLSyncClient({
store: myTldrawStore,
socket: myWebSocketAdapter,
roomId: 'drawing-room-123',
})
syncClient.connect()
// Now all changes to myTldrawStore are synchronized with other users
```
When you update the store locally, the changes are immediately visible in your UI (optimistic updates), then sent to the server for validation and distribution to other clients.
> Tip: Sync-core is designed to work with any WebSocket implementation, making it suitable for various deployment scenarios from simple Node.js servers to edge computing platforms.
## 2. Core Concepts
### Client-Server Architecture
Sync-core uses a **server-authoritative** model where the server is the single source of truth for all changes. This ensures data consistency while still providing responsive local interactions:
- **Optimistic Updates**: Your local changes apply immediately for responsive UI
- **Server Validation**: The server validates and potentially modifies your changes
- **Conflict Resolution**: If conflicts occur, the server's version takes precedence
### Rooms and Sessions
A **room** represents a collaborative document space where multiple users can work together:
```ts
// Server-side room management
const room = new TLSyncRoom({
store: serverStore,
roomId: 'drawing-room-123',
})
// Each connected client creates a session
room.handleSocketConnect(clientSocket, sessionMeta)
```
Each client connection creates a **session** within the room, tracking that user's connection state, permissions, and presence information.
### Network Diffs and Synchronization
Instead of sending entire document states, sync-core uses **network diffs** - compact representations of what actually changed:
```ts
// Example network diff for updating a shape's position
const diff = {
'shape:abc123': [
RecordOpType.Patch,
{
x: [ValueOpType.Put, 150],
y: [ValueOpType.Put, 200],
},
],
}
```
This approach minimizes bandwidth usage and enables efficient synchronization even with large documents.
## 3. Basic Usage
### Setting Up a Sync Client
To enable synchronization in your tldraw application, you need three components: a store, a WebSocket adapter, and a sync client:
```ts
import { createTLStore } from '@tldraw/store'
import { createTLSchema } from '@tldraw/tlschema'
import { TLSyncClient, ClientWebSocketAdapter } from '@tldraw/sync-core'
// Create your tldraw store
const store = createTLStore({
schema: createTLSchema(),
})
// Create a WebSocket connection
const socket = new ClientWebSocketAdapter('ws://localhost:3000/sync')
// Create the sync client
const syncClient = new TLSyncClient({
store,
socket,
roomId: 'my-drawing-room',
})
// Start synchronization
syncClient.connect()
```
Once connected, any changes to your store will automatically sync with other clients in the same room.
### Monitoring Connection Status
The sync client provides reactive status information:
```ts
import { react } from '@tldraw/state'
// React to connection status changes
react('connection status', () => {
const status = syncClient.status.get()
switch (status) {
case 'offline':
console.log('No network connection')
break
case 'connecting':
console.log('Connecting to server...')
break
case 'online':
console.log('Connected and synchronized')
break
}
})
```
The status signal automatically updates as network conditions change, allowing your UI to reflect the current connection state.
### Handling Connection Events
You can listen to specific sync events for custom behavior:
```ts
syncClient.onReceiveMessage((message) => {
switch (message.type) {
case 'connect':
console.log('Successfully connected to room')
break
case 'incompatibility-error':
console.log('Client version incompatible with server')
break
}
})
```
> Tip: Always handle incompatibility errors gracefully - they indicate version mismatches between your client and server.
## 4. Advanced Topics
### Server-Side Room Management
On the server side, you manage rooms that coordinate multiple client sessions:
```ts
import { TLSyncRoom } from '@tldraw/sync-core'
class CollaborationServer {
private rooms = new Map<string, TLSyncRoom>()
getOrCreateRoom(roomId: string) {
if (!this.rooms.has(roomId)) {
const room = new TLSyncRoom({
store: this.createRoomStore(),
roomId,
// Optional persistence adapter
persistenceAdapter: this.createPersistenceAdapter(roomId),
})
this.rooms.set(roomId, room)
}
return this.rooms.get(roomId)!
}
handleClientConnection(socket: WebSocket, roomId: string) {
const room = this.getOrCreateRoom(roomId)
room.handleSocketConnect(socket, {
sessionId: generateSessionId(),
userId: extractUserId(socket),
isReadonly: checkPermissions(socket),
})
}
}
```
Rooms automatically handle session lifecycle, broadcasting changes, and cleaning up disconnected clients.
### Custom WebSocket Adapters
While sync-core provides a `ClientWebSocketAdapter`, you can implement custom adapters for specific requirements:
```ts
import { TLPersistentClientSocket } from '@tldraw/sync-core'
class CustomSocketAdapter implements TLPersistentClientSocket {
status = atom<TLPersistentClientSocketStatus>('offline')
sendMessage(message: any): void {
// Your custom sending logic
this.customWebSocket.send(JSON.stringify(message))
}
onReceiveMessage = createNanoEvents<any>()
onStatusChange = createNanoEvents<TLPersistentClientSocketStatus>()
restart(): void {
// Your reconnection logic
}
}
```
Custom adapters let you integrate with existing WebSocket libraries or add custom authentication and error handling.
### Conflict Resolution Strategies
When multiple users edit simultaneously, conflicts can occur. Sync-core's server-authoritative model resolves these automatically:
```ts
// Client A moves shape to x: 100
store.update('shape:abc', (shape) => ({ ...shape, x: 100 }))
// Simultaneously, Client B moves same shape to x: 200
// Server receives both changes and determines the final state
// All clients receive the server's authoritative version
react('shape changes', () => {
const shape = store.get('shape:abc')
// Final position will be whatever the server decided
console.log('Final position:', shape?.x)
})
```
The server applies changes in the order it receives them, with later changes taking precedence for conflicting properties.
### Presence and Live Cursors
Sync-core supports real-time presence information like cursor positions:
```ts
// Client sends presence updates
syncClient.updatePresence({
cursor: { x: 150, y: 200 },
selection: ['shape:abc123'],
userName: 'Alice',
})
// Other clients receive presence updates
syncClient.onPresenceUpdate((presenceUpdates) => {
for (const [sessionId, presence] of presenceUpdates) {
updateLiveCursor(sessionId, presence.cursor)
updateUserSelection(sessionId, presence.selection)
}
})
```
Presence updates are ephemeral - they don't persist to storage and are only visible to currently connected users.
### Schema Evolution and Migrations
When your application's data schema changes, sync-core coordinates migrations across clients:
```ts
const schema = createTLSchema({
// Your shape definitions
shapes: {
myShape: MyShapeUtil,
},
})
// The client sends its schema version during connection
const syncClient = new TLSyncClient({
store: createTLStore({ schema }),
socket,
roomId: 'room-123',
})
```
If schema versions don't match between client and server, sync-core will:
1. Attempt automatic migration if possible
2. Send an incompatibility error if migration fails
3. Allow graceful degradation for unknown record types
> Tip: Design your schema changes to be backward-compatible when possible to avoid forcing all users to upgrade simultaneously.
## 5. Debugging
Sync-core provides several tools for understanding and debugging synchronization behavior in your collaborative applications.
### Connection Diagnostics
Monitor the detailed connection lifecycle:
```ts
import { TLSyncClient } from '@tldraw/sync-core'
const syncClient = new TLSyncClient({
/* ... */
})
// Enable detailed logging
syncClient.onReceiveMessage((message) => {
console.log('Received:', message.type, message)
})
syncClient.onStatusChange((status, previous) => {
console.log(`Status: ${previous} → ${status}`)
})
// Connection attempt
syncClient.connect()
// Output shows the complete handshake:
// Status: offline → connecting
// Received: connect { hydrationType: 'wipe_all', ... }
// Status: connecting → online
```
This reveals the exact sequence of messages during connection establishment and any errors that occur.
### Message Flow Analysis
Track all synchronization messages to understand data flow:
```ts
// Log outgoing messages
const originalSend = syncClient.socket.sendMessage
syncClient.socket.sendMessage = (message) => {
console.log('Sending:', message.type, message)
originalSend.call(syncClient.socket, message)
}
// Example output when making a change:
// Sending: push { diff: { "shape:abc123": [2, { x: [1, 150] }] } }
// Received: data { diff: { "shape:abc123": [2, { x: [1, 150] }] } }
```
This shows how local changes become push messages and return as data messages from the server.
### Network Diff Inspection
Understand what changes are being synchronized:
```ts
import { diffRecord } from '@tldraw/sync-core'
// Monitor store changes and see their diff representation
const unsubscribe = store.listen(
(entry) => {
if (entry.changes.length > 0) {
for (const change of entry.changes) {
console.log('Change type:', change.source)
console.log('Record diff:', change)
// For detailed diff analysis
if (change.type === 'update') {
const diff = diffRecord(change.prev, change.record)
console.log('Network diff would be:', diff)
}
}
}
},
{ source: 'user' }
)
// Example output:
// Change type: user
// Record diff: { type: 'update', id: 'shape:abc123', ... }
// Network diff would be: { x: [1, 150], y: [1, 200] }
```
### Session and Room Debugging
On the server side, inspect room and session states:
```ts
class DebuggableRoom extends TLSyncRoom {
debugSessions() {
console.log(`Room ${this.roomId} has ${this.getNumActiveConnections()} connections:`)
for (const [sessionId, session] of this.sessions) {
console.log(
` ${sessionId}: ${session.state} (${session.isReadonly ? 'readonly' : 'read-write'})`
)
}
}
debugLastChange() {
console.log('Last document change:', this.documentState.clock)
console.log('Store has', Object.keys(this.store.serialize()).length, 'records')
}
}
// Use during development
const room = new DebuggableRoom({
/* ... */
})
setInterval(() => room.debugSessions(), 5000)
```
### Error Diagnosis
Handle and debug common synchronization errors:
```ts
syncClient.onReceiveMessage((message) => {
switch (message.type) {
case 'incompatibility-error':
console.error('Schema mismatch:', {
clientSchema: message.clientSchema,
serverSchema: message.serverSchema,
reason: message.reason,
})
break
case 'error':
console.error('Sync error:', message.error)
// Common causes:
// - Room not found (check roomId)
// - Permission denied (check authentication)
// - Invalid record data (check schema validation)
break
}
})
// Network-level debugging
syncClient.socket.onStatusChange((status) => {
if (status === 'offline') {
console.log('Connection lost - check network and server health')
// Attempt manual reconnection
setTimeout(() => {
syncClient.socket.restart()
}, 1000)
}
})
```
### Performance Monitoring
Track synchronization performance metrics:
```ts
class SyncProfiler {
private messageCount = 0
private bytesTransferred = 0
private roundTripTimes: number[] = []
profile(syncClient: TLSyncClient) {
const startTime = Date.now()
syncClient.onReceiveMessage((message) => {
this.messageCount++
this.bytesTransferred += JSON.stringify(message).length
// Track ping/pong for latency
if (message.type === 'pong') {
const roundTrip = Date.now() - message.sentAt
this.roundTripTimes.push(roundTrip)
}
})
// Periodic reporting
setInterval(() => {
const avgLatency =
this.roundTripTimes.length > 0
? this.roundTripTimes.reduce((a, b) => a + b, 0) / this.roundTripTimes.length
: 0
console.log('Sync Performance:', {
uptime: Date.now() - startTime,
messages: this.messageCount,
bytesTransferred: this.bytesTransferred,
avgLatencyMs: avgLatency,
})
this.roundTripTimes = [] // Reset for next period
}, 30000)
}
}
new SyncProfiler().profile(syncClient)
```
> Tip: High message counts or latency often indicate network issues or inefficient change patterns. Consider batching rapid changes or optimizing your shape update logic.
## 6. Integration
### React Integration
Sync-core integrates seamlessly with React applications through the store's reactive signals:
```ts
import { useEditor } from '@tldraw/editor'
import { react } from '@tldraw/state'
import { useEffect, useState } from 'react'
function CollaborationStatusBadge() {
const editor = useEditor()
const [status, setStatus] = useState<string>('offline')
useEffect(() => {
if (!editor.store.syncClient) return
return react('sync status', () => {
setStatus(editor.store.syncClient.status.get())
})
}, [editor])
return (
<div className={`status-badge ${status}`}>
{status === 'online' ? '🟢 Connected' : '🔴 Offline'}
</div>
)
}
```
The reactive nature of sync-core means your React components automatically update when connection status or synchronized data changes.
### Custom Persistence
Integrate with your existing database or storage systems:
```ts
import { TLSyncRoom } from '@tldraw/sync-core'
class DatabasePersistenceAdapter {
constructor(
private db: Database,
private roomId: string
) {}
async loadRoom(): Promise<SerializedStore> {
const roomData = await this.db.query('SELECT document_state FROM rooms WHERE id = ?', [
this.roomId,
])
return JSON.parse(roomData.document_state)
}
async saveRoom(serializedStore: SerializedStore): Promise<void> {
await this.db.query('UPDATE rooms SET document_state = ?, updated_at = NOW() WHERE id = ?', [
JSON.stringify(serializedStore),
this.roomId,
])
}
}
const room = new TLSyncRoom({
store: createTLStore({ schema }),
roomId: 'room-123',
persistenceAdapter: new DatabasePersistenceAdapter(myDatabase, 'room-123'),
})
```
This allows rooms to persist their state to your preferred storage backend while maintaining real-time synchronization.
### Authentication and Authorization
Implement custom authentication by extending the WebSocket adapter:
```ts
class AuthenticatedSocketAdapter extends ClientWebSocketAdapter {
constructor(
url: string,
private authToken: string
) {
super(url)
}
protected connect(): void {
this.ws = new WebSocket(this.url, [], {
headers: {
Authorization: `Bearer ${this.authToken}`,
},
})
this.setupEventHandlers()
}
}
// Server-side authentication
room.handleSocketConnect(socket, {
sessionId: generateSessionId(),
userId: extractUserFromToken(authToken),
isReadonly: !hasEditPermission(authToken, roomId),
})
```
### Multi-Room Applications
Manage multiple collaborative documents in a single application:
```ts
class RoomManager {
private rooms = new Map<string, TLSyncClient>()
joinRoom(roomId: string): TLSyncClient {
if (this.rooms.has(roomId)) {
return this.rooms.get(roomId)!
}
const store = createTLStore({ schema: mySchema })
const socket = new ClientWebSocketAdapter(`ws://localhost:3000/rooms/${roomId}`)
const syncClient = new TLSyncClient({ store, socket, roomId })
this.rooms.set(roomId, syncClient)
syncClient.connect()
return syncClient
}
leaveRoom(roomId: string): void {
const client = this.rooms.get(roomId)
if (client) {
client.disconnect()
this.rooms.delete(roomId)
}
}
}
const roomManager = new RoomManager()
const drawingRoom = roomManager.joinRoom('drawing-123')
const presentationRoom = roomManager.joinRoom('slides-456')
```
### Edge Computing and Cloudflare Workers
Sync-core works well with edge computing platforms:
```ts
// Cloudflare Worker example
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.headers.get('Upgrade') !== 'websocket') {
return new Response('Expected websocket', { status: 426 })
}
const { 0: client, 1: server } = new WebSocketPair()
const roomId = new URL(request.url).pathname.split('/').pop()
const room = this.getOrCreateRoom(roomId, env)
room.handleSocketConnect(server, {
sessionId: crypto.randomUUID(),
// Extract user info from request headers or auth
})
return new Response(null, {
status: 101,
webSocket: client,
})
},
}
```
The lightweight nature of sync-core makes it suitable for serverless and edge environments where traditional long-running connections might be challenging.
> Tip: When deploying to edge environments, consider the trade-offs between geographical distribution (lower latency) and consistency (potential for split-brain scenarios).

View File

@@ -0,0 +1 @@
This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md)

View File

@@ -0,0 +1,37 @@
# @tldraw/sync-core
This project contains core functionality for [tldraw sync](https://tldraw.dev/docs/sync). [Click here](https://tldraw.dev/blog/product/announcing-tldraw-sync) to learn more.
## Documentation
Documentation for the most recent release can be found on [tldraw.dev/docs](https://tldraw.dev/docs), including [reference docs](https://tldraw.dev/reference/editor/Editor). Our release notes can be found [here](https://tldraw.dev/releases).
For more agent-friendly docs, see our [LLMs.txt](https://tldraw.dev/llms.txt).
A `DOCS.md` file is included alongside this README in the published package, with detailed API documentation and usage examples.
## License
This project is part of the tldraw SDK. It is provided under the [tldraw SDK license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
You can use the tldraw SDK in commercial or non-commercial projects so long as you preserve the "Made with tldraw" watermark on the canvas. To remove the watermark, you can purchase a [business license](https://tldraw.dev/pricing). Visit [tldraw.dev](https://tldraw.dev) to learn more.
## Trademarks
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
## Distributions
You can find tldraw on npm [here](https://www.npmjs.com/package/@tldraw/tldraw?activeTab=versions).
## Contribution
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
## Community
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
## Contact
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).

289
packages/sync-core/SPEC.md Normal file
View File

@@ -0,0 +1,289 @@
# @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.

View File

@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
"extends": "../../internal/config/api-extractor.json"
}

View File

@@ -0,0 +1,886 @@
## API Report File for "@tldraw/sync-core"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { Atom } from '@tldraw/state';
import { AtomMap } from '@tldraw/store';
import { DebouncedFunc } from 'lodash';
import { DebouncedFuncLeading } from 'lodash';
import { Emitter } from 'nanoevents';
import { RecordsDiff } from '@tldraw/store';
import { RecordType } from '@tldraw/store';
import { SerializedSchema } from '@tldraw/store';
import { SerializedSchemaV2 } from 'tldraw';
import { Signal } from '@tldraw/state';
import { Store } from '@tldraw/store';
import { StoreSchema } from '@tldraw/store';
import { StoreSnapshot } from '@tldraw/store';
import { SynchronousStorage } from '@tldraw/store';
import { TLDocument } from 'tldraw';
import { TLPage } from 'tldraw';
import { TLRecord } from '@tldraw/tlschema';
import { TLStoreSnapshot } from '@tldraw/tlschema';
import { UnknownRecord } from '@tldraw/store';
// @internal
export type AppendOp = [type: typeof ValueOpType.Append, value: string | unknown[], offset: number];
// @internal
export function applyObjectDiff<T extends object>(object: T, objectDiff: ObjectDiff): T;
// @internal
export function chunk(msg: string, maxSafeMessageSize?: number): string[];
// @internal
export class ClientWebSocketAdapter implements TLPersistentClientSocket<TLSocketClientSentEvent<TLRecord>, TLSocketServerSentEvent<TLRecord>> {
constructor(getUri: () => Promise<string> | string);
close(): void;
// (undocumented)
_closeSocket(): void;
get connectionStatus(): TLPersistentClientSocketStatus;
// (undocumented)
_connectionStatus: Atom<'initial' | TLPersistentClientSocketStatus>;
// (undocumented)
isDisposed: boolean;
onReceiveMessage(cb: (val: TLSocketServerSentEvent<TLRecord>) => void): () => void;
onStatusChange(cb: TLSocketStatusListener): () => void;
// (undocumented)
readonly _reconnectManager: ReconnectManager;
restart(): void;
sendMessage(msg: TLSocketClientSentEvent<TLRecord>): void;
// (undocumented)
_setNewSocket(ws: WebSocket): void;
// (undocumented)
_ws: null | WebSocket;
}
// @public
export const DEFAULT_INITIAL_SNAPSHOT: {
documentClock: number;
documents: ({
lastChangedClock: number;
state: TLDocument;
} | {
lastChangedClock: number;
state: TLPage;
})[];
schema: SerializedSchemaV2;
tombstoneHistoryStartsAtClock: number;
};
// @internal
export type DeleteOp = [type: typeof ValueOpType.Delete];
// @internal
export function diffRecord(prev: object, next: object, legacyAppendMode?: boolean): null | ObjectDiff;
// @public
export class DurableObjectSqliteSyncWrapper implements TLSyncSqliteWrapper {
constructor(storage: {
sql: {
exec(sql: string, ...bindings: unknown[]): Iterable<any> & {
toArray(): any[];
};
};
transactionSync(callback: () => any): any;
}, config?: TLSyncSqliteWrapperConfig | undefined);
// (undocumented)
config?: TLSyncSqliteWrapperConfig | undefined;
// (undocumented)
exec(sql: string): void;
// (undocumented)
prepare<TResult extends TLSqliteRow | void = void, TParams extends TLSqliteInputValue[] = []>(sql: string): TLSyncSqliteStatement<TResult, TParams>;
// (undocumented)
transaction<T>(callback: () => T): T;
}
// @internal
export function getNetworkDiff<R extends UnknownRecord>(diff: RecordsDiff<R>): NetworkDiff<R> | null;
// @internal
export function getTlsyncProtocolVersion(): number;
// @public
export class InMemorySyncStorage<R extends UnknownRecord> implements TLSyncStorage<R> {
constructor({ snapshot, objectTypes, onChange, }?: {
objectTypes?: readonly string[];
onChange?(arg: TLSyncStorageOnChangeCallbackProps): unknown;
snapshot?: RoomSnapshot;
});
// @internal (undocumented)
documentClock: Atom<number>;
// @internal (undocumented)
documents: AtomMap<string, {
lastChangedClock: number;
state: R;
}>;
// (undocumented)
getClock(): number;
// (undocumented)
getObjectsByIds(ids: Iterable<string>): RoomSnapshot['documents'];
// (undocumented)
getObjectsSnapshot(): RoomSnapshot['documents'];
// (undocumented)
getSnapshot(): RoomSnapshot;
// @internal
objects: AtomMap<string, {
lastChangedClock: number;
state: R;
}>;
// @internal (undocumented)
readonly objectTypes: ReadonlySet<string>;
// (undocumented)
onChange(callback: (arg: TLSyncStorageOnChangeCallbackProps) => unknown): () => void;
// @internal (undocumented)
pruneTombstones: DebouncedFunc<() => void>;
// @internal (undocumented)
schema: Atom<SerializedSchema>;
// @internal (undocumented)
tombstoneHistoryStartsAtClock: Atom<number>;
// @internal (undocumented)
tombstones: AtomMap<string, number>;
// (undocumented)
transaction<T>(callback: TLSyncStorageTransactionCallback<R, T>, opts?: TLSyncStorageTransactionOptions): TLSyncStorageTransactionResult<T, R>;
}
// @public
export class JsonChunkAssembler {
handleMessage(msg: string): {
data: object;
stringified: string;
} | {
error: Error;
} | null;
state: 'idle' | {
charsReceived: number;
chunksReceived: string[];
totalChunks: number;
};
}
// @public
export function loadSnapshotIntoStorage<R extends UnknownRecord>(txn: TLSyncStorageTransaction<R>, schema: StoreSchema<R, any>, snapshot: RoomSnapshot | TLStoreSnapshot): void;
// @internal (undocumented)
export interface MinimalDocStore<R extends UnknownRecord> {
// (undocumented)
delete(id: string): void;
// (undocumented)
get(id: string): undefined | UnknownRecord;
// (undocumented)
set(id: string, record: R): void;
}
// @internal
export interface NetworkDiff<R extends UnknownRecord> {
// (undocumented)
[id: string]: RecordOp<R>;
}
// @public
export class NodeSqliteWrapper implements TLSyncSqliteWrapper {
constructor(db: SyncSqliteDatabase, config?: TLSyncSqliteWrapperConfig | undefined);
// (undocumented)
config?: TLSyncSqliteWrapperConfig | undefined;
// (undocumented)
exec(sql: string): void;
// (undocumented)
prepare<TResult extends TLSqliteRow | void = void, TParams extends TLSqliteInputValue[] = TLSqliteInputValue[]>(sql: string): TLSyncSqliteStatement<TResult, TParams>;
// (undocumented)
transaction<T>(callback: () => T): T;
}
// @internal
export interface ObjectDiff {
// (undocumented)
[k: string]: ValueOp;
}
// @public
export type OmitVoid<T, KS extends keyof T = keyof T> = {
[K in KS extends any ? (void extends T[KS] ? never : KS) : never]: T[K];
};
// @internal
export type PatchOp = [type: typeof ValueOpType.Patch, diff: ObjectDiff];
// @internal
export interface PersistedRoomSnapshotForSupabase {
// (undocumented)
drawing: RoomSnapshot;
// (undocumented)
id: string;
// (undocumented)
slug: string;
}
// @internal (undocumented)
export class PresenceStore<R extends UnknownRecord> implements MinimalDocStore<R> {
// (undocumented)
delete(id: string): void;
// (undocumented)
get(id: string): undefined | UnknownRecord;
// (undocumented)
set(id: string, state: R): void;
// (undocumented)
values(): Generator<R, undefined, unknown>;
}
// @internal
export type PutOp = [type: typeof ValueOpType.Put, value: unknown];
// @internal
export class ReconnectManager {
constructor(socketAdapter: ClientWebSocketAdapter, getUri: () => Promise<string> | string);
close(): void;
connected(): void;
disconnected(): void;
// (undocumented)
intendedDelay: number;
maybeReconnected(): void;
}
// @internal
export type RecordOp<R extends UnknownRecord> = [typeof RecordOpType.Patch, ObjectDiff] | [typeof RecordOpType.Put, R] | [typeof RecordOpType.Remove];
// @internal
export const RecordOpType: {
readonly Patch: 'patch';
readonly Put: 'put';
readonly Remove: 'remove';
};
// @internal
export type RecordOpType = (typeof RecordOpType)[keyof typeof RecordOpType];
// @internal
export type RoomSession<R extends UnknownRecord, Meta> = (RoomSessionBase<R, Meta> & {
state: typeof RoomSessionState.AwaitingConnectMessage;
sessionStartTime: number;
}) | (RoomSessionBase<R, Meta> & {
state: typeof RoomSessionState.AwaitingRemoval;
cancellationTime: number;
}) | (RoomSessionBase<R, Meta> & {
state: typeof RoomSessionState.Connected;
outstandingDataMessages: TLSocketServerSentDataEvent<R>[];
serializedSchema: SerializedSchema;
debounceTimer: null | ReturnType<typeof setTimeout>;
lastInteractionTime: number;
requiresDownMigrations: boolean;
});
// @internal
export interface RoomSessionBase<R extends UnknownRecord, Meta> {
isReadonly: boolean;
meta: Meta;
objectAccess: TLObjectStoreAccess;
presenceId: null | string;
requiresLegacyRejection: boolean;
sessionId: string;
socket: TLRoomSocket<R>;
supportsStringAppend: boolean;
}
// @internal
export const RoomSessionState: {
readonly AwaitingRemoval: 'awaiting-removal';
readonly Connected: 'connected';
readonly AwaitingConnectMessage: 'awaiting-connect-message';
};
// @internal
export type RoomSessionState = (typeof RoomSessionState)[keyof typeof RoomSessionState];
// @public
export interface RoomSnapshot {
clock?: number;
documentClock?: number;
documents: Array<{
lastChangedClock: number;
state: UnknownRecord;
}>;
schema?: SerializedSchema;
tombstoneHistoryStartsAtClock?: number;
tombstones?: Record<string, number>;
}
// @public @deprecated
export interface RoomStoreMethods<R extends UnknownRecord = UnknownRecord> {
delete(recordOrId: R | string): void;
get(id: string): null | R;
getAll(): R[];
put(record: R): void;
}
// @public
export interface SessionStateSnapshot {
// (undocumented)
isReadonly: boolean;
objectAccess?: TLObjectStoreAccess;
// (undocumented)
presenceId: null | string;
// (undocumented)
presenceRecord: null | UnknownRecord;
// (undocumented)
requiresLegacyRejection: boolean;
// (undocumented)
serializedSchema: SerializedSchema;
// (undocumented)
supportsStringAppend: boolean;
}
// @public
export class SQLiteSyncStorage<R extends UnknownRecord> implements TLSyncStorage<R> {
constructor({ sql, snapshot, objectTypes, onChange, }: {
objectTypes?: readonly string[];
onChange?(arg: TLSyncStorageOnChangeCallbackProps): unknown;
snapshot?: RoomSnapshot | StoreSnapshot<R>;
sql: TLSyncSqliteWrapper;
});
// (undocumented)
getClock(): number;
static getDocumentClock(storage: TLSyncSqliteWrapper): null | number;
// (undocumented)
getObjectsByIds(ids: Iterable<string>): RoomSnapshot['documents'];
// (undocumented)
getObjectsSnapshot(): RoomSnapshot['documents'];
// @internal (undocumented)
_getSchema(): SerializedSchema;
// (undocumented)
getSnapshot(): RoomSnapshot;
// @internal (undocumented)
_getTombstoneHistoryStartsAtClock(): number;
static hasBeenInitialized(storage: TLSyncSqliteWrapper): boolean;
// @internal (undocumented)
readonly objectTypes: ReadonlySet<string>;
// (undocumented)
onChange(callback: (arg: TLSyncStorageOnChangeCallbackProps) => void): () => void;
// @internal (undocumented)
pruneTombstones: DebouncedFunc<() => void>;
// @internal (undocumented)
_setSchema(schema: SerializedSchema): void;
// (undocumented)
transaction<T>(callback: TLSyncStorageTransactionCallback<R, T>, opts?: TLSyncStorageTransactionOptions): TLSyncStorageTransactionResult<T, R>;
}
// @public
export type SubscribingFn<T> = (cb: (val: T) => void) => () => void;
// @public
export interface SyncSqliteDatabase {
exec(sql: string): void;
prepare(sql: string): {
all(...params: unknown[]): unknown[];
iterate(...params: unknown[]): IterableIterator<unknown>;
run(...params: unknown[]): unknown;
};
}
// @internal
export interface TLConnectRequest {
// (undocumented)
connectRequestId: string;
// (undocumented)
lastServerClock: number;
// (undocumented)
protocolVersion: number;
// (undocumented)
schema: SerializedSchema;
// (undocumented)
type: 'connect';
}
// @public
export type TLCustomMessageHandler = (this: null, data: any) => void;
// @internal @deprecated
export const TLIncompatibilityReason: {
readonly ClientTooOld: 'clientTooOld';
readonly InvalidOperation: 'invalidOperation';
readonly InvalidRecord: 'invalidRecord';
readonly ServerTooOld: 'serverTooOld';
};
// @internal @deprecated
export type TLIncompatibilityReason = (typeof TLIncompatibilityReason)[keyof typeof TLIncompatibilityReason];
// @public
export type TLObjectStoreAccess = 'read' | 'write';
// @public
export interface TLPersistentClientSocket<ClientSentMessage extends object = object, ServerSentMessage extends object = object> {
close(): void;
connectionStatus: 'error' | 'offline' | 'online';
onReceiveMessage: SubscribingFn<ServerSentMessage>;
onStatusChange: SubscribingFn<TLSocketStatusChangeEvent>;
restart(): void;
sendMessage(msg: ClientSentMessage): void;
}
// @internal
export type TLPersistentClientSocketStatus = 'error' | 'offline' | 'online';
// @internal
export interface TLPingRequest {
// (undocumented)
type: 'ping';
}
// @public
export type TLPresenceMode =
/** No presence sharing - client operates independently */
'full'
| 'solo'
/** Full presence sharing - cursors and selections visible to others */;
// @internal
export interface TLPushRequest<R extends UnknownRecord> {
// (undocumented)
clientClock: number;
// (undocumented)
diff?: NetworkDiff<R>;
// (undocumented)
presence?: [typeof RecordOpType.Patch, ObjectDiff] | [typeof RecordOpType.Put, R];
// (undocumented)
type: 'push';
}
// @public
export type TLRecordAuthorizer<Rec extends UnknownRecord, SessionMeta> = (args: {
session: {
isReadonly: boolean;
meta: SessionMeta;
sessionId: string;
};
} & ({
next: null;
prev: Rec;
type: 'delete';
} | {
next: Rec;
prev: null;
type: 'create';
} | {
next: Rec;
prev: Rec;
type: 'update';
})) => null | Rec;
// @public
export type TLRecordAuthorizers<R extends UnknownRecord, SessionMeta> = {
[K in R['typeName']]?: TLRecordAuthorizer<Extract<R, {
typeName: K;
}>, SessionMeta>;
};
// @public
export class TLRemoteSyncError extends Error {
constructor(reason: string | TLSyncErrorCloseEventReason);
// (undocumented)
name: string;
// (undocumented)
readonly reason: string | TLSyncErrorCloseEventReason;
}
// @internal
export interface TLRoomSocket<R extends UnknownRecord> {
close(code?: number, reason?: string): void;
isOpen: boolean;
sendMessage(msg: TLSocketServerSentEvent<R>): void;
}
// @internal
export type TLSocketClientSentEvent<R extends UnknownRecord> = TLConnectRequest | TLPingRequest | TLPushRequest<R>;
// @public
export class TLSocketRoom<R extends UnknownRecord = UnknownRecord, SessionMeta = void> {
constructor(opts: TLSocketRoomOptions<R, SessionMeta>);
close(): void;
closeSession(sessionId: string, fatalReason?: string | TLSyncErrorCloseEventReason): void;
getCurrentDocumentClock(): number;
getCurrentObjectsSnapshot(): RoomSnapshot['documents'];
// @deprecated
getCurrentSnapshot(): RoomSnapshot;
getNumActiveSessions(): number;
// @internal
getPresenceRecords(): Record<string, UnknownRecord>;
getRecord(id: string): R;
getSessions(): Array<{
isConnected: boolean;
isReadonly: boolean;
meta: SessionMeta;
objectAccess: TLObjectStoreAccess;
sessionId: string;
}>;
getSessionSnapshot(sessionId: string): null | SessionStateSnapshot;
handleSocketClose(sessionId: string): void;
handleSocketConnect(opts: {
isReadonly?: boolean;
objectAccess?: TLObjectStoreAccess;
sessionId: string;
socket: WebSocketMinimal;
} & (SessionMeta extends void ? object : {
meta: SessionMeta;
})): void;
handleSocketError(sessionId: string): void;
handleSocketMessage(sessionId: string, message: AllowSharedBufferSource | string): void;
handleSocketResume(opts: {
sessionId: string;
snapshot: SessionStateSnapshot;
socket: WebSocketMinimal;
} & (SessionMeta extends void ? object : {
meta: SessionMeta;
})): void;
isClosed(): boolean;
loadSnapshot(snapshot: RoomSnapshot | TLStoreSnapshot): void;
// (undocumented)
readonly log?: TLSyncLog;
// (undocumented)
readonly opts: TLSocketRoomOptions<R, SessionMeta>;
sendCustomMessage(sessionId: string, data: any): void;
// (undocumented)
storage: TLSyncStorage<R>;
// @deprecated
updateStore(updater: (store: RoomStoreMethods<R>) => Promise<void> | void): Promise<void>;
}
// @public
export interface TLSocketRoomOptions<R extends UnknownRecord, SessionMeta> {
authorizeRecord?: TLRecordAuthorizers<R, SessionMeta>;
// (undocumented)
clientTimeout?: number;
// @deprecated (undocumented)
initialSnapshot?: RoomSnapshot | TLStoreSnapshot;
// (undocumented)
log?: TLSyncLog;
objectTypes?: readonly string[];
// (undocumented)
onAfterReceiveMessage?: (args: {
message: TLSocketServerSentEvent<R>;
meta: SessionMeta;
sessionId: string;
stringified: string;
}) => void;
// (undocumented)
onBeforeSendMessage?: (args: {
message: TLSocketServerSentEvent<R>;
meta: SessionMeta;
sessionId: string;
stringified: string;
}) => void;
onCommittedChanges?: (args: {
diff: TLSyncForwardDiff<R>;
documentClock: number;
}) => void;
// @deprecated (undocumented)
onDataChange?(): void;
// @internal (undocumented)
onPresenceChange?(): void;
// (undocumented)
onSessionRemoved?: (room: TLSocketRoom<R, SessionMeta>, args: {
meta: SessionMeta;
numSessionsRemaining: number;
sessionId: string;
}) => void;
onSessionSnapshot?: (sessionId: string, snapshot: SessionStateSnapshot) => void;
// (undocumented)
schema?: StoreSchema<R, any>;
// (undocumented)
storage?: TLSyncStorage<R>;
}
// @internal
export type TLSocketServerSentDataEvent<R extends UnknownRecord> = {
action: 'commit' | 'discard' | {
rebaseWithDiff: NetworkDiff<R>;
};
clientClock: number;
serverClock: number;
type: 'push_result';
} | {
diff: NetworkDiff<R>;
serverClock: number;
type: 'patch';
};
// @internal
export type TLSocketServerSentEvent<R extends UnknownRecord> = {
objectAccess?: TLObjectStoreAccess;
connectRequestId: string;
diff: NetworkDiff<R>;
hydrationType: 'wipe_all' | 'wipe_presence';
isReadonly: boolean;
protocolVersion: number;
schema: SerializedSchema;
serverClock: number;
type: 'connect';
} | {
data: any;
type: 'custom';
} | {
data: TLSocketServerSentDataEvent<R>[];
type: 'data';
} | {
reason: TLIncompatibilityReason;
type: 'incompatibility_error';
} | {
type: 'pong';
} | TLSocketServerSentDataEvent<R>;
// @public
export type TLSocketStatusChangeEvent = {
status: 'offline' | 'online';
} | {
status: 'error';
reason: string;
};
// @internal
export type TLSocketStatusListener = (params: TLSocketStatusChangeEvent) => void;
// @public
export type TLSqliteInputValue = bigint | null | number | string | Uint8Array;
// @public
export type TLSqliteOutputValue = bigint | null | number | string | Uint8Array;
// @public
export type TLSqliteRow = Record<string, TLSqliteOutputValue>;
// @public
export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>> {
constructor(config: {
didCancel?(): boolean;
onAfterConnect?(self: TLSyncClient<R, S>, details: {
isReadonly: boolean;
objectAccess: TLObjectStoreAccess;
}): void;
onCustomMessageReceived?: TLCustomMessageHandler;
onLoad(self: TLSyncClient<R, S>): void;
onSyncError(reason: string): void;
presence: Signal<null | R>;
presenceMode?: Signal<TLPresenceMode>;
socket: TLPersistentClientSocket<any, any>;
store: S;
});
close(): void;
// @internal (undocumented)
isConnectedToRoom: boolean;
// @internal (undocumented)
latestConnectRequestId: null | string;
// @internal (undocumented)
readonly presenceMode: Signal<TLPresenceMode> | undefined;
// @internal (undocumented)
readonly presenceState: Signal<null | R> | undefined;
// @internal (undocumented)
readonly socket: TLPersistentClientSocket<TLSocketClientSentEvent<R>, TLSocketServerSentEvent<R>>;
// @internal (undocumented)
readonly store: S;
}
// @public
export const TLSyncErrorCloseEventCode: 4099;
// @public
export const TLSyncErrorCloseEventReason: {
readonly MESSAGE_TOO_LARGE: 'MESSAGE_TOO_LARGE';
readonly RATE_LIMITED: 'RATE_LIMITED';
readonly CLIENT_TOO_OLD: 'CLIENT_TOO_OLD';
readonly INVALID_RECORD: 'INVALID_RECORD';
readonly ROOM_FULL: 'ROOM_FULL';
readonly NOT_FOUND: 'NOT_FOUND';
readonly SERVER_TOO_OLD: 'SERVER_TOO_OLD';
readonly UNKNOWN_ERROR: 'UNKNOWN_ERROR';
readonly NOT_AUTHENTICATED: 'NOT_AUTHENTICATED';
readonly FORBIDDEN: 'FORBIDDEN';
};
// @public
export type TLSyncErrorCloseEventReason = (typeof TLSyncErrorCloseEventReason)[keyof typeof TLSyncErrorCloseEventReason];
// @public
export interface TLSyncForwardDiff<R extends UnknownRecord> {
// (undocumented)
deletes: string[];
// (undocumented)
puts: Record<string, [before: R, after: R] | R>;
}
// @public
export interface TLSyncLog {
error?(...args: any[]): void;
warn?(...args: any[]): void;
}
// @internal
export class TLSyncRoom<R extends UnknownRecord, SessionMeta> {
constructor(opts: {
onCommittedChanges?(args: {
diff: TLSyncForwardDiff<R>;
documentClock: number;
}): void;
authorizeRecord?: TLRecordAuthorizers<R, SessionMeta>;
objectTypes?: readonly string[];
clientTimeout?: number;
log?: TLSyncLog;
onPresenceChange?(): void;
schema: StoreSchema<R, any>;
storage: TLSyncStorage<R>;
});
close(): void;
// (undocumented)
readonly documentTypes: Set<string>;
// (undocumented)
readonly events: Emitter< {
room_became_empty(): void;
session_removed(args: {
meta: SessionMeta;
sessionId: string;
}): void;
}>;
// (undocumented)
_flushDataMessages(sessionId: string): void;
getCanEmitStringAppend(): boolean;
handleClose(sessionId: string): void;
handleMessage(sessionId: string, message: TLSocketClientSentEvent<R>): Promise<void>;
handleNewSession(opts: {
isReadonly: boolean;
meta: SessionMeta;
objectAccess?: TLObjectStoreAccess;
sessionId: string;
socket: TLRoomSocket<R>;
}): this;
handleResumedSession(opts: {
isReadonly: boolean;
meta: SessionMeta;
objectAccess?: TLObjectStoreAccess;
presenceId: null | string;
presenceRecord: null | UnknownRecord;
requiresLegacyRejection: boolean;
serializedSchema: SerializedSchema;
sessionId: string;
socket: TLRoomSocket<R>;
supportsStringAppend: boolean;
}): void;
// (undocumented)
readonly internalTxnId = "TLSyncRoom.txn";
isClosed(): boolean;
readonly objectTypes: Set<string>;
// (undocumented)
readonly presenceStore: PresenceStore<R>;
// (undocumented)
readonly presenceType: null | RecordType<R, any>;
// (undocumented)
pruneSessions: DebouncedFuncLeading<() => void>;
rejectSession(sessionId: string, fatalReason?: string | TLSyncErrorCloseEventReason): void;
// (undocumented)
readonly schema: StoreSchema<R, any>;
sendCustomMessage(sessionId: string, data: any): void;
// (undocumented)
readonly serializedSchema: SerializedSchema;
// (undocumented)
readonly sessions: Map<string, RoomSession<R, SessionMeta>>;
}
// @public
export interface TLSyncSqliteStatement<TResult extends TLSqliteRow | void, TParams extends TLSqliteInputValue[] = []> {
all(...bindings: TParams): TResult[];
iterate(...bindings: TParams): IterableIterator<TResult>;
run(...bindings: TParams): void;
}
// @public
export interface TLSyncSqliteWrapper {
readonly config?: TLSyncSqliteWrapperConfig;
exec(sql: string): void;
prepare<TResult extends TLSqliteRow | void, TParams extends TLSqliteInputValue[] = []>(sql: string): TLSyncSqliteStatement<TResult, TParams>;
transaction<T>(callback: () => T): T;
}
// @public
export interface TLSyncSqliteWrapperConfig {
tablePrefix?: string;
}
// @public
export interface TLSyncStorage<R extends UnknownRecord> {
// (undocumented)
getClock(): number;
getObjectsByIds?(ids: Iterable<string>): RoomSnapshot['documents'];
getObjectsSnapshot?(): RoomSnapshot['documents'];
getSnapshot?(): RoomSnapshot;
// (undocumented)
onChange(callback: (arg: TLSyncStorageOnChangeCallbackProps) => unknown): () => void;
// (undocumented)
transaction<T>(callback: TLSyncStorageTransactionCallback<R, T>, opts?: TLSyncStorageTransactionOptions): TLSyncStorageTransactionResult<T, R>;
}
// @public
export interface TLSyncStorageGetChangesSinceResult<R extends UnknownRecord> {
diff: TLSyncForwardDiff<R>;
wipeAll: boolean;
}
// @public
export interface TLSyncStorageOnChangeCallbackProps {
// (undocumented)
documentClock: number;
id?: string;
}
// @public
export interface TLSyncStorageTransaction<R extends UnknownRecord> extends SynchronousStorage<R> {
getChangesSince(sinceClock: number): TLSyncStorageGetChangesSinceResult<R> | undefined;
getClock(): number;
}
// @public
export type TLSyncStorageTransactionCallback<R extends UnknownRecord, T> = (txn: TLSyncStorageTransaction<R>) => T extends Promise<any> ? {
__error: 'Transaction callbacks cannot be async. Use synchronous operations only.';
} : T;
// @public
export interface TLSyncStorageTransactionOptions {
emitChanges?: 'always' | 'when-different';
id?: string;
}
// @public
export interface TLSyncStorageTransactionResult<T, R extends UnknownRecord = UnknownRecord> {
changes?: TLSyncForwardDiff<R>;
// (undocumented)
didChange: boolean;
// (undocumented)
documentClock: number;
// (undocumented)
result: T;
}
// @internal
export type ValueOp = AppendOp | DeleteOp | PatchOp | PutOp;
// @internal
export const ValueOpType: {
readonly Append: 'append';
readonly Delete: 'delete';
readonly Patch: 'patch';
readonly Put: 'put';
};
// @internal
export type ValueOpType = (typeof ValueOpType)[keyof typeof ValueOpType];
// @public
export interface WebSocketMinimal {
addEventListener?: (type: 'close' | 'error' | 'message', listener: (event: any) => void) => void;
close: (code?: number, reason?: string) => void;
readyState: number;
removeEventListener?: (type: 'close' | 'error' | 'message', listener: (event: any) => void) => void;
send: (data: string) => void;
}
// (No @packageDocumentation comment for this package)
```

View File

@@ -0,0 +1,83 @@
{
"name": "@tldraw/sync-core",
"description": "tldraw infinite canvas SDK (multiplayer sync).",
"version": "5.4.2",
"author": {
"name": "tldraw GB Ltd.",
"email": "hello@tldraw.com"
},
"homepage": "https://tldraw.dev",
"license": "SEE LICENSE IN LICENSE.md",
"tldraw_product": {
"stableId": "tldraw:sync",
"name": "Sync",
"type": "feature",
"parent": "tldraw:sdk-core",
"premium": false
},
"repository": {
"type": "git",
"url": "https://github.com/tldraw/tldraw"
},
"bugs": {
"url": "https://github.com/tldraw/tldraw/issues"
},
"keywords": [
"tldraw",
"sdk",
"drawing",
"app",
"development",
"whiteboard",
"canvas",
"infinite"
],
"/* NOTE */": "These `main` and `types` fields are rewritten by the build script. They are not the actual values we publish",
"main": "./src/index.ts",
"types": "./.tsbuild/index.d.ts",
"type": "module",
"/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here",
"files": [],
"scripts": {
"test-ci": "yarn run -T vitest run --passWithNoTests",
"test": "yarn run -T vitest --passWithNoTests",
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts",
"build": "yarn run -T tsx ../../internal/scripts/build-package.ts",
"build-api": "yarn run -T tsx ../../internal/scripts/build-api.ts",
"prepack": "yarn run -T tsx ../../internal/scripts/prepack.ts",
"postpack": "../../internal/scripts/postpack.sh",
"pack-tarball": "yarn pack"
},
"devDependencies": {
"@peculiar/webcrypto": "^1.5.0",
"@tldraw/editor": "workspace:*",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/uuid-readable": "^0.0.3",
"fake-indexeddb": "^4.0.2",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"resize-observer-polyfill": "^1.5.1",
"tldraw": "workspace:*",
"typescript": "^7.0.2",
"uuid-by-string": "^4.0.0",
"uuid-readable": "^0.0.2",
"vitest": "^5.0.0"
},
"dependencies": {
"@tldraw/state": "workspace:*",
"@tldraw/store": "workspace:*",
"@tldraw/tlschema": "workspace:*",
"@tldraw/utils": "workspace:*",
"nanoevents": "^7.0.1",
"ws": "^8.18.0"
},
"peerDependencies": {
"react": "^18.2.0 || ^19.2.1",
"react-dom": "^18.2.0 || ^19.2.1"
},
"engines": {
"node": ">=22.12.0"
}
}

View File

@@ -0,0 +1,44 @@
// IndexedDB polyfill for browser storage testing
require('fake-indexeddb/auto')
// ResizeObserver polyfill for DOM observation testing
global.ResizeObserver = require('resize-observer-polyfill')
// Crypto polyfill for cryptographic operations
global.crypto ??= new (require('@peculiar/webcrypto').Crypto)()
// FontFace API polyfill for font testing
global.FontFace = class FontFace {
load() {
return Promise.resolve()
}
}
// Document fonts API mock
document.fonts = {
add: () => {},
delete: () => {},
forEach: () => {},
}
// jsdom ships a global WebSocket built on its bundled copy of undici. In this jsdom test
// environment its events are created in a different realm than the Node EventTarget they're
// dispatched on, so connecting throws "The 'event' argument must be an instance of Event". Use
// the `ws` package's WebSocket for client connections instead, matching the WebSocketServer the
// tests connect to.
global.WebSocket = require('ws').WebSocket
// Window.matchMedia polyfill for media queries
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => {},
}),
})

View File

@@ -0,0 +1,93 @@
import { registerTldrawLibraryVersion } from '@tldraw/utils'
export { chunk, JsonChunkAssembler } from './lib/chunk'
export { ClientWebSocketAdapter, ReconnectManager } from './lib/ClientWebSocketAdapter'
export {
applyObjectDiff,
diffRecord,
getNetworkDiff,
RecordOpType,
ValueOpType,
type AppendOp,
type DeleteOp,
type NetworkDiff,
type ObjectDiff,
type PatchOp,
type PutOp,
type RecordOp,
type ValueOp,
} from './lib/diff'
export { DurableObjectSqliteSyncWrapper } from './lib/DurableObjectSqliteSyncWrapper'
export { DEFAULT_INITIAL_SNAPSHOT, InMemorySyncStorage } from './lib/InMemorySyncStorage'
export { NodeSqliteWrapper, type SyncSqliteDatabase } from './lib/NodeSqliteWrapper'
export {
getTlsyncProtocolVersion,
// eslint-disable-next-line @typescript-eslint/no-deprecated
TLIncompatibilityReason,
type TLConnectRequest,
type TLObjectStoreAccess,
type TLPingRequest,
type TLPushRequest,
type TLSocketClientSentEvent,
type TLSocketServerSentDataEvent,
type TLSocketServerSentEvent,
} from './lib/protocol'
export { RoomSessionState, type RoomSession, type RoomSessionBase } from './lib/RoomSession'
export type { PersistedRoomSnapshotForSupabase } from './lib/server-types'
export type { WebSocketMinimal } from './lib/ServerSocketAdapter'
export {
SQLiteSyncStorage,
type TLSqliteInputValue,
type TLSqliteOutputValue,
type TLSqliteRow,
type TLSyncSqliteStatement,
type TLSyncSqliteWrapper,
type TLSyncSqliteWrapperConfig,
} from './lib/SQLiteSyncStorage'
export { TLRemoteSyncError } from './lib/TLRemoteSyncError'
export {
TLSocketRoom,
type OmitVoid,
// eslint-disable-next-line @typescript-eslint/no-deprecated
type RoomStoreMethods,
type SessionStateSnapshot,
type TLSocketRoomOptions,
type TLSyncLog,
} from './lib/TLSocketRoom'
export {
TLSyncClient,
TLSyncErrorCloseEventCode,
TLSyncErrorCloseEventReason,
type SubscribingFn,
type TLCustomMessageHandler,
type TLPersistentClientSocket,
type TLPersistentClientSocketStatus,
type TLPresenceMode,
type TLSocketStatusChangeEvent,
type TLSocketStatusListener,
} from './lib/TLSyncClient'
export {
TLSyncRoom,
type MinimalDocStore,
type PresenceStore,
type RoomSnapshot,
type TLRecordAuthorizer,
type TLRecordAuthorizers,
type TLRoomSocket,
} from './lib/TLSyncRoom'
export {
loadSnapshotIntoStorage,
type TLSyncForwardDiff,
type TLSyncStorage,
type TLSyncStorageGetChangesSinceResult,
type TLSyncStorageOnChangeCallbackProps,
type TLSyncStorageTransaction,
type TLSyncStorageTransactionCallback,
type TLSyncStorageTransactionOptions,
type TLSyncStorageTransactionResult,
} from './lib/TLSyncStorage'
registerTldrawLibraryVersion(
(globalThis as any).TLDRAW_LIBRARY_NAME,
(globalThis as any).TLDRAW_LIBRARY_VERSION,
(globalThis as any).TLDRAW_LIBRARY_MODULES
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,765 @@
import { atom, Atom } from '@tldraw/state'
import { TLRecord } from '@tldraw/tlschema'
import { assert, warnOnce } from '@tldraw/utils'
import { chunk, MAX_ASSEMBLED_MESSAGE_CHARS } from './chunk'
import { TLSocketClientSentEvent, TLSocketServerSentEvent } from './protocol'
import {
TLPersistentClientSocket,
TLPersistentClientSocketStatus,
TLSocketStatusListener,
TLSyncErrorCloseEventCode,
TLSyncErrorCloseEventReason,
} from './TLSyncClient'
// The ready states from the WebSocket spec, kept local instead of read off the global
// constructor: an extension or page teardown can leave `WebSocket` undefined by the time a
// window `online` or close handler fires, and `WebSocket.OPEN` then throws (#10106).
const ReadyState = {
CONNECTING: 0,
OPEN: 1,
CLOSING: 2,
CLOSED: 3,
} as const
function listenTo<T extends EventTarget>(target: T, event: string, handler: () => void) {
target.addEventListener(event, handler)
return () => {
target.removeEventListener(event, handler)
}
}
function debug(...args: any[]) {
// @ts-ignore
if (typeof window !== 'undefined' && window.__tldraw_socket_debug) {
const now = new Date()
// eslint-disable-next-line no-console
console.log(
`${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}.${now.getMilliseconds()}`,
...args
//, new Error().stack
)
}
}
// NOTE: ClientWebSocketAdapter requires its users to implement their own connection loss
// detection, for example by regularly pinging the server and .restart()ing
// the connection when a number of pings goes unanswered. Without this mechanism,
// we might not be able to detect the websocket connection going down in a timely manner
// (it will probably time out on outgoing data packets at some point).
//
// This is by design. While the Websocket protocol specifies protocol-level pings,
// they don't seem to be surfaced in browser APIs and can't be relied on. Therefore,
// pings need to be implemented one level up, on the application API side, which for our
// codebase means whatever code that uses ClientWebSocketAdapter.
/**
* A WebSocket adapter that provides persistent connection management for tldraw synchronization.
* This adapter handles connection establishment, reconnection logic, and message routing between
* the sync client and server. It implements automatic reconnection with exponential backoff
* and supports connection loss detection.
*
* Note: This adapter requires users to implement their own connection loss detection (e.g., pings)
* as browser WebSocket APIs don't reliably surface protocol-level ping/pong frames.
*
* @internal
* @example
* ```ts
* // Create a WebSocket adapter with connection URI
* const adapter = new ClientWebSocketAdapter(() => 'ws://localhost:3000/sync')
*
* // Listen for connection status changes
* adapter.onStatusChange((status) => {
* console.log('Connection status:', status)
* })
*
* // Listen for incoming messages
* adapter.onReceiveMessage((message) => {
* console.log('Received:', message)
* })
*
* // Send a message when connected
* if (adapter.connectionStatus === 'online') {
* adapter.sendMessage({ type: 'ping' })
* }
* ```
*/
export class ClientWebSocketAdapter implements TLPersistentClientSocket<
TLSocketClientSentEvent<TLRecord>,
TLSocketServerSentEvent<TLRecord>
> {
_ws: WebSocket | null = null
isDisposed = false
/** @internal */
readonly _reconnectManager: ReconnectManager
/**
* Permanently closes the WebSocket adapter and disposes of all resources.
* Once closed, the adapter cannot be reused and should be discarded.
* This method is idempotent - calling it multiple times has no additional effect.
*/
// TODO: .close should be a project-wide interface with a common contract (.close()d thing
// can only be garbage collected, and can't be used anymore)
close() {
this.isDisposed = true
this._reconnectManager.close()
// orphan the socket before closing it (as _closeSocket does) so its onclose can't run
// _handleDisconnect after disposal — that would notify listeners and schedule a reconnect
const ws = this._ws
this._ws = null
// WebSocket.close() is idempotent
ws?.close()
this._connectionStatus.set('offline')
}
/**
* Creates a new ClientWebSocketAdapter instance.
*
* @param getUri - Function that returns the WebSocket URI to connect to.
* Can return a string directly or a Promise that resolves to a string.
* This function is called each time a connection attempt is made,
* allowing for dynamic URI generation (e.g., for authentication tokens).
*/
constructor(getUri: () => Promise<string> | string) {
this._reconnectManager = new ReconnectManager(this, getUri)
}
private _handleConnect() {
debug('handleConnect')
this._connectionStatus.set('online')
this.statusListeners.forEach((cb) => cb({ status: 'online' }))
this._reconnectManager.connected()
}
private _handleDisconnect(
reason: 'closed' | 'manual',
closeCode?: number,
didOpen?: boolean,
closeReason?: string
) {
closeReason = closeReason || TLSyncErrorCloseEventReason.UNKNOWN_ERROR
debug('handleDisconnect', {
currentStatus: this.connectionStatus,
closeCode,
reason,
})
let newStatus: 'offline' | 'error'
switch (reason) {
case 'closed':
if (closeCode === TLSyncErrorCloseEventCode) {
newStatus = 'error'
} else {
newStatus = 'offline'
}
break
case 'manual':
newStatus = 'offline'
break
}
if (closeCode === 1006 && !didOpen) {
warnOnce(
"Could not open WebSocket connection. This might be because you're trying to load a URL that doesn't support websockets. Check the URL you're trying to connect to."
)
}
if (
// it the status changed
this.connectionStatus !== newStatus &&
// ignore errors if we're already in the offline state
!(newStatus === 'error' && this.connectionStatus === 'offline')
) {
this._connectionStatus.set(newStatus)
this.statusListeners.forEach((cb) =>
cb(newStatus === 'error' ? { status: 'error', reason: closeReason } : { status: newStatus })
)
}
this._reconnectManager.disconnected()
}
_setNewSocket(ws: WebSocket) {
assert(!this.isDisposed, 'Tried to set a new websocket on a disposed socket')
assert(
this._ws === null ||
this._ws.readyState === ReadyState.CLOSED ||
this._ws.readyState === ReadyState.CLOSING,
`Tried to set a new websocket in when the existing one was ${this._ws?.readyState}`
)
let didOpen = false
// NOTE: Sockets can stay for quite a while in the CLOSING state. This is because the transition
// between CLOSING and CLOSED happens either after the closing handshake, or after a
// timeout, but in either case those sockets don't need any special handling, the browser
// will close them eventually. We just "orphan" such sockets and ignore their onclose/onerror.
ws.onopen = () => {
debug('ws.onopen')
assert(
this._ws === ws,
"sockets must only be orphaned when they are CLOSING or CLOSED, so they can't open"
)
didOpen = true
this._handleConnect()
}
ws.onclose = (event: CloseEvent) => {
debug('ws.onclose', event)
if (this._ws === ws) {
this._handleDisconnect('closed', event.code, didOpen, event.reason)
} else {
debug('ignoring onclose for an orphaned socket')
}
}
ws.onerror = (event) => {
debug('ws.onerror', event)
if (this._ws === ws) {
this._handleDisconnect('closed')
} else {
debug('ignoring onerror for an orphaned socket')
}
}
ws.onmessage = (ev) => {
assert(
this._ws === ws,
"sockets must only be orphaned when they are CLOSING or CLOSED, so they can't receive messages"
)
let parsed: TLSocketServerSentEvent<TLRecord>
try {
parsed = JSON.parse(ev.data.toString())
} catch {
// A malformed message means the connection delivered corrupt data, so we can't trust
// that we're still in sync. Dropping the message can silently desync the client (e.g. a
// missed 'patch' is applied as if it never happened), so instead we restart the
// connection. Reconnecting re-hydrates the store from the last known server clock and
// brings us fully back in sync.
warnOnce('Received malformed WebSocket message. Restarting the connection.')
this.restart()
return
}
this.messageListeners.forEach((cb) => cb(parsed))
}
this._ws = ws
}
_closeSocket() {
if (this._ws === null) return
this._ws.close()
// explicitly orphan the socket to ignore its onclose/onerror, because onclose can be delayed
this._ws = null
this._handleDisconnect('manual')
}
_connectionStatus: Atom<TLPersistentClientSocketStatus | 'initial'> = atom(
'websocket connection status',
'initial'
)
/**
* Gets the current connection status of the WebSocket.
*
* @returns The current connection status: 'online', 'offline', or 'error'
*/
// eslint-disable-next-line tldraw/no-setter-getter
get connectionStatus(): TLPersistentClientSocketStatus {
const status = this._connectionStatus.get()
return status === 'initial' ? 'offline' : status
}
/**
* Sends a message to the server through the WebSocket connection.
* Messages are automatically chunked if they exceed size limits.
*
* @param msg - The message to send to the server
*
* @example
* ```ts
* adapter.sendMessage({
* type: 'push',
* diff: { 'shape:abc123': [2, { x: [1, 150] }] }
* })
* ```
*/
sendMessage(msg: TLSocketClientSentEvent<TLRecord>) {
assert(!this.isDisposed, 'Tried to send message on a disposed socket')
if (!this._ws) return
if (this.connectionStatus === 'online') {
const stringified = JSON.stringify(msg)
// The server rejects a session whose assembled message exceeds this, so sending would
// only earn a close and a reconnect that re-sends the same message. Fail here the way
// the server would, so onSyncError runs once instead of the client looping forever.
if (stringified.length > MAX_ASSEMBLED_MESSAGE_CHARS) {
this._handleDisconnect(
'closed',
TLSyncErrorCloseEventCode,
true,
TLSyncErrorCloseEventReason.MESSAGE_TOO_LARGE
)
return
}
const chunks = chunk(stringified)
for (const part of chunks) {
this._ws.send(part)
}
} else {
console.warn('Tried to send message while ' + this.connectionStatus)
}
}
private messageListeners = new Set<(msg: TLSocketServerSentEvent<TLRecord>) => void>()
/**
* Registers a callback to handle incoming messages from the server.
*
* @param cb - Callback function that will be called with each received message
* @returns A cleanup function to remove the message listener
*
* @example
* ```ts
* const unsubscribe = adapter.onReceiveMessage((message) => {
* switch (message.type) {
* case 'connect':
* console.log('Connected to room')
* break
* case 'data':
* console.log('Received data:', message.diff)
* break
* }
* })
*
* // Later, remove the listener
* unsubscribe()
* ```
*/
onReceiveMessage(cb: (val: TLSocketServerSentEvent<TLRecord>) => void) {
assert(!this.isDisposed, 'Tried to add message listener on a disposed socket')
this.messageListeners.add(cb)
return () => {
this.messageListeners.delete(cb)
}
}
private statusListeners = new Set<TLSocketStatusListener>()
/**
* Registers a callback to handle connection status changes.
*
* @param cb - Callback function that will be called when the connection status changes
* @returns A cleanup function to remove the status listener
*
* @example
* ```ts
* const unsubscribe = adapter.onStatusChange((status) => {
* if (status.status === 'error') {
* console.error('Connection error:', status.reason)
* } else {
* console.log('Status changed to:', status.status)
* }
* })
*
* // Later, remove the listener
* unsubscribe()
* ```
*/
onStatusChange(cb: TLSocketStatusListener) {
assert(!this.isDisposed, 'Tried to add status listener on a disposed socket')
this.statusListeners.add(cb)
return () => {
this.statusListeners.delete(cb)
}
}
/**
* Manually restarts the WebSocket connection.
* This closes the current connection (if any) and attempts to establish a new one.
* Useful for implementing connection loss detection and recovery.
*
* @example
* ```ts
* // Restart connection after detecting it's stale
* if (lastPongTime < Date.now() - 30000) {
* adapter.restart()
* }
* ```
*/
restart() {
assert(!this.isDisposed, 'Tried to restart a disposed socket')
debug('restarting')
this._closeSocket()
this._reconnectManager.maybeReconnected()
}
}
/**
* Minimum reconnection delay in milliseconds when the browser tab is active and focused.
*
* @internal
*/
export const ACTIVE_MIN_DELAY = 500
/**
* Maximum reconnection delay in milliseconds when the browser tab is active and focused.
*
* @internal
*/
export const ACTIVE_MAX_DELAY = 2000
/**
* Minimum reconnection delay in milliseconds when the browser tab is inactive or hidden.
* This longer delay helps reduce battery drain and server load when users aren't actively viewing the tab.
*
* @internal
*/
export const INACTIVE_MIN_DELAY = 1000
/**
* Maximum reconnection delay in milliseconds when the browser tab is inactive or hidden.
* Set to 5 minutes to balance between maintaining sync and conserving resources.
*
* @internal
*/
export const INACTIVE_MAX_DELAY = 1000 * 60 * 5
/**
* Exponential backoff multiplier for calculating reconnection delays.
* Each failed connection attempt increases the delay by this factor until max delay is reached.
*
* @internal
*/
export const DELAY_EXPONENT = 1.5
/**
* Maximum time in milliseconds to wait for a connection attempt before considering it failed.
* This helps detect connections stuck in the CONNECTING state and retry with fresh attempts.
*
* @internal
*/
export const ATTEMPT_TIMEOUT = 1000
/**
* Manages automatic reconnection logic for WebSocket connections with intelligent backoff strategies.
* This class handles connection attempts, tracks connection state, and implements exponential backoff
* with different delays based on whether the browser tab is active or inactive.
*
* The ReconnectManager responds to various browser events like network status changes,
* tab visibility changes, and connection events to optimize reconnection timing and
* minimize unnecessary connection attempts.
*
* @internal
*
* @example
* ```ts
* const manager = new ReconnectManager(
* socketAdapter,
* () => 'ws://localhost:3000/sync'
* )
*
* // Manager automatically handles:
* // - Initial connection
* // - Reconnection on disconnect
* // - Exponential backoff on failures
* // - Tab visibility-aware delays
* // - Network status change responses
* ```
*/
export class ReconnectManager {
private isDisposed = false
private disposables: (() => void)[] = [
() => {
if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout)
if (this.recheckConnectingTimeout) clearTimeout(this.recheckConnectingTimeout)
},
]
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null
private recheckConnectingTimeout: ReturnType<typeof setTimeout> | null = null
private lastAttemptStart: number | null = null
intendedDelay: number = ACTIVE_MIN_DELAY
private state: 'pendingAttempt' | 'pendingAttemptResult' | 'delay' | 'connected'
/**
* Creates a new ReconnectManager instance.
*
* socketAdapter - The ClientWebSocketAdapter instance to manage
* getUri - Function that returns the WebSocket URI for connection attempts
*/
constructor(
private socketAdapter: ClientWebSocketAdapter,
private getUri: () => Promise<string> | string
) {
this.subscribeToReconnectHints()
this.disposables.push(
listenTo(window, 'offline', () => {
debug('window went offline')
// On the one hand, 'offline' event is not really reliable; on the other, the only
// alternative is to wait for pings not being delivered, which takes more than 20 seconds,
// which means we won't see the ClientWebSocketAdapter status change for more than
// 20 seconds after the tab goes offline. Our application layer must be resistent to
// connection restart anyway, so we can just try to reconnect and see if
// we're truly offline.
this.socketAdapter._closeSocket()
})
)
this.state = 'pendingAttempt'
this.intendedDelay = ACTIVE_MIN_DELAY
this.scheduleAttempt()
}
private subscribeToReconnectHints() {
this.disposables.push(
listenTo(window, 'online', () => {
debug('window went online')
this.maybeReconnected()
}),
listenTo(document, 'visibilitychange', () => {
if (!document.hidden) {
debug('document became visible')
this.maybeReconnected()
}
})
)
if (Object.prototype.hasOwnProperty.call(navigator, 'connection')) {
const connection = (navigator as any)['connection'] as EventTarget
this.disposables.push(
listenTo(connection, 'change', () => {
debug('navigator.connection change')
this.maybeReconnected()
})
)
}
}
private async scheduleAttempt() {
assert(this.state === 'pendingAttempt')
if (this.isDisposed) return
debug('scheduling a connection attempt')
let uri: string
try {
uri = await this.getUri()
} catch (error) {
// A failed auth-token fetch must retry instead of leaving us with no socket or timer.
if (this.state !== 'pendingAttempt' || this.isDisposed) return
console.error('Failed to get the websocket URI, retrying', error)
this.state = 'delay'
this.reconnectTimeout = setTimeout(() => this.disconnected(), this.intendedDelay)
return
}
// this can happen if the promise gets resolved too late
if (this.state !== 'pendingAttempt' || this.isDisposed) return
assert(
this.socketAdapter._ws?.readyState !== ReadyState.OPEN,
'There should be no connection attempts while already connected'
)
this.lastAttemptStart = Date.now()
this.socketAdapter._setNewSocket(new WebSocket(httpToWs(uri)))
this.state = 'pendingAttemptResult'
}
private getMaxDelay() {
return document.hidden ? INACTIVE_MAX_DELAY : ACTIVE_MAX_DELAY
}
private getMinDelay() {
return document.hidden ? INACTIVE_MIN_DELAY : ACTIVE_MIN_DELAY
}
private clearReconnectTimeout() {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
}
private clearRecheckConnectingTimeout() {
if (this.recheckConnectingTimeout) {
clearTimeout(this.recheckConnectingTimeout)
this.recheckConnectingTimeout = null
}
}
/**
* Checks if reconnection should be attempted and initiates it if appropriate.
* This method is called in response to network events, tab visibility changes,
* and other hints that connectivity may have been restored.
*
* The method intelligently handles various connection states:
* - Already connected: no action needed
* - Currently connecting: waits or retries based on attempt age
* - Disconnected: initiates immediate reconnection attempt
*
* @example
* ```ts
* // Called automatically on network/visibility events, but can be called manually
* manager.maybeReconnected()
* ```
*/
maybeReconnected() {
debug('ReconnectManager.maybeReconnected')
// It doesn't make sense to have another check scheduled if we're already checking it now.
// If we have a CONNECTING check scheduled and relevant, it'll be recreated below anyway
this.clearRecheckConnectingTimeout()
// readyState can be CONNECTING, OPEN, CLOSING, CLOSED, or null (if getUri() is still pending)
if (this.socketAdapter._ws?.readyState === ReadyState.OPEN) {
debug('ReconnectManager.maybeReconnected: already connected')
// nothing to do, we're already OK
return
}
if (this.socketAdapter._ws?.readyState === ReadyState.CONNECTING) {
debug('ReconnectManager.maybeReconnected: connecting')
// We might be waiting for a TCP connection that sent SYN out and will never get it back,
// while a new connection appeared. On the other hand, we might have just started connecting
// and will succeed in a bit. Thus, we're checking how old the attempt is and retry anew
// if it's old enough. This by itself can delay the connection a bit, but shouldn't prevent
// new connections as long as `maybeReconnected` is not looped itself
assert(
this.lastAttemptStart,
'ReadyState=CONNECTING without lastAttemptStart should be impossible'
)
const sinceLastStart = Date.now() - this.lastAttemptStart
if (sinceLastStart < ATTEMPT_TIMEOUT) {
debug('ReconnectManager.maybeReconnected: connecting, rechecking later')
this.recheckConnectingTimeout = setTimeout(
() => this.maybeReconnected(),
ATTEMPT_TIMEOUT - sinceLastStart
)
} else {
debug('ReconnectManager.maybeReconnected: connecting, but for too long, retry now')
// Last connection attempt was started a while ago, it's possible that network conditions
// changed, and it's worth retrying to connect. `disconnected` will handle reconnection
//
// NOTE: The danger here is looping in connection attemps if connections are slow.
// Make sure that `maybeReconnected` is not called in the `disconnected` codepath!
this.clearRecheckConnectingTimeout()
this.socketAdapter._closeSocket()
}
return
}
debug('ReconnectManager.maybeReconnected: closing/closed/null, retry now')
// readyState is CLOSING or CLOSED, or the websocket is null
// Restart the backoff and retry ASAP (honouring the min delay)
// this.state doesn't really matter, because disconnected() will handle any state correctly
this.intendedDelay = ACTIVE_MIN_DELAY
this.disconnected()
}
/**
* Handles disconnection events and schedules reconnection attempts with exponential backoff.
* This method is called when the WebSocket connection is lost or fails to establish.
*
* It implements intelligent delay calculation based on:
* - Previous attempt timing
* - Current tab visibility (active vs inactive delays)
* - Exponential backoff for repeated failures
*
* @example
* ```ts
* // Called automatically when connection is lost
* // Schedules reconnection with appropriate delay
* manager.disconnected()
* ```
*/
disconnected() {
debug('ReconnectManager.disconnected')
// This either means we're freshly disconnected, or the last connection attempt failed;
// either way, time to try again.
if (this.isDisposed) return
// Guard against delayed notifications and recheck synchronously
if (
this.socketAdapter._ws?.readyState !== ReadyState.OPEN &&
this.socketAdapter._ws?.readyState !== ReadyState.CONNECTING
) {
debug('ReconnectManager.disconnected: websocket is not OPEN or CONNECTING')
this.clearReconnectTimeout()
let delayLeft
if (this.state === 'connected') {
// it's the first sign that we got disconnected; the state will be updated below,
// just set the appropriate delay for now
this.intendedDelay = this.getMinDelay()
delayLeft = this.intendedDelay
} else {
delayLeft =
this.lastAttemptStart !== null
? this.lastAttemptStart + this.intendedDelay - Date.now()
: 0
}
if (delayLeft > 0) {
debug('ReconnectManager.disconnected: delaying, delayLeft', delayLeft)
// try again later
this.state = 'delay'
this.reconnectTimeout = setTimeout(() => this.disconnected(), delayLeft)
} else {
// not connected and not delayed, time to retry
this.state = 'pendingAttempt'
this.intendedDelay = Math.min(
this.getMaxDelay(),
Math.max(this.getMinDelay(), this.intendedDelay) * DELAY_EXPONENT
)
debug(
'ReconnectManager.disconnected: attempting a connection, next delay',
this.intendedDelay
)
this.scheduleAttempt()
}
}
}
/**
* Handles successful connection events and resets reconnection state.
* This method is called when the WebSocket successfully connects to the server.
*
* It clears any pending reconnection attempts and resets the delay back to minimum
* for future connection attempts.
*
* @example
* ```ts
* // Called automatically when WebSocket opens successfully
* manager.connected()
* ```
*/
connected() {
debug('ReconnectManager.connected')
// this notification could've been delayed, recheck synchronously
if (this.socketAdapter._ws?.readyState === ReadyState.OPEN) {
debug('ReconnectManager.connected: websocket is OPEN')
this.state = 'connected'
this.clearReconnectTimeout()
this.intendedDelay = ACTIVE_MIN_DELAY
}
}
/**
* Permanently closes the reconnection manager and cleans up all resources.
* This stops all pending reconnection attempts and removes event listeners.
* Once closed, the manager cannot be reused.
*/
close() {
this.disposables.forEach((d) => d())
this.isDisposed = true
}
}
function httpToWs(url: string) {
return url.replace(/^http(s)?:/, 'ws$1:')
}

View File

@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest'
import { DurableObjectSqliteSyncWrapper } from './DurableObjectSqliteSyncWrapper'
// A minimal fake of the Durable Object storage API: `sql.exec` records each
// call and returns an iterable cursor with `toArray()`, and `transactionSync`
// just invokes the callback.
function makeFakeStorage(rows: unknown[] = []) {
const calls: { sql: string; bindings: unknown[] }[] = []
const storage = {
sql: {
exec(sql: string, ...bindings: unknown[]) {
calls.push({ sql, bindings })
return {
[Symbol.iterator]() {
return rows[Symbol.iterator]()
},
toArray() {
return [...rows]
},
}
},
},
transactionSync: vi.fn(<T>(callback: () => T): T => callback()),
}
return { storage, calls }
}
describe('DurableObjectSqliteSyncWrapper', () => {
describe('prepare', () => {
it('[DO1] iterate re-executes the stored SQL with the given bindings on every call', () => {
const { storage, calls } = makeFakeStorage([{ id: 1 }, { id: 2 }])
const wrapper = new DurableObjectSqliteSyncWrapper(storage)
const stmt = wrapper.prepare<{ id: number }, [number]>('SELECT id FROM test WHERE value > ?')
expect(calls).toHaveLength(0) // nothing executed at prepare time
expect([...stmt.iterate(100)]).toEqual([{ id: 1 }, { id: 2 }])
expect([...stmt.iterate(200)]).toEqual([{ id: 1 }, { id: 2 }])
expect(calls).toEqual([
{ sql: 'SELECT id FROM test WHERE value > ?', bindings: [100] },
{ sql: 'SELECT id FROM test WHERE value > ?', bindings: [200] },
])
})
it('[DO1] all re-executes the stored SQL with the given bindings and returns toArray()', () => {
const { storage, calls } = makeFakeStorage([{ name: 'alice' }])
const wrapper = new DurableObjectSqliteSyncWrapper(storage)
const stmt = wrapper.prepare<{ name: string }, [number]>('SELECT name FROM test WHERE id = ?')
expect(stmt.all(1)).toEqual([{ name: 'alice' }])
expect(stmt.all(2)).toEqual([{ name: 'alice' }])
expect(calls).toEqual([
{ sql: 'SELECT name FROM test WHERE id = ?', bindings: [1] },
{ sql: 'SELECT name FROM test WHERE id = ?', bindings: [2] },
])
})
it('[DO1] run re-executes the stored SQL with the given bindings', () => {
const { storage, calls } = makeFakeStorage()
const wrapper = new DurableObjectSqliteSyncWrapper(storage)
const stmt = wrapper.prepare<void, [number, string]>(
'INSERT INTO test (id, name) VALUES (?, ?)'
)
stmt.run(1, 'alice')
stmt.run(2, 'bob')
expect(calls).toEqual([
{ sql: 'INSERT INTO test (id, name) VALUES (?, ?)', bindings: [1, 'alice'] },
{ sql: 'INSERT INTO test (id, name) VALUES (?, ?)', bindings: [2, 'bob'] },
])
})
})
describe('exec', () => {
it('[DO1] forwards to sql.exec', () => {
const { storage, calls } = makeFakeStorage()
const wrapper = new DurableObjectSqliteSyncWrapper(storage)
wrapper.exec('CREATE TABLE test (id INTEGER PRIMARY KEY)')
expect(calls).toEqual([{ sql: 'CREATE TABLE test (id INTEGER PRIMARY KEY)', bindings: [] }])
})
})
describe('transaction', () => {
it('[DO1] delegates to transactionSync and returns its result', () => {
const { storage } = makeFakeStorage()
const wrapper = new DurableObjectSqliteSyncWrapper(storage)
const callback = vi.fn(() => 'done')
const result = wrapper.transaction(callback)
expect(result).toBe('done')
expect(callback).toHaveBeenCalledTimes(1)
expect(storage.transactionSync).toHaveBeenCalledTimes(1)
expect(storage.transactionSync).toHaveBeenCalledWith(callback)
})
})
})

View File

@@ -0,0 +1,94 @@
import {
type TLSqliteInputValue,
type TLSqliteRow,
type TLSyncSqliteStatement,
type TLSyncSqliteWrapper,
type TLSyncSqliteWrapperConfig,
} from './SQLiteSyncStorage'
/**
* Mimics a prepared statement interface for Durable Objects SQLite.
* Rather than actually preparing the statement, it just stores the SQL and
* executes it fresh each time. This is still fast because DO SQLite maintains
* an internal LRU cache of prepared statements.
*/
class DurableObjectStatement<
TResult extends TLSqliteRow | void,
TParams extends TLSqliteInputValue[],
> implements TLSyncSqliteStatement<TResult, TParams> {
constructor(
private sql: {
exec(sql: string, ...bindings: unknown[]): Iterable<any> & { toArray(): any[] }
},
private query: string
) {}
iterate(...bindings: TParams): IterableIterator<TResult> {
const result = this.sql.exec(this.query, ...bindings)
return result[Symbol.iterator]() as IterableIterator<TResult>
}
all(...bindings: TParams): TResult[] {
return this.sql.exec(this.query, ...bindings).toArray()
}
run(...bindings: TParams): void {
this.sql.exec(this.query, ...bindings)
}
}
/**
* A wrapper around Cloudflare Durable Object's SqlStorage that implements TLSyncSqliteWrapper.
*
* Use this wrapper with SQLiteSyncStorage to persist tldraw sync state using
* Cloudflare Durable Object's built-in SQLite storage. This provides automatic
* persistence that survives Durable Object hibernation and restarts.
*
* @example
* ```ts
* import { SQLiteSyncStorage, DurableObjectSqliteSyncWrapper } from '@tldraw/sync-core'
*
* // In your Durable Object class:
* class MyDurableObject extends DurableObject {
* private storage: SQLiteSyncStorage
*
* constructor(ctx: DurableObjectState, env: Env) {
* super(ctx, env)
* const sql = new DurableObjectSqliteSyncWrapper(ctx.storage)
* this.storage = new SQLiteSyncStorage({ sql })
* }
* }
* ```
*
* @example
* ```ts
* // With table prefix to avoid conflicts with other tables
* const sql = new DurableObjectSqliteSyncWrapper(this.ctx.storage, { tablePrefix: 'tldraw_' })
* // Creates tables: tldraw_documents, tldraw_tombstones, tldraw_metadata
* ```
*
* @public
*/
export class DurableObjectSqliteSyncWrapper implements TLSyncSqliteWrapper {
constructor(
private storage: {
sql: { exec(sql: string, ...bindings: unknown[]): Iterable<any> & { toArray(): any[] } }
transactionSync(callback: () => any): any
},
public config?: TLSyncSqliteWrapperConfig
) {}
exec(sql: string): void {
this.storage.sql.exec(sql)
}
prepare<TResult extends TLSqliteRow | void = void, TParams extends TLSqliteInputValue[] = []>(
sql: string
): TLSyncSqliteStatement<TResult, TParams> {
return new DurableObjectStatement<TResult, TParams>(this.storage.sql, sql)
}
transaction<T>(callback: () => T): T {
return this.storage.transactionSync(callback)
}
}

View File

@@ -0,0 +1,315 @@
import { TLDOCUMENT_ID, TLRecord } from '@tldraw/tlschema'
import { describe, expect, it } from 'vitest'
import {
contractRecords,
makeContractSnapshot,
makePage,
registerStorageContractTests,
} from '../test/storageContractSuite'
import {
computeTombstonePruning,
InMemorySyncStorage,
MAX_TOMBSTONES,
TOMBSTONE_PRUNE_BUFFER_SIZE,
} from './InMemorySyncStorage'
// Helper to create a sorted tombstone array for computeTombstonePruning
function makeTombstones(
count: number,
clockFn: (i: number) => number = (i) => i + 1
): Array<{ id: string; clock: number }> {
return Array.from({ length: count }, (_, i) => ({ id: `doc${i}`, clock: clockFn(i) }))
}
describe('InMemorySyncStorage', () => {
registerStorageContractTests({
create: (opts) => new InMemorySyncStorage<TLRecord>(opts),
})
describe('constructor clock handling', () => {
it('[IM1] clamps the document clock up to the max lastChangedClock in the snapshot', () => {
const storage = new InMemorySyncStorage<TLRecord>({
snapshot: makeContractSnapshot(contractRecords, {
documents: [
{ state: contractRecords[0], lastChangedClock: 10 },
{ state: contractRecords[1], lastChangedClock: 3 },
],
documentClock: 3,
}),
})
expect(storage.getClock()).toBe(10)
})
it('[IM1] clamps the document clock up to the max tombstone clock in the snapshot', () => {
const storage = new InMemorySyncStorage<TLRecord>({
snapshot: makeContractSnapshot(contractRecords, {
tombstones: { 'shape:gone': 25 },
documentClock: 3,
tombstoneHistoryStartsAtClock: 0,
}),
})
expect(storage.getClock()).toBe(25)
})
it('[IM1] handles snapshots with far more records and tombstones than fit in a spread call', () => {
const documents = []
for (let i = 0; i < 150_000; i++) {
documents.push({
state: { ...contractRecords[0], id: `shape:${i}` } as TLRecord,
lastChangedClock: i,
})
}
const tombstones: Record<string, number> = {}
for (let i = 0; i < 150_000; i++) tombstones[`shape:gone-${i}`] = 150_000 + i
const storage = new InMemorySyncStorage<TLRecord>({
snapshot: makeContractSnapshot(contractRecords, {
documents,
tombstones,
documentClock: 0,
tombstoneHistoryStartsAtClock: 0,
}),
})
expect(storage.getClock()).toBe(299_999)
})
it('[IM2] clamps tombstoneHistoryStartsAtClock down to the document clock', () => {
const storage = new InMemorySyncStorage<TLRecord>({
snapshot: makeContractSnapshot(contractRecords, {
documentClock: 5,
tombstoneHistoryStartsAtClock: 10,
}),
})
expect(storage.tombstoneHistoryStartsAtClock.get()).toBe(5)
expect(storage.getClock()).toBe(5)
})
it('[IM3] discards snapshot tombstones when tombstoneHistoryStartsAtClock equals the document clock', () => {
const storage = new InMemorySyncStorage<TLRecord>({
snapshot: makeContractSnapshot(contractRecords, {
tombstones: { 'shape:deleted1': 5 },
tombstoneHistoryStartsAtClock: 15,
documentClock: 15,
}),
})
expect(storage.tombstones.size).toBe(0)
})
it('[IM3] keeps snapshot tombstones when there is usable history', () => {
const storage = new InMemorySyncStorage<TLRecord>({
snapshot: makeContractSnapshot(contractRecords, {
tombstones: { 'shape:deleted1': 5, 'shape:deleted2': 10 },
tombstoneHistoryStartsAtClock: 0,
documentClock: 15,
}),
})
expect(storage.tombstones.size).toBe(2)
expect(storage.tombstones.get('shape:deleted1')).toBe(5)
})
})
describe('record freezing', () => {
it('[IM4] freezes records stored via set()', () => {
const storage = new InMemorySyncStorage<TLRecord>({
snapshot: makeContractSnapshot(contractRecords),
})
const page = makePage('mutable')
storage.transaction((txn) => txn.set(page.id, page))
expect(Object.isFrozen(storage.documents.get(page.id)?.state)).toBe(true)
})
it('[IM4] freezes records loaded from the constructor snapshot', () => {
const storage = new InMemorySyncStorage<TLRecord>({
snapshot: makeContractSnapshot(contractRecords),
})
expect(Object.isFrozen(storage.documents.get(TLDOCUMENT_ID)?.state)).toBe(true)
})
})
describe('schema default', () => {
it('[IM5] assumes the earliest tldraw schema when the snapshot has none', () => {
const snapshot = makeContractSnapshot(contractRecords) as any
delete snapshot.schema
const storage = new InMemorySyncStorage<TLRecord>({ snapshot })
// the earliest serialized schema pins every sequence to version 0
const schema = storage.schema.get() as any
expect(Object.values(schema.sequences).every((v) => v === 0)).toBe(true)
})
})
describe('duplicate ids', () => {
it('[IM6] the last entry wins for duplicate document ids in a snapshot', () => {
const page1 = makePage('dupe', 'First', 'a1')
const page2 = makePage('dupe', 'Second', 'a2')
const storage = new InMemorySyncStorage<TLRecord>({
snapshot: {
documents: [
{ state: page1, lastChangedClock: 1 },
{ state: page2, lastChangedClock: 2 },
],
documentClock: 5,
schema: makeContractSnapshot([]).schema,
},
})
expect(storage.documents.get('page:dupe')).toEqual({ state: page2, lastChangedClock: 2 })
})
})
describe('computeTombstonePruning', () => {
describe('threshold', () => {
it('[TP1] returns null when the count is below maxTombstones', () => {
expect(
computeTombstonePruning({
tombstones: makeTombstones(100),
documentClock: 1000,
maxTombstones: 500,
})
).toBeNull()
})
it('[TP1] returns null when the count equals maxTombstones exactly', () => {
expect(
computeTombstonePruning({
tombstones: makeTombstones(500),
documentClock: 1000,
maxTombstones: 500,
})
).toBeNull()
})
it('[TP1] returns null for an empty array', () => {
expect(computeTombstonePruning({ tombstones: [], documentClock: 1000 })).toBeNull()
})
it('[TP1] does not prune at exactly MAX_TOMBSTONES with default constants', () => {
expect(
computeTombstonePruning({
tombstones: makeTombstones(MAX_TOMBSTONES),
documentClock: 100000,
})
).toBeNull()
})
})
describe('cutoff calculation', () => {
it('[TP2] deletes the oldest pruneBufferSize + excess tombstones', () => {
// 10 tombstones, max 5, buffer 2 => delete 2 + 10 - 5 = 7
const result = computeTombstonePruning({
tombstones: makeTombstones(10),
documentClock: 100,
maxTombstones: 5,
pruneBufferSize: 2,
})
expect(result).toEqual({
idsToDelete: ['doc0', 'doc1', 'doc2', 'doc3', 'doc4', 'doc5', 'doc6'],
newTombstoneHistoryStartsAtClock: 8, // clock of doc7, the oldest survivor
})
})
it('[TP2] one over MAX_TOMBSTONES deletes the buffer plus one with default constants', () => {
const result = computeTombstonePruning({
tombstones: makeTombstones(MAX_TOMBSTONES + 1),
documentClock: 100000,
})
expect(result!.idsToDelete.length).toBe(TOMBSTONE_PRUNE_BUFFER_SIZE + 1)
})
it('[TP2] works with a zero buffer size', () => {
const result = computeTombstonePruning({
tombstones: makeTombstones(101),
documentClock: 1000,
maxTombstones: 100,
pruneBufferSize: 0,
})
expect(result!.idsToDelete).toEqual(['doc0'])
expect(result!.newTombstoneHistoryStartsAtClock).toBe(2)
})
it('[TP2] extends the cutoff rather than splitting a clock value', () => {
const tombstones = [
{ id: 'a', clock: 1 },
{ id: 'b1', clock: 2 },
{ id: 'b2', clock: 2 },
{ id: 'b3', clock: 2 },
{ id: 'b4', clock: 2 },
{ id: 'c', clock: 3 },
]
// max 4, buffer 1 => initial cutoff 3, which would split the clock-2 group
const result = computeTombstonePruning({
tombstones,
documentClock: 100,
maxTombstones: 4,
pruneBufferSize: 1,
})
expect(result!.idsToDelete).toEqual(['a', 'b1', 'b2', 'b3', 'b4'])
expect(result!.newTombstoneHistoryStartsAtClock).toBe(3)
})
it('[TP2] leaves the cutoff alone when it falls on a clock boundary', () => {
const tombstones = [
{ id: 'a1', clock: 1 },
{ id: 'a2', clock: 1 },
{ id: 'a3', clock: 1 },
{ id: 'b1', clock: 2 },
{ id: 'b2', clock: 2 },
{ id: 'b3', clock: 2 },
{ id: 'c1', clock: 3 },
{ id: 'c2', clock: 3 },
{ id: 'c3', clock: 3 },
{ id: 'd1', clock: 4 },
]
// max 5, buffer 1 => cutoff 6, which lands exactly between clocks 2 and 3
const result = computeTombstonePruning({
tombstones,
documentClock: 100,
maxTombstones: 5,
pruneBufferSize: 1,
})
expect(result!.idsToDelete).toEqual(['a1', 'a2', 'a3', 'b1', 'b2', 'b3'])
expect(result!.newTombstoneHistoryStartsAtClock).toBe(3)
})
it('[TP2] handles non-contiguous clock values', () => {
const tombstones = [
{ id: 'a', clock: 1 },
{ id: 'b', clock: 5 },
{ id: 'c', clock: 10 },
{ id: 'd', clock: 50 },
{ id: 'e', clock: 100 },
]
const result = computeTombstonePruning({
tombstones,
documentClock: 200,
maxTombstones: 3,
pruneBufferSize: 1,
})
expect(result!.idsToDelete).toEqual(['a', 'b', 'c'])
expect(result!.newTombstoneHistoryStartsAtClock).toBe(50)
})
})
describe('history clock fallback', () => {
it('[TP3] uses documentClock when every tombstone is deleted', () => {
// max 5, buffer 10 => cutoff 15, but only 10 items exist
const result = computeTombstonePruning({
tombstones: makeTombstones(10),
documentClock: 999,
maxTombstones: 5,
pruneBufferSize: 10,
})
expect(result!.idsToDelete.length).toBe(10)
expect(result!.newTombstoneHistoryStartsAtClock).toBe(999)
})
it('[TP3] uses documentClock when a shared clock value forces deleting everything', () => {
const result = computeTombstonePruning({
tombstones: makeTombstones(100, () => 5),
documentClock: 1000,
maxTombstones: 50,
pruneBufferSize: 10,
})
expect(result!.idsToDelete.length).toBe(100)
expect(result!.newTombstoneHistoryStartsAtClock).toBe(1000)
})
})
})
})

View File

@@ -0,0 +1,452 @@
import { atom, Atom, transaction } from '@tldraw/state'
import { AtomMap, devFreeze, SerializedSchema, UnknownRecord } from '@tldraw/store'
import {
createTLSchema,
DocumentRecordType,
PageRecordType,
TLDOCUMENT_ID,
TLPageId,
} from '@tldraw/tlschema'
import { assert, IndexKey, objectMapEntries, throttle } from '@tldraw/utils'
import { MicrotaskNotifier } from './MicrotaskNotifier'
import { RoomSnapshot } from './TLSyncRoom'
import {
TLSyncForwardDiff,
TLSyncStorage,
TLSyncStorageGetChangesSinceResult,
TLSyncStorageOnChangeCallbackProps,
TLSyncStorageTransaction,
TLSyncStorageTransactionCallback,
TLSyncStorageTransactionOptions,
TLSyncStorageTransactionResult,
} from './TLSyncStorage'
/** @internal */
export const TOMBSTONE_PRUNE_BUFFER_SIZE = 1000
/** @internal */
export const MAX_TOMBSTONES = 5000
/**
* Result of computing which tombstones to prune.
* @internal
*/
export interface TombstonePruneResult {
/** The new value for tombstoneHistoryStartsAtClock */
newTombstoneHistoryStartsAtClock: number
/** IDs of tombstones to delete */
idsToDelete: string[]
}
/**
* Computes which tombstones should be pruned, avoiding partial history for any clock value.
* Returns null if no pruning is needed (tombstone count <= maxTombstones).
*
* @param tombstones - Array of tombstones sorted by clock ascending (oldest first)
* @param documentClock - Current document clock (used as fallback if all tombstones are deleted)
* @param maxTombstones - Maximum number of tombstones to keep (default: MAX_TOMBSTONES)
* @param pruneBufferSize - Extra tombstones to prune beyond the threshold (default: TOMBSTONE_PRUNE_BUFFER_SIZE)
* @returns Pruning result or null if no pruning needed
*
* @internal
*/
export function computeTombstonePruning({
tombstones,
documentClock,
maxTombstones = MAX_TOMBSTONES,
pruneBufferSize = TOMBSTONE_PRUNE_BUFFER_SIZE,
}: {
tombstones: Array<{ id: string; clock: number }>
documentClock: number
maxTombstones?: number
pruneBufferSize?: number
}): TombstonePruneResult | null {
if (tombstones.length <= maxTombstones) {
return null
}
// Determine how many to delete, avoiding partial history for a clock value
let cutoff = pruneBufferSize + tombstones.length - maxTombstones
while (
cutoff < tombstones.length &&
tombstones[cutoff - 1]?.clock === tombstones[cutoff]?.clock
) {
cutoff++
}
// Set history start to the oldest remaining tombstone's clock
// (or documentClock if we're deleting everything)
const oldestRemaining = tombstones[cutoff]
const newTombstoneHistoryStartsAtClock = oldestRemaining?.clock ?? documentClock
// Collect the oldest tombstones to delete (first cutoff entries)
const idsToDelete = tombstones.slice(0, cutoff).map((t) => t.id)
return { newTombstoneHistoryStartsAtClock, idsToDelete }
}
/**
* Default initial snapshot for a new room.
* @public
*/
export const DEFAULT_INITIAL_SNAPSHOT = {
documentClock: 0,
tombstoneHistoryStartsAtClock: 0,
schema: createTLSchema().serialize(),
documents: [
{
state: DocumentRecordType.create({ id: TLDOCUMENT_ID }),
lastChangedClock: 0,
},
{
state: PageRecordType.create({
id: 'page:page' as TLPageId,
name: 'Page 1',
index: 'a1' as IndexKey,
}),
lastChangedClock: 0,
},
],
}
/**
* In-memory implementation of TLSyncStorage using AtomMap for documents and tombstones,
* and atoms for clock values. This is the default storage implementation used by TLSyncRoom.
*
* @public
*/
export class InMemorySyncStorage<R extends UnknownRecord> implements TLSyncStorage<R> {
/** @internal */
documents: AtomMap<string, { state: R; lastChangedClock: number }>
/**
* Object-store lane records, partitioned out of `documents` so they never appear in the
* document snapshot. They share the same clock, tombstones, and transactions as documents.
* @internal
*/
objects: AtomMap<string, { state: R; lastChangedClock: number }>
/** @internal */
readonly objectTypes: ReadonlySet<string>
/** @internal */
tombstones: AtomMap<string, number>
/** @internal */
schema: Atom<SerializedSchema>
/** @internal */
documentClock: Atom<number>
/** @internal */
tombstoneHistoryStartsAtClock: Atom<number>
private notifier = new MicrotaskNotifier<[TLSyncStorageOnChangeCallbackProps]>()
onChange(callback: (arg: TLSyncStorageOnChangeCallbackProps) => unknown): () => void {
return this.notifier.register(callback)
}
constructor({
snapshot = DEFAULT_INITIAL_SNAPSHOT,
objectTypes,
onChange,
}: {
snapshot?: RoomSnapshot
/**
* Record type names stored in the object-store lane. Records of these types are routed to
* a separate partition: excluded from `getSnapshot()`, returned by `getObjectsSnapshot()`.
*/
objectTypes?: readonly string[]
onChange?(arg: TLSyncStorageOnChangeCallbackProps): unknown
} = {}) {
this.objectTypes = new Set(objectTypes ?? [])
// a loop rather than `Math.max(0, ...clocks)`: spreading a large room's clocks as call
// arguments overflows the stack at roughly 100k+ records/tombstones
let maxClockValue = 0
// route snapshot entries into their partitions (a seed snapshot may carry object-lane
// records merged in, e.g. loaded from a separate persistence lane)
const documentEntries: [string, { state: R; lastChangedClock: number }][] = []
const objectEntries: [string, { state: R; lastChangedClock: number }][] = []
for (const d of snapshot.documents) {
if (d.lastChangedClock > maxClockValue) maxClockValue = d.lastChangedClock
const entries = this.objectTypes.has(d.state.typeName) ? objectEntries : documentEntries
entries.push([
d.state.id,
{ state: devFreeze(d.state) as R, lastChangedClock: d.lastChangedClock },
])
}
const tombstoneEntries = objectMapEntries(snapshot.tombstones ?? {})
for (const [, clock] of tombstoneEntries) {
if (clock > maxClockValue) maxClockValue = clock
}
this.documents = new AtomMap('room documents', documentEntries)
this.objects = new AtomMap('room objects', objectEntries)
const documentClock = Math.max(maxClockValue, snapshot.documentClock ?? snapshot.clock ?? 0)
this.documentClock = atom('document clock', documentClock)
// math.min to make sure the tombstone history starts at or before the document clock
const tombstoneHistoryStartsAtClock = Math.min(
snapshot.tombstoneHistoryStartsAtClock ?? documentClock,
documentClock
)
this.tombstoneHistoryStartsAtClock = atom(
'tombstone history starts at clock',
tombstoneHistoryStartsAtClock
)
// eslint-disable-next-line @typescript-eslint/no-deprecated
this.schema = atom('schema', snapshot.schema ?? createTLSchema().serializeEarliestVersion())
this.tombstones = new AtomMap(
'room tombstones',
// If the tombstone history starts now (or we didn't have the
// tombstoneHistoryStartsAtClock) then there are no tombstones
tombstoneHistoryStartsAtClock === documentClock ? [] : tombstoneEntries
)
if (onChange) {
this.onChange(onChange)
}
}
transaction<T>(
callback: TLSyncStorageTransactionCallback<R, T>,
opts?: TLSyncStorageTransactionOptions
): TLSyncStorageTransactionResult<T, R> {
const clockBefore = this.documentClock.get()
const trackChanges = opts?.emitChanges === 'always'
const txn = new InMemorySyncStorageTransaction<R>(this)
let result: T
let changes: TLSyncForwardDiff<R> | undefined
try {
result = transaction(() => {
return callback(txn as any)
}) as T
if (trackChanges) {
changes = txn.getChangesSince(clockBefore)?.diff
}
} catch (error) {
console.error('Error in transaction', error)
throw error
} finally {
txn.close()
}
if (
typeof result === 'object' &&
result &&
'then' in result &&
typeof result.then === 'function'
) {
const err = new Error('Transaction must return a value, not a promise')
console.error(err)
throw err
}
const clockAfter = this.documentClock.get()
const didChange = clockAfter > clockBefore
if (didChange) {
this.notifier.notify({ id: opts?.id, documentClock: clockAfter })
}
// InMemorySyncStorage applies changes verbatim, so we only emit changes
// when 'always' is specified (not for 'when-different')
return { documentClock: clockAfter, didChange: clockAfter > clockBefore, result, changes }
}
getClock(): number {
return this.documentClock.get()
}
/** @internal */
pruneTombstones = throttle(
() => {
if (this.tombstones.size > MAX_TOMBSTONES) {
// Convert to array and sort by clock ascending (oldest first)
const tombstones = Array.from(this.tombstones.entries())
.map(([id, clock]) => ({ id, clock }))
.sort((a, b) => a.clock - b.clock)
const result = computeTombstonePruning({
tombstones,
documentClock: this.documentClock.get(),
})
if (result) {
this.tombstoneHistoryStartsAtClock.set(result.newTombstoneHistoryStartsAtClock)
this.tombstones.deleteMany(result.idsToDelete)
}
}
},
1000,
// prevent this from running synchronously to avoid blocking requests
{ leading: false }
)
getSnapshot(): RoomSnapshot {
// object-lane records live in their own partition, so the document snapshot is
// pure-document by construction
return {
tombstoneHistoryStartsAtClock: this.tombstoneHistoryStartsAtClock.get(),
documentClock: this.documentClock.get(),
documents: Array.from(this.documents.values()),
tombstones: Object.fromEntries(this.tombstones.entries()),
schema: this.schema.get(),
}
}
getObjectsSnapshot(): RoomSnapshot['documents'] {
return Array.from(this.objects.values())
}
getObjectsByIds(ids: Iterable<string>): RoomSnapshot['documents'] {
const result: RoomSnapshot['documents'] = []
const seen = new Set<string>()
for (const id of ids) {
if (seen.has(id)) continue
seen.add(id)
const doc = this.objects.get(id)
if (doc) result.push(doc)
}
return result
}
}
/**
* Transaction implementation for InMemorySyncStorage.
* Provides access to documents, tombstones, and metadata within a transaction.
*
* @internal
*/
class InMemorySyncStorageTransaction<
R extends UnknownRecord,
> implements TLSyncStorageTransaction<R> {
private _clock
private _closed = false
constructor(private storage: InMemorySyncStorage<R>) {
this._clock = this.storage.documentClock.get()
}
/** @internal */
close() {
this._closed = true
}
private assertNotClosed() {
assert(!this._closed, 'Transaction has ended, iterator cannot be consumed')
}
getClock(): number {
return this._clock
}
private didIncrementClock: boolean = false
private getNextClock(): number {
if (!this.didIncrementClock) {
this.didIncrementClock = true
this._clock = this.storage.documentClock.set(this.storage.documentClock.get() + 1)
}
return this._clock
}
/** The partition a record with this id currently lives in, if any. */
private mapContaining(id: string) {
if (this.storage.documents.has(id)) return this.storage.documents
if (this.storage.objects.has(id)) return this.storage.objects
return undefined
}
get(id: string): R | undefined {
this.assertNotClosed()
return (this.storage.documents.get(id) ?? this.storage.objects.get(id))?.state
}
set(id: string, record: R): void {
this.assertNotClosed()
assert(id === record.id, `Record id mismatch: key does not match record.id`)
const clock = this.getNextClock()
// Automatically clear tombstone if it exists
if (this.storage.tombstones.has(id)) {
this.storage.tombstones.delete(id)
}
// route by type: object-lane records live in their own partition
const map = this.storage.objectTypes.has(record.typeName)
? this.storage.objects
: this.storage.documents
map.set(id, {
state: devFreeze(record) as R,
lastChangedClock: clock,
})
}
delete(id: string): void {
this.assertNotClosed()
// Only create a tombstone if the record actually exists
const map = this.mapContaining(id)
if (!map) return
const clock = this.getNextClock()
map.delete(id)
this.storage.tombstones.set(id, clock)
this.storage.pruneTombstones()
}
// iteration spans both partitions so schema migrations cover object-lane records too
*entries(): IterableIterator<[string, R]> {
this.assertNotClosed()
for (const map of [this.storage.documents, this.storage.objects]) {
for (const [id, record] of map.entries()) {
this.assertNotClosed()
yield [id, record.state]
}
}
}
*keys(): IterableIterator<string> {
this.assertNotClosed()
for (const map of [this.storage.documents, this.storage.objects]) {
for (const key of map.keys()) {
this.assertNotClosed()
yield key
}
}
}
*values(): IterableIterator<R> {
this.assertNotClosed()
for (const map of [this.storage.documents, this.storage.objects]) {
for (const record of map.values()) {
this.assertNotClosed()
yield record.state
}
}
}
getSchema(): SerializedSchema {
this.assertNotClosed()
return this.storage.schema.get()
}
setSchema(schema: SerializedSchema): void {
this.assertNotClosed()
this.storage.schema.set(schema)
}
getChangesSince(sinceClock: number): TLSyncStorageGetChangesSinceResult<R> | undefined {
this.assertNotClosed()
const clock = this.storage.documentClock.get()
if (sinceClock === clock) return undefined
if (sinceClock > clock) {
// something went wrong, wipe the slate clean
sinceClock = -1
}
const diff: TLSyncForwardDiff<R> = { puts: {}, deletes: [] }
const wipeAll = sinceClock < this.storage.tombstoneHistoryStartsAtClock.get()
// both partitions share the clock and tombstones, so the change feed spans both
for (const map of [this.storage.documents, this.storage.objects]) {
for (const doc of map.values()) {
if (wipeAll || doc.lastChangedClock > sinceClock) {
// For historical changes, we don't have "from" state, so use added
diff.puts[doc.state.id] = doc.state as R
}
}
}
if (!wipeAll) {
for (const [id, clock] of this.storage.tombstones.entries()) {
if (clock > sinceClock) {
// For tombstones, we don't have the removed record, use placeholder
diff.deletes.push(id)
}
}
}
return { diff, wipeAll }
}
}

View File

@@ -0,0 +1,254 @@
import { vi } from 'vitest'
import { MicrotaskNotifier } from './MicrotaskNotifier'
// Helper to flush all pending microtasks
async function flushMicrotasks() {
await Promise.resolve()
}
describe('MicrotaskNotifier', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
consoleErrorSpy.mockRestore()
})
describe('deferred delivery (MN1)', () => {
it('[MN1] does not call listeners synchronously', () => {
const notifier = new MicrotaskNotifier<[string]>()
const listener = vi.fn()
notifier.register(listener)
notifier.notify('hello')
expect(listener).not.toHaveBeenCalled()
})
it('[MN1] delivers the notification arguments to every listener on a microtask', async () => {
const notifier = new MicrotaskNotifier<[string, number, boolean]>()
const listener1 = vi.fn()
const listener2 = vi.fn()
notifier.register(listener1)
notifier.register(listener2)
await flushMicrotasks()
notifier.notify('test', 123, true)
await flushMicrotasks()
expect(listener1).toHaveBeenCalledExactlyOnceWith('test', 123, true)
expect(listener2).toHaveBeenCalledExactlyOnceWith('test', 123, true)
})
it('[MN1] delivers multiple notifications in order', async () => {
const notifier = new MicrotaskNotifier<[number]>()
const calls: number[] = []
const listener = vi.fn((n: number) => calls.push(n))
notifier.register(listener)
await flushMicrotasks()
notifier.notify(1)
notifier.notify(2)
notifier.notify(3)
await flushMicrotasks()
expect(calls).toEqual([1, 2, 3])
})
it('[MN1] notify with no listeners does not throw', async () => {
const notifier = new MicrotaskNotifier<[string]>()
expect(() => notifier.notify('test')).not.toThrow()
await flushMicrotasks()
})
})
describe('deferred registration (MN2)', () => {
it('[MN2] a listener misses notifications issued before register was called', async () => {
const notifier = new MicrotaskNotifier<[string]>()
const listener = vi.fn()
notifier.notify('before registration')
notifier.register(listener)
await flushMicrotasks()
expect(listener).not.toHaveBeenCalled()
})
it('[MN2] a listener receives notifications issued after register in the same synchronous block', async () => {
const notifier = new MicrotaskNotifier<[string]>()
const listener = vi.fn()
// Both register and notify queue microtasks; microtasks run FIFO, so
// the add runs first and the notification is delivered
notifier.register(listener)
notifier.notify('same sync block')
await flushMicrotasks()
expect(listener).toHaveBeenCalledExactlyOnceWith('same sync block')
})
it('[MN2] registering during a notification defers the new listener to later notifications', async () => {
const notifier = new MicrotaskNotifier<[string]>()
const lateListener = vi.fn()
const listener1 = vi.fn(() => {
notifier.register(lateListener)
})
notifier.register(listener1)
await flushMicrotasks()
notifier.notify('first')
await flushMicrotasks()
expect(lateListener).not.toHaveBeenCalled()
await flushMicrotasks() // late registration completes
notifier.notify('second')
await flushMicrotasks()
expect(lateListener).toHaveBeenCalledExactlyOnceWith('second')
})
})
describe('unsubscribing (MN3)', () => {
it('[MN3] unsubscribe is synchronous and stops future notifications', async () => {
const notifier = new MicrotaskNotifier<[string]>()
const listener = vi.fn()
const other = vi.fn()
const unsubscribe = notifier.register(listener)
notifier.register(other)
await flushMicrotasks()
unsubscribe()
notifier.notify('should not receive')
await flushMicrotasks()
expect(listener).not.toHaveBeenCalled()
// other listeners are unaffected
expect(other).toHaveBeenCalledExactlyOnceWith('should not receive')
})
it('[MN3] unsubscribing before the add microtask runs prevents the registration entirely', async () => {
const notifier = new MicrotaskNotifier<[string]>()
const listener = vi.fn()
const unsubscribe = notifier.register(listener)
unsubscribe() // before the add microtask runs
await flushMicrotasks()
notifier.notify('should not receive')
await flushMicrotasks()
expect(listener).not.toHaveBeenCalled()
})
it('[MN3] double unsubscribe is safe', async () => {
const notifier = new MicrotaskNotifier<[string]>()
const listener = vi.fn()
const unsubscribe = notifier.register(listener)
await flushMicrotasks()
unsubscribe()
unsubscribe()
unsubscribe()
notifier.notify('test')
await flushMicrotasks()
expect(listener).not.toHaveBeenCalled()
})
it('[MN3] registering the same function twice', async () => {
const notifier = new MicrotaskNotifier<[string]>()
const listener = vi.fn()
const unsub1 = notifier.register(listener)
const _unsub2 = notifier.register(listener)
await flushMicrotasks()
notifier.notify('test')
await flushMicrotasks()
// The listener set deduplicates the same function reference, so it is
// only called once per notification
expect(listener).toHaveBeenCalledTimes(1)
// Unsubscribing via the first registration removes the function for
// both registrations
unsub1()
notifier.notify('test2')
await flushMicrotasks()
expect(listener).toHaveBeenCalledTimes(1)
})
it('[MN3] unsubscribing during notification iteration takes effect for later notifications', async () => {
const notifier = new MicrotaskNotifier<[string]>()
let unsub2: (() => void) | undefined = undefined
const listener1 = vi.fn(() => {
// Unsubscribe listener2 while iteration is in progress
unsub2?.()
})
const listener2 = vi.fn()
const listener3 = vi.fn()
notifier.register(listener1)
unsub2 = notifier.register(listener2)
notifier.register(listener3)
await flushMicrotasks()
notifier.notify('test')
await flushMicrotasks()
expect(listener1).toHaveBeenCalled()
expect(listener3).toHaveBeenCalled()
listener1.mockClear()
listener2.mockClear()
listener3.mockClear()
notifier.notify('test2')
await flushMicrotasks()
expect(listener1).toHaveBeenCalled()
expect(listener2).not.toHaveBeenCalled()
expect(listener3).toHaveBeenCalled()
})
})
describe('listener errors (MN4)', () => {
it('[MN4] a listener that throws is caught and logged, and remaining listeners still run', async () => {
const notifier = new MicrotaskNotifier<[string]>()
const errorListener = vi.fn(() => {
throw new Error('listener error')
})
const normalListener = vi.fn()
notifier.register(errorListener)
notifier.register(normalListener)
await flushMicrotasks()
notifier.notify('test')
await flushMicrotasks()
expect(errorListener).toHaveBeenCalled()
expect(normalListener).toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error in MicrotaskNotifier listener',
expect.any(Error)
)
})
})
})

View File

@@ -0,0 +1,38 @@
/**
* A notifier that queues its notifications to the microtask queue.
* This is useful for avoiding race conditions where callbacks are triggered prematurely.
*/
export class MicrotaskNotifier<T extends unknown[]> {
private listeners = new Set<(...props: T) => void>()
notify(...props: T) {
queueMicrotask(() => {
for (const listener of this.listeners) {
try {
listener(...props)
} catch (error) {
console.error('Error in MicrotaskNotifier listener', error)
}
}
})
}
register(_listener: (...props: T) => void) {
// Track if unsubscribe was called before the add microtask ran
let didDelete = false
// We defer the add to the microtask queue to ensure the callback isn't invoked
// for changes that happened before this registration
queueMicrotask(() => {
if (didDelete) return
this.listeners.add(_listener)
})
return () => {
if (didDelete) return
didDelete = true
// Synchronous delete ensures immediate unsubscription
this.listeners.delete(_listener)
}
}
}

View File

@@ -0,0 +1,305 @@
import { DatabaseSync } from 'node:sqlite'
import { beforeEach, describe, expect, it } from 'vitest'
import { NodeSqliteWrapper } from './NodeSqliteWrapper'
describe('NodeSqliteWrapper', () => {
let db: DatabaseSync
let wrapper: NodeSqliteWrapper
beforeEach(() => {
db = new DatabaseSync(':memory:')
wrapper = new NodeSqliteWrapper(db)
wrapper.exec('CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT, value INTEGER)')
})
describe('exec', () => {
it('[NW1] executes DDL statements', () => {
wrapper.exec('CREATE TABLE another (id INTEGER PRIMARY KEY)')
// Verify table exists by inserting
wrapper.prepare('INSERT INTO another (id) VALUES (?)').run(1)
const results = wrapper.prepare<{ id: number }>('SELECT * FROM another').all()
expect(results).toEqual([{ id: 1 }])
})
it('[NW1] executes multi-statement DDL', () => {
wrapper.exec(`
CREATE TABLE t1 (id INTEGER PRIMARY KEY);
CREATE TABLE t2 (id INTEGER PRIMARY KEY);
CREATE TABLE t3 (id INTEGER PRIMARY KEY)
`)
// Verify all tables exist
wrapper.prepare('INSERT INTO t1 (id) VALUES (?)').run(1)
wrapper.prepare('INSERT INTO t2 (id) VALUES (?)').run(2)
wrapper.prepare('INSERT INTO t3 (id) VALUES (?)').run(3)
expect(wrapper.prepare<{ id: number }>('SELECT * FROM t1').all()).toEqual([{ id: 1 }])
expect(wrapper.prepare<{ id: number }>('SELECT * FROM t2').all()).toEqual([{ id: 2 }])
expect(wrapper.prepare<{ id: number }>('SELECT * FROM t3').all()).toEqual([{ id: 3 }])
})
})
describe('prepare', () => {
describe('all()', () => {
it('[NW1] returns all results as an array', () => {
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(1, 'alice', 100)
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(2, 'bob', 200)
const results = wrapper
.prepare<{ id: number; name: string; value: number }>('SELECT * FROM test ORDER BY id')
.all()
expect(results).toEqual([
{ id: 1, name: 'alice', value: 100 },
{ id: 2, name: 'bob', value: 200 },
])
})
it('[NW1] handles queries with bindings', () => {
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(1, 'alice', 100)
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(2, 'bob', 200)
const results = wrapper
.prepare<{ name: string }>('SELECT name FROM test WHERE value > ?')
.all(150)
expect(results).toEqual([{ name: 'bob' }])
})
it('[NW1] returns empty array for DML statements', () => {
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(1, 'alice', 100)
const stmt = wrapper.prepare('UPDATE test SET value = ? WHERE id = ?')
expect(stmt.all(999, 1)).toEqual([])
// Verify the update happened
const result = wrapper
.prepare<{ value: number }>('SELECT value FROM test WHERE id = ?')
.all(1)
expect(result).toEqual([{ value: 999 }])
})
it('[NW1] handles INSERT with RETURNING clause', () => {
const results = wrapper
.prepare<{
id: number
name: string
}>('INSERT INTO test (name, value) VALUES (?, ?) RETURNING id, name')
.all('charlie', 300)
expect(results).toHaveLength(1)
expect(results[0].name).toBe('charlie')
expect(typeof results[0].id).toBe('number')
})
})
describe('iterate()', () => {
it('[NW1] returns results via iteration', () => {
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(1, 'alice', 100)
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(2, 'bob', 200)
const stmt = wrapper.prepare<{ id: number; name: string; value: number }>(
'SELECT * FROM test ORDER BY id'
)
const results: { id: number; name: string; value: number }[] = []
for (const row of stmt.iterate()) {
results.push(row)
}
expect(results).toEqual([
{ id: 1, name: 'alice', value: 100 },
{ id: 2, name: 'bob', value: 200 },
])
})
it('[NW1] handles queries with bindings', () => {
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(1, 'alice', 100)
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(2, 'bob', 200)
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(3, 'carol', 300)
const results: { name: string }[] = []
for (const row of wrapper
.prepare<{ name: string }>('SELECT name FROM test WHERE value > ?')
.iterate(150)) {
results.push(row)
}
expect(results).toEqual([{ name: 'bob' }, { name: 'carol' }])
})
})
describe('run()', () => {
it('[NW1] executes DML without returning results', () => {
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(1, 'alice', 100)
// Verify the insert happened
const results = wrapper.prepare<{ id: number }>('SELECT id FROM test').all()
expect(results).toEqual([{ id: 1 }])
})
it('[NW1] executes UPDATE', () => {
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(1, 'alice', 100)
wrapper.prepare('UPDATE test SET value = ? WHERE id = ?').run(999, 1)
const results = wrapper
.prepare<{ value: number }>('SELECT value FROM test WHERE id = ?')
.all(1)
expect(results).toEqual([{ value: 999 }])
})
it('[NW1] executes DELETE', () => {
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(1, 'alice', 100)
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(2, 'bob', 200)
wrapper.prepare('DELETE FROM test WHERE id = ?').run(1)
const results = wrapper.prepare<{ id: number }>('SELECT id FROM test').all()
expect(results).toEqual([{ id: 2 }])
})
})
describe('prepared statement reuse', () => {
it('[NW1] can be reused with different bindings', () => {
const insert = wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)')
insert.run(1, 'alice', 100)
insert.run(2, 'bob', 200)
insert.run(3, 'carol', 300)
const results = wrapper.prepare<{ id: number }>('SELECT id FROM test ORDER BY id').all()
expect(results).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }])
})
it('[NW1] iterate can be called multiple times', () => {
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(1, 'alice', 100)
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(2, 'bob', 200)
const stmt = wrapper.prepare<{ id: number }>('SELECT id FROM test ORDER BY id')
// First iteration
const results1: number[] = []
for (const row of stmt.iterate()) {
results1.push(row.id)
}
// Second iteration
const results2: number[] = []
for (const row of stmt.iterate()) {
results2.push(row.id)
}
expect(results1).toEqual([1, 2])
expect(results2).toEqual([1, 2])
})
})
it('[NW1] only executes first statement in multi-statement SQL (node:sqlite limitation)', () => {
// node:sqlite's prepare() silently ignores statements after the first semicolon
wrapper.prepare('INSERT INTO test (id) VALUES (1); INSERT INTO test (id) VALUES (2)').run()
// Only the first statement was executed
const results = wrapper.prepare<{ id: number }>('SELECT id FROM test').all()
expect(results).toEqual([{ id: 1 }])
})
it('[NW1] handles string with semicolon in value (not multi-statement)', () => {
wrapper
.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)')
.run(1, 'hello; world', 100)
const results = wrapper.prepare<{ name: string }>('SELECT name FROM test').all()
expect(results).toEqual([{ name: 'hello; world' }])
})
})
describe('transaction', () => {
it('[NW2] commits on success', () => {
wrapper.transaction(() => {
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(1, 'alice', 100)
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(2, 'bob', 200)
})
const results = wrapper.prepare<{ id: number }>('SELECT id FROM test ORDER BY id').all()
expect(results).toEqual([{ id: 1 }, { id: 2 }])
})
it('[NW2] rolls back on error', () => {
expect(() => {
wrapper.transaction(() => {
wrapper
.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)')
.run(1, 'alice', 100)
throw new Error('oops')
})
}).toThrow('oops')
const results = wrapper.prepare('SELECT * FROM test').all()
expect(results).toEqual([])
})
it('[NW2] returns the callback result', () => {
const result = wrapper.transaction(() => {
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(1, 'alice', 100)
return 'done'
})
expect(result).toBe('done')
})
it('[NW2] supports nested reads within transaction', () => {
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(1, 'alice', 100)
const result = wrapper.transaction(() => {
const rows = wrapper
.prepare<{ value: number }>('SELECT value FROM test WHERE id = ?')
.all(1)
wrapper.prepare('UPDATE test SET value = ? WHERE id = ?').run(rows[0].value + 50, 1)
return wrapper.prepare<{ value: number }>('SELECT value FROM test WHERE id = ?').all(1)[0]
.value
})
expect(result).toBe(150)
})
it('[NW2] rolls back and rethrows the original error', () => {
const customError = new Error('custom error')
expect(() => {
wrapper.transaction(() => {
wrapper
.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)')
.run(1, 'alice', 100)
throw customError
})
}).toThrow(customError)
// the write was rolled back
expect(wrapper.prepare('SELECT * FROM test').all()).toEqual([])
})
it('[NW2] rolls back when COMMIT itself fails, so the connection is not left inside a transaction', () => {
// simulate a COMMIT failure (SQLITE_BUSY, I/O error) at the driver level
const failingDb = {
exec: (sql: string) => {
if (sql === 'COMMIT') throw new Error('SQLITE_BUSY: database is locked')
return db.exec(sql)
},
prepare: (sql: string) => db.prepare(sql),
}
const failingWrapper = new NodeSqliteWrapper(failingDb as any)
expect(() =>
failingWrapper.transaction(() => {
failingWrapper
.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)')
.run(1, 'alice', 100)
})
).toThrow('SQLITE_BUSY')
// rolled back, and the next transaction can BEGIN normally
expect(wrapper.prepare('SELECT * FROM test').all()).toEqual([])
expect(() =>
wrapper.transaction(() => {
wrapper.prepare('INSERT INTO test (id, name, value) VALUES (?, ?, ?)').run(2, 'bob', 200)
})
).not.toThrow()
expect(wrapper.prepare<{ id: number }>('SELECT id FROM test').all()).toEqual([{ id: 2 }])
})
})
})

View File

@@ -0,0 +1,102 @@
import {
type TLSqliteInputValue,
type TLSqliteRow,
type TLSyncSqliteStatement,
type TLSyncSqliteWrapper,
type TLSyncSqliteWrapperConfig,
} from './SQLiteSyncStorage'
/**
* Minimal interface for a synchronous SQLite database.
*
* This interface is compatible with:
* - `node:sqlite` DatabaseSync (Node.js 22.5+)
* - `better-sqlite3` Database
*
* Any SQLite library that provides synchronous `exec` and `prepare` methods
* with the signatures below can be used with {@link NodeSqliteWrapper}.
*
* @public
*/
export interface SyncSqliteDatabase {
/** Execute raw SQL without returning results */
exec(sql: string): void
/** Prepare a statement for execution */
prepare(sql: string): {
iterate(...params: unknown[]): IterableIterator<unknown>
all(...params: unknown[]): unknown[]
run(...params: unknown[]): unknown
}
}
/**
* A wrapper around synchronous SQLite databases that implements TLSyncSqliteWrapper.
* Works with both `node:sqlite` DatabaseSync (Node.js 22.5+) and `better-sqlite3` Database.
*
* Use this wrapper with SQLiteSyncStorage to persist tldraw sync state to a SQLite database
* in Node.js environments.
*
* @example
* ```ts
* // With node:sqlite (Node.js 22.5+)
* import { DatabaseSync } from 'node:sqlite'
* import { SQLiteSyncStorage, NodeSqliteWrapper } from '@tldraw/sync-core'
*
* const db = new DatabaseSync(':memory:')
* const sql = new NodeSqliteWrapper(db)
* const storage = new SQLiteSyncStorage({ sql })
* ```
*
* @example
* ```ts
* // With better-sqlite3
* import Database from 'better-sqlite3'
* import { SQLiteSyncStorage, NodeSqliteWrapper } from '@tldraw/sync-core'
*
* const db = new Database(':memory:')
* const sql = new NodeSqliteWrapper(db)
* const storage = new SQLiteSyncStorage({ sql })
* ```
*
* @example
* ```ts
* // With table prefix to avoid conflicts with other tables
* const sql = new NodeSqliteWrapper(db, { tablePrefix: 'tldraw_' })
* // Creates tables: tldraw_documents, tldraw_tombstones, tldraw_metadata
* ```
*
* @public
*/
export class NodeSqliteWrapper implements TLSyncSqliteWrapper {
constructor(
private db: SyncSqliteDatabase,
public config?: TLSyncSqliteWrapperConfig
) {}
exec(sql: string): void {
this.db.exec(sql)
}
prepare<
TResult extends TLSqliteRow | void = void,
TParams extends TLSqliteInputValue[] = TLSqliteInputValue[],
>(sql: string): TLSyncSqliteStatement<TResult, TParams> {
return this.db.prepare(sql) as unknown as TLSyncSqliteStatement<TResult, TParams>
}
transaction<T>(callback: () => T): T {
this.db.exec('BEGIN')
let result: T
try {
result = callback()
// COMMIT itself can fail (SQLITE_BUSY from another connection, I/O errors); without a
// ROLLBACK the connection would stay inside the transaction and every later BEGIN
// would throw "cannot start a transaction within a transaction"
this.db.exec('COMMIT')
} catch (e) {
this.db.exec('ROLLBACK')
throw e
}
return result
}
}

View File

@@ -0,0 +1,148 @@
import { SerializedSchema, UnknownRecord } from '@tldraw/store'
import { TLObjectStoreAccess, TLSocketServerSentDataEvent } from './protocol'
import { TLRoomSocket } from './TLSyncRoom'
/**
* Enumeration of possible states for a room session during its lifecycle.
*
* Room sessions progress through these states as clients connect, authenticate,
* and disconnect from collaborative rooms.
*
* @internal
*/
export const RoomSessionState = {
/** Session is waiting for the initial connect message from the client */
AwaitingConnectMessage: 'awaiting-connect-message',
/** Session is disconnected but waiting for final cleanup before removal */
AwaitingRemoval: 'awaiting-removal',
/** Session is fully connected and actively synchronizing */
Connected: 'connected',
} as const
/**
* Type representing the possible states a room session can be in.
*
* @example
* ```ts
* const sessionState: RoomSessionState = RoomSessionState.Connected
* if (sessionState === RoomSessionState.AwaitingConnectMessage) {
* console.log('Session waiting for connect message')
* }
* ```
*
* @internal
*/
export type RoomSessionState = (typeof RoomSessionState)[keyof typeof RoomSessionState]
/**
* Maximum time in milliseconds to wait for a connect message after socket connection.
*
* If a client connects but doesn't send a connect message within this time,
* the session will be terminated.
*
* @public
*/
export const SESSION_START_WAIT_TIME = 10000
/**
* Time in milliseconds to wait before completely removing a disconnected session.
*
* This grace period allows for quick reconnections without losing session state,
* which is especially helpful for brief network interruptions.
*
* @public
*/
export const SESSION_REMOVAL_WAIT_TIME = 5000
/**
* Maximum time in milliseconds a connected session can remain idle before cleanup.
*
* Sessions that don't receive any messages or interactions for this duration
* may be considered for cleanup to free server resources.
*
* @public
*/
export const SESSION_IDLE_TIMEOUT = 20000
/**
* Base properties shared by all room session states.
*
* @internal
*/
export interface RoomSessionBase<R extends UnknownRecord, Meta> {
/** Unique identifier for this session */
sessionId: string
/** Presence identifier for live cursor/selection tracking, if available */
presenceId: string | null
/** WebSocket connection wrapper for this session */
socket: TLRoomSocket<R>
/** Custom metadata associated with this session */
meta: Meta
/** Whether this session has read-only permissions for document-lane records */
isReadonly: boolean
/**
* Write access for object-store lane record types (see `objectTypes` on the room).
* Deliberately independent of `isReadonly` so "can comment but not edit" is expressible.
*/
objectAccess: TLObjectStoreAccess
/** Whether this session requires legacy protocol rejection handling */
requiresLegacyRejection: boolean
/** Whether this session supports string append operations */
supportsStringAppend: boolean
}
/**
* Represents a client session within a collaborative room, tracking the connection
* state, permissions, and synchronization details for a single user.
*
* Each session corresponds to one WebSocket connection and progresses through
* different states during its lifecycle. The session type is a discriminated union
* based on the current state, ensuring type safety when accessing state-specific properties.
*
* @example
* ```ts
* // Check session state and access appropriate properties
* function handleSession(session: RoomSession<MyRecord, UserMeta>) {
* switch (session.state) {
* case RoomSessionState.AwaitingConnectMessage:
* console.log(`Session ${session.sessionId} started at ${session.sessionStartTime}`)
* break
* case RoomSessionState.Connected:
* console.log(`Connected session has ${session.outstandingDataMessages.length} pending messages`)
* break
* case RoomSessionState.AwaitingRemoval:
* console.log(`Session will be removed at ${session.cancellationTime}`)
* break
* }
* }
* ```
*
* @internal
*/
export type RoomSession<R extends UnknownRecord, Meta> =
| (RoomSessionBase<R, Meta> & {
/** Current state of the session */
state: typeof RoomSessionState.AwaitingConnectMessage
/** Timestamp when the session was created */
sessionStartTime: number
})
| (RoomSessionBase<R, Meta> & {
/** Current state of the session */
state: typeof RoomSessionState.AwaitingRemoval
/** Timestamp when the session was marked for removal */
cancellationTime: number
})
| (RoomSessionBase<R, Meta> & {
/** Current state of the session */
state: typeof RoomSessionState.Connected
/** Serialized schema information for this connected session */
serializedSchema: SerializedSchema
/** Whether this session requires down migrations */
requiresDownMigrations: boolean
/** Timestamp of the last interaction or message from this session */
lastInteractionTime: number
/** Timer for debouncing operations, if active */
debounceTimer: ReturnType<typeof setTimeout> | null
/** Queue of data messages waiting to be sent to this session */
outstandingDataMessages: TLSocketServerSentDataEvent<R>[]
})

View File

@@ -0,0 +1,325 @@
import { DatabaseSync } from 'node:sqlite'
import { DocumentRecordType, PageRecordType, TLDOCUMENT_ID, TLRecord } from '@tldraw/tlschema'
import { ZERO_INDEX_KEY } from '@tldraw/utils'
import { describe, expect, it, vi } from 'vitest'
import {
contractRecords,
contractSchema,
makeContractSnapshot,
makePage,
registerStorageContractTests,
} from '../test/storageContractSuite'
import { MAX_TOMBSTONES } from './InMemorySyncStorage'
import { NodeSqliteWrapper } from './NodeSqliteWrapper'
import { SQLiteSyncStorage } from './SQLiteSyncStorage'
function createWrapper(config?: { tablePrefix?: string }) {
return new NodeSqliteWrapper(new DatabaseSync(':memory:'), config)
}
describe('SQLiteSyncStorage', () => {
registerStorageContractTests({
create: (opts) => new SQLiteSyncStorage<TLRecord>({ sql: createWrapper(), ...opts }),
})
describe('persistence and initialization', () => {
it('[SQ1] data persists across re-instantiation over the same database', () => {
const sql = createWrapper()
const storage = new SQLiteSyncStorage<TLRecord>({
sql,
snapshot: makeContractSnapshot(contractRecords),
})
const newPage = makePage('persisted')
storage.transaction((txn) => txn.set(newPage.id, newPage))
const reopened = new SQLiteSyncStorage<TLRecord>({ sql })
expect(reopened.getClock()).toBe(1)
expect(
reopened.getSnapshot().documents.find((d) => d.state.id === newPage.id)?.state
).toEqual(newPage)
})
it('[SQ1] sweeps pre-partition object-lane rows out of the documents table on reopen', () => {
// a database written before the objects table existed: object-lane records (here
// 'page' stands in) live in the documents table
const sql = createWrapper()
const storage = new SQLiteSyncStorage<TLRecord>({
sql,
snapshot: makeContractSnapshot(contractRecords),
})
const extraPage = makePage('legacy')
storage.transaction((txn) => txn.set(extraPage.id, extraPage))
expect(storage.getSnapshot().documents).toHaveLength(3)
// reopening with objectTypes moves the rows into the objects partition
const reopened = new SQLiteSyncStorage<TLRecord>({ sql, objectTypes: ['page'] })
expect(reopened.getSnapshot().documents.map((d) => d.state.typeName)).toEqual(['document'])
expect(
reopened
.getObjectsSnapshot()
.map((d) => d.state.id)
.sort()
).toEqual([contractRecords[1].id, extraPage.id].sort())
// lastChangedClock survives the move
expect(
reopened.getObjectsSnapshot().find((d) => d.state.id === extraPage.id)?.lastChangedClock
).toBe(1)
})
it('[SQ1] honors the table prefix', () => {
const sql = createWrapper({ tablePrefix: 'tldraw_' })
const storage = new SQLiteSyncStorage<TLRecord>({
sql,
snapshot: makeContractSnapshot(contractRecords, { documentClock: 7 }),
})
expect(storage.getClock()).toBe(7)
// the prefixed tables exist; the unprefixed ones do not
expect(sql.prepare('SELECT count(*) as count FROM tldraw_documents').all()).toEqual([
{ count: 2 },
])
expect(() => sql.prepare('SELECT count(*) as count FROM documents').all()).toThrow()
})
it('[SQ2] a provided snapshot wipes and replaces existing data', () => {
const sql = createWrapper()
const storage = new SQLiteSyncStorage<TLRecord>({
sql,
snapshot: makeContractSnapshot(contractRecords, { documentClock: 10 }),
})
const extra = makePage('extra')
storage.transaction((txn) => txn.set(extra.id, extra))
const replacement = makeContractSnapshot([contractRecords[0]], { documentClock: 3 })
const reinitialized = new SQLiteSyncStorage<TLRecord>({ sql, snapshot: replacement })
expect(reinitialized.getClock()).toBe(3)
expect(reinitialized.getSnapshot().documents.map((d) => d.state.id)).toEqual([
contractRecords[0].id,
])
})
it('[SQ2] without a snapshot, pre-existing data is kept untouched', () => {
const sql = createWrapper()
new SQLiteSyncStorage<TLRecord>({
sql,
snapshot: makeContractSnapshot(contractRecords, { documentClock: 9 }),
})
const reopened = new SQLiteSyncStorage<TLRecord>({ sql })
expect(reopened.getClock()).toBe(9)
expect(reopened.getSnapshot().documents.length).toBe(2)
})
it('[SQ2] accepts a StoreSnapshot and converts it', () => {
const sql = createWrapper()
const storage = new SQLiteSyncStorage<TLRecord>({
sql,
snapshot: {
store: {
[TLDOCUMENT_ID]: contractRecords[0],
[contractRecords[1].id]: contractRecords[1],
} as any,
schema: contractSchema.serialize(),
},
})
const snapshot = storage.getSnapshot()
expect(snapshot.documentClock).toBe(0)
expect(snapshot.documents.length).toBe(2)
expect(snapshot.documents.every((d) => d.lastChangedClock === 0)).toBe(true)
expect(snapshot.tombstones).toEqual({})
})
})
describe('clock handling vs the in-memory implementation', () => {
it('[SQ3] takes documentClock verbatim, without clamping to lastChangedClock', () => {
const storage = new SQLiteSyncStorage<TLRecord>({
sql: createWrapper(),
snapshot: makeContractSnapshot(contractRecords, {
documents: [
{ state: contractRecords[0], lastChangedClock: 10 },
{ state: contractRecords[1], lastChangedClock: 3 },
],
documentClock: 3,
}),
})
expect(storage.getClock()).toBe(3)
})
it('[SQ3] takes tombstoneHistoryStartsAtClock verbatim and keeps tombstones with an empty history window', () => {
const storage = new SQLiteSyncStorage<TLRecord>({
sql: createWrapper(),
snapshot: makeContractSnapshot(contractRecords, {
tombstones: { 'shape:deleted1': 5 },
tombstoneHistoryStartsAtClock: 15,
documentClock: 15,
}),
})
const snapshot = storage.getSnapshot()
expect(snapshot.tombstoneHistoryStartsAtClock).toBe(15)
expect(snapshot.tombstones).toEqual({ 'shape:deleted1': 5 })
})
})
describe('static methods', () => {
it('[SQ4] hasBeenInitialized is false for an empty database and true once initialized', () => {
const sql = createWrapper()
expect(SQLiteSyncStorage.hasBeenInitialized(sql)).toBe(false)
new SQLiteSyncStorage<TLRecord>({ sql, snapshot: makeContractSnapshot(contractRecords) })
expect(SQLiteSyncStorage.hasBeenInitialized(sql)).toBe(true)
})
it('[SQ4] hasBeenInitialized respects the table prefix', () => {
const sql = createWrapper({ tablePrefix: 'test_' })
expect(SQLiteSyncStorage.hasBeenInitialized(sql)).toBe(false)
new SQLiteSyncStorage<TLRecord>({ sql, snapshot: makeContractSnapshot(contractRecords) })
expect(SQLiteSyncStorage.hasBeenInitialized(sql)).toBe(true)
})
it('[SQ5] getDocumentClock returns null when uninitialized, the persisted clock afterwards', () => {
const sql = createWrapper()
expect(SQLiteSyncStorage.getDocumentClock(sql)).toBe(null)
const storage = new SQLiteSyncStorage<TLRecord>({
sql,
snapshot: makeContractSnapshot(contractRecords, { documentClock: 42 }),
})
expect(SQLiteSyncStorage.getDocumentClock(sql)).toBe(42)
const newPage = makePage('test_page')
storage.transaction((txn) => txn.set(newPage.id, newPage))
expect(SQLiteSyncStorage.getDocumentClock(sql)).toBe(43)
})
})
describe('storage format migrations', () => {
it('[SQ6] stores document state as JSON-encoded BLOBs', () => {
const sql = createWrapper()
new SQLiteSyncStorage<TLRecord>({
sql,
snapshot: makeContractSnapshot(contractRecords),
})
const row = sql
.prepare<{ state: Uint8Array }>('SELECT state FROM documents WHERE id = ?')
.all(TLDOCUMENT_ID as any)[0]
// node:sqlite may hand back a Uint8Array from another realm, so avoid instanceof
expect(typeof row.state).not.toBe('string')
expect(ArrayBuffer.isView(row.state)).toBe(true)
expect(JSON.parse(new TextDecoder().decode(row.state))).toEqual(contractRecords[0])
})
it('[SQ6] migrates v1 databases (TEXT column) to BLOB, preserving data', () => {
// simulate a database created by the v1 schema
const db = new DatabaseSync(':memory:')
db.exec(`
CREATE TABLE documents (
id TEXT PRIMARY KEY,
state TEXT NOT NULL,
lastChangedClock INTEGER NOT NULL
);
CREATE INDEX idx_documents_lastChangedClock ON documents(lastChangedClock);
CREATE TABLE tombstones (
id TEXT PRIMARY KEY,
clock INTEGER NOT NULL
);
CREATE INDEX idx_tombstones_clock ON tombstones(clock);
CREATE TABLE metadata (
migrationVersion INTEGER NOT NULL,
documentClock INTEGER NOT NULL,
tombstoneHistoryStartsAtClock INTEGER NOT NULL,
schema TEXT NOT NULL
);
INSERT INTO metadata (migrationVersion, documentClock, tombstoneHistoryStartsAtClock, schema)
VALUES (1, 5, 0, '${JSON.stringify(contractSchema.serialize()).replace(/'/g, "''")}');
`)
const doc = DocumentRecordType.create({ id: TLDOCUMENT_ID })
const page = PageRecordType.create({
id: PageRecordType.createId('migrated_page'),
name: 'Migrated Page',
index: ZERO_INDEX_KEY,
})
const insert = db.prepare(
'INSERT INTO documents (id, state, lastChangedClock) VALUES (?, ?, ?)'
)
insert.run(doc.id, JSON.stringify(doc), 1)
insert.run(page.id, JSON.stringify(page), 2)
db.exec("INSERT INTO tombstones (id, clock) VALUES ('shape:deleted', 3)")
const storage = new SQLiteSyncStorage<TLRecord>({ sql: new NodeSqliteWrapper(db) })
const snapshot = storage.getSnapshot()
expect(snapshot.documentClock).toBe(5)
expect(snapshot.documents.map((d) => d.state.id).sort()).toEqual([doc.id, page.id].sort())
expect(snapshot.tombstones).toEqual({ 'shape:deleted': 3 })
// still writable after migration
const newPage = makePage('new_after_migration')
storage.transaction((txn) => txn.set(newPage.id, newPage))
expect(storage.getSnapshot().documents.length).toBe(3)
})
it('[SQ6] fresh databases start at migration version 3', () => {
const sql = createWrapper()
new SQLiteSyncStorage<TLRecord>({ sql, snapshot: makeContractSnapshot(contractRecords) })
const row = sql
.prepare<{ migrationVersion: number }>('SELECT migrationVersion FROM metadata')
.all()[0]
expect(row?.migrationVersion).toBe(3)
})
})
describe('tombstone pruning robustness', () => {
const makeTombstones = (count: number) => {
const tombstones: Record<string, number> = {}
for (let i = 0; i < count; i++) tombstones[`shape:doc${i}`] = i + 1
return tombstones
}
function makeOverfullStorage() {
const db = new DatabaseSync(':memory:')
const storage = new SQLiteSyncStorage<TLRecord>({
sql: new NodeSqliteWrapper(db),
snapshot: makeContractSnapshot(contractRecords, {
tombstones: makeTombstones(MAX_TOMBSTONES + 500),
documentClock: MAX_TOMBSTONES + 501,
tombstoneHistoryStartsAtClock: 0,
}),
})
return { db, storage, prune: (storage as any).pruneTombstones as { (): void; flush(): void } }
}
it('[SS18] a prune that fires after the database was closed does not throw', () => {
const { db, storage, prune } = makeOverfullStorage()
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
// a delete schedules the throttled prune; the host then closes everything
storage.transaction((txn) => txn.delete(contractRecords[1].id))
db.close()
// the throttle timer fires later, from outside any caller that could catch it
expect(() => prune.flush()).not.toThrow()
expect(consoleSpy).toHaveBeenCalledWith('Failed to prune tombstones', expect.anything())
} finally {
consoleSpy.mockRestore()
}
})
it('[SS18] a prune whose delete fails leaves the history clock where it was', () => {
const { storage, prune } = makeOverfullStorage()
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const stmts = (storage as any).stmts
const originalRun = stmts.deleteTombstonesBefore.run
stmts.deleteTombstonesBefore.run = () => {
throw new Error('SQLITE_BUSY: database is locked')
}
prune()
expect(() => prune.flush()).not.toThrow()
stmts.deleteTombstonesBefore.run = originalRun
// neither half of the prune applied: the history start was not advanced past
// tombstones that still exist
const snapshot = storage.getSnapshot()
expect(snapshot.tombstoneHistoryStartsAtClock).toBe(0)
expect(Object.keys(snapshot.tombstones!).length).toBe(MAX_TOMBSTONES + 500)
} finally {
consoleSpy.mockRestore()
}
})
})
})

View File

@@ -0,0 +1,749 @@
import { transaction } from '@tldraw/state'
import { SerializedSchema, StoreSnapshot, UnknownRecord } from '@tldraw/store'
import { assert, objectMapEntries, throttle } from '@tldraw/utils'
import {
computeTombstonePruning,
DEFAULT_INITIAL_SNAPSHOT,
MAX_TOMBSTONES,
} from './InMemorySyncStorage'
import { MicrotaskNotifier } from './MicrotaskNotifier'
import { RoomSnapshot } from './TLSyncRoom'
import {
convertStoreSnapshotToRoomSnapshot,
TLSyncForwardDiff,
TLSyncStorage,
TLSyncStorageGetChangesSinceResult,
TLSyncStorageOnChangeCallbackProps,
TLSyncStorageTransaction,
TLSyncStorageTransactionCallback,
TLSyncStorageTransactionOptions,
TLSyncStorageTransactionResult,
} from './TLSyncStorage'
/**
* Valid input value types for SQLite query parameters.
* These are the types that can be passed as bindings to prepared statements.
* @public
*/
export type TLSqliteInputValue = null | number | bigint | string | Uint8Array
/**
* Possible output value types returned from SQLite queries.
* Includes all input types plus Uint8Array for BLOB columns.
* @public
*/
export type TLSqliteOutputValue = null | number | bigint | string | Uint8Array
/**
* A row returned from a SQLite query, mapping column names to their values.
* @public
*/
export type TLSqliteRow = Record<string, TLSqliteOutputValue>
/**
* A prepared statement that can be executed multiple times with different bindings.
* @public
*/
export interface TLSyncSqliteStatement<
TResult extends TLSqliteRow | void,
TParams extends TLSqliteInputValue[] = [],
> {
/** Execute the statement and iterate over results one at a time */
iterate(...bindings: TParams): IterableIterator<TResult>
/** Execute the statement and return all results as an array */
all(...bindings: TParams): TResult[]
/** Execute the statement without returning results (for DML) */
run(...bindings: TParams): void
}
/**
* Configuration for SQLiteSyncStorage.
* @public
*/
export interface TLSyncSqliteWrapperConfig {
/** Prefix for all table names (default: ''). E.g. 'sync_' creates tables 'sync_documents', 'sync_tombstones', 'sync_metadata' */
tablePrefix?: string
}
/**
* Interface for SQLite storage with prepare, exec and transaction capabilities.
* @public
*/
export interface TLSyncSqliteWrapper {
/** Optional configuration for table names. If not provided, defaults are used. */
readonly config?: TLSyncSqliteWrapperConfig
/** Prepare a SQL statement for execution */
prepare<TResult extends TLSqliteRow | void, TParams extends TLSqliteInputValue[] = []>(
sql: string
): TLSyncSqliteStatement<TResult, TParams>
/** Execute raw SQL (for DDL, multi-statement scripts) */
exec(sql: string): void
/** Execute a callback within a transaction */
transaction<T>(callback: () => T): T
}
export function migrateSqliteSyncStorage(
storage: TLSyncSqliteWrapper,
{
documentsTable = 'documents',
objectsTable = 'objects',
tombstonesTable = 'tombstones',
metadataTable = 'metadata',
}: {
documentsTable?: string
objectsTable?: string
tombstonesTable?: string
metadataTable?: string
} = {}
): void {
let migrationVersion = 0
try {
const row = storage
.prepare<{
migrationVersion: number
}>(`SELECT migrationVersion FROM ${metadataTable} LIMIT 1`)
.all()[0]
migrationVersion = row?.migrationVersion ?? 0
} catch (_e) {
// noop
}
if (migrationVersion === 0) {
migrationVersion++
storage.exec(`
CREATE TABLE ${documentsTable} (
id TEXT PRIMARY KEY,
state BLOB NOT NULL,
lastChangedClock INTEGER NOT NULL
);
CREATE INDEX idx_${documentsTable}_lastChangedClock ON ${documentsTable}(lastChangedClock);
CREATE TABLE ${tombstonesTable} (
id TEXT PRIMARY KEY,
clock INTEGER NOT NULL
);
CREATE INDEX idx_${tombstonesTable}_clock ON ${tombstonesTable}(clock);
-- This table is used to store the metadata for the sync storage.
-- There should only be one row in this table.
CREATE TABLE ${metadataTable} (
migrationVersion INTEGER NOT NULL,
documentClock INTEGER NOT NULL,
tombstoneHistoryStartsAtClock INTEGER NOT NULL,
schema TEXT NOT NULL
);
INSERT INTO ${metadataTable} (migrationVersion, documentClock, tombstoneHistoryStartsAtClock, schema) VALUES (2, 0, 0, '')
`)
// Skip migration 2 since we created the table with BLOB already
migrationVersion++
}
if (migrationVersion === 1) {
// Migration 2: Convert state column from TEXT to BLOB
// SQLite doesn't support ALTER COLUMN, so we need to recreate the table
migrationVersion++
storage.exec(`
CREATE TABLE ${documentsTable}_new (
id TEXT PRIMARY KEY,
state BLOB NOT NULL,
lastChangedClock INTEGER NOT NULL
);
INSERT INTO ${documentsTable}_new (id, state, lastChangedClock)
SELECT id, CAST(state AS BLOB), lastChangedClock FROM ${documentsTable};
DROP TABLE ${documentsTable};
ALTER TABLE ${documentsTable}_new RENAME TO ${documentsTable};
CREATE INDEX idx_${documentsTable}_lastChangedClock ON ${documentsTable}(lastChangedClock);
`)
}
if (migrationVersion === 2) {
// Migration 3: Add the object-store lane table. Object-lane records (e.g. comments) are
// partitioned out of the documents table so the document snapshot never contains them.
// They share the documents' clock and tombstones.
migrationVersion++
storage.exec(`
CREATE TABLE ${objectsTable} (
id TEXT PRIMARY KEY,
state BLOB NOT NULL,
lastChangedClock INTEGER NOT NULL
);
CREATE INDEX idx_${objectsTable}_lastChangedClock ON ${objectsTable}(lastChangedClock);
`)
}
// add more migrations here if and when needed
storage.exec(`UPDATE ${metadataTable} SET migrationVersion = ${migrationVersion}`)
}
const textEncoder = new TextEncoder()
const textDecoder = new TextDecoder()
function encodeState(state: unknown): Uint8Array {
return textEncoder.encode(JSON.stringify(state))
}
function decodeState<T>(state: Uint8Array): T {
return JSON.parse(textDecoder.decode(state))
}
/**
* SQLite-based implementation of TLSyncStorage.
* Stores documents, tombstones, metadata, and clock values in SQLite tables.
*
* This storage backend provides persistent synchronization state that survives
* process restarts, unlike InMemorySyncStorage which loses data when the process ends.
*
* @example
* ```ts
* // With Cloudflare Durable Objects
* import { SQLiteSyncStorage, DurableObjectSqliteSyncWrapper } from '@tldraw/sync-core'
*
* const sql = new DurableObjectSqliteSyncWrapper(this.ctx.storage)
* const storage = new SQLiteSyncStorage({ sql })
* ```
*
* @example
* ```ts
* // With Node.js sqlite (Node 22.5+)
* import { DatabaseSync } from 'node:sqlite'
* import { SQLiteSyncStorage, NodeSqliteWrapper } from '@tldraw/sync-core'
*
* const db = new DatabaseSync('sync-state.db')
* const sql = new NodeSqliteWrapper(db)
* const storage = new SQLiteSyncStorage({ sql })
* ```
*
* @example
* ```ts
* // Initialize with an existing snapshot
* const storage = new SQLiteSyncStorage({ sql, snapshot: existingSnapshot })
* ```
*
* @public
*/
export class SQLiteSyncStorage<R extends UnknownRecord> implements TLSyncStorage<R> {
/**
* Check if the storage has been initialized (has data in the clock table).
* Useful for determining whether to load from an external source on first access.
*/
static hasBeenInitialized(storage: TLSyncSqliteWrapper): boolean {
const prefix = storage.config?.tablePrefix ?? ''
try {
const schema = storage
.prepare<{ schema: string }>(`SELECT schema FROM ${prefix}metadata LIMIT 1`)
.all()[0]?.schema
return !!schema
} catch (_e) {
return false
}
}
/**
* Get the current document clock value from storage without fully initializing.
* Returns null if storage has not been initialized.
* Useful for comparing storage freshness against external sources.
*/
static getDocumentClock(storage: TLSyncSqliteWrapper): number | null {
const prefix = storage.config?.tablePrefix ?? ''
try {
const row = storage
.prepare<{ documentClock: number }>(`SELECT documentClock FROM ${prefix}metadata LIMIT 1`)
.all()[0]
// documentClock exists but could be 0, so we check if the storage is initialized
if (row && SQLiteSyncStorage.hasBeenInitialized(storage)) {
return row.documentClock
}
return null
} catch (_e) {
return null
}
}
// Prepared statements - created once, reused many times
private readonly stmts
private readonly sql: TLSyncSqliteWrapper
/** @internal */
readonly objectTypes: ReadonlySet<string>
constructor({
sql,
snapshot,
objectTypes,
onChange,
}: {
sql: TLSyncSqliteWrapper
snapshot?: RoomSnapshot | StoreSnapshot<R>
/**
* Record type names stored in the object-store lane. Records of these types are routed to
* a separate table: excluded from `getSnapshot()`, returned by `getObjectsSnapshot()`.
* They share the documents' clock, tombstones, and transactions.
*/
objectTypes?: readonly string[]
onChange?(arg: TLSyncStorageOnChangeCallbackProps): unknown
}) {
this.sql = sql
this.objectTypes = new Set(objectTypes ?? [])
const prefix = sql.config?.tablePrefix ?? ''
const documentsTable = `${prefix}documents`
const objectsTable = `${prefix}objects`
const tombstonesTable = `${prefix}tombstones`
const metadataTable = `${prefix}metadata`
migrateSqliteSyncStorage(this.sql, {
documentsTable,
objectsTable,
tombstonesTable,
metadataTable,
})
// Both record tables have identical shape; prepare the same statements for each.
const makeRecordTableStmts = (table: string) => ({
get: this.sql.prepare<{ state: Uint8Array }, [id: string]>(
`SELECT state FROM ${table} WHERE id = ?`
),
getWithClock: this.sql.prepare<{ state: Uint8Array; lastChangedClock: number }, [id: string]>(
`SELECT state, lastChangedClock FROM ${table} WHERE id = ?`
),
insert: this.sql.prepare<void, [id: string, state: Uint8Array, lastChangedClock: number]>(
`INSERT OR REPLACE INTO ${table} (id, state, lastChangedClock) VALUES (?, ?, ?)`
),
delete: this.sql.prepare<void, [id: string]>(`DELETE FROM ${table} WHERE id = ?`),
exists: this.sql.prepare<{ id: string }, [id: string]>(
`SELECT id FROM ${table} WHERE id = ?`
),
iterate: this.sql.prepare<{ state: Uint8Array; lastChangedClock: number }>(
`SELECT state, lastChangedClock FROM ${table}`
),
iterateEntries: this.sql.prepare<{ id: string; state: Uint8Array }>(
`SELECT id, state FROM ${table}`
),
iterateKeys: this.sql.prepare<{ id: string }>(`SELECT id FROM ${table}`),
iterateValues: this.sql.prepare<{ state: Uint8Array }>(`SELECT state FROM ${table}`),
changedSince: this.sql.prepare<{ state: Uint8Array }, [sinceClock: number]>(
`SELECT state FROM ${table} WHERE lastChangedClock > ?`
),
})
// Prepare all statements once
this.stmts = {
// Metadata
getDocumentClock: this.sql.prepare<{ documentClock: number }>(
`SELECT documentClock FROM ${metadataTable} LIMIT 1`
),
getTombstoneHistoryStartsAtClock: this.sql.prepare<{ tombstoneHistoryStartsAtClock: number }>(
`SELECT tombstoneHistoryStartsAtClock FROM ${metadataTable}`
),
getSchema: this.sql.prepare<{ schema: string }>(`SELECT schema FROM ${metadataTable}`),
setSchema: this.sql.prepare<void, [schema: string]>(`UPDATE ${metadataTable} SET schema = ?`),
setTombstoneHistoryStartsAtClock: this.sql.prepare<void, [clock: number]>(
`UPDATE ${metadataTable} SET tombstoneHistoryStartsAtClock = ?`
),
incrementDocumentClock: this.sql.prepare<void>(
`UPDATE ${metadataTable} SET documentClock = documentClock + 1`
),
// Record tables (document lane + object-store lane)
documents: makeRecordTableStmts(documentsTable),
objects: makeRecordTableStmts(objectsTable),
// Tombstones
insertTombstone: this.sql.prepare<void, [id: string, clock: number]>(
`INSERT OR REPLACE INTO ${tombstonesTable} (id, clock) VALUES (?, ?)`
),
deleteTombstone: this.sql.prepare<void, [id: string]>(
`DELETE FROM ${tombstonesTable} WHERE id = ?`
),
deleteTombstonesBefore: this.sql.prepare<void, [clock: number]>(
`DELETE FROM ${tombstonesTable} WHERE clock < ?`
),
countTombstones: this.sql.prepare<{ count: number }>(
`SELECT count(*) as count FROM ${tombstonesTable}`
),
iterateTombstones: this.sql.prepare<{ id: string; clock: number }>(
`SELECT id, clock FROM ${tombstonesTable} ORDER BY clock ASC`
),
getTombstonesChangedSince: this.sql.prepare<{ id: string }, [sinceClock: number]>(
`SELECT id FROM ${tombstonesTable} WHERE clock > ?`
),
// Initial setup (only used when loading a snapshot)
updateMetadata: this.sql.prepare<
void,
[documentClock: number, tombstoneHistoryStartsAtClock: number, schema: string]
>(
`UPDATE ${metadataTable} SET documentClock = ?, tombstoneHistoryStartsAtClock = ?, schema = ?`
),
}
// Check if we already have data
const hasData = SQLiteSyncStorage.hasBeenInitialized(sql)
if (snapshot || !hasData) {
snapshot = convertStoreSnapshotToRoomSnapshot(snapshot ?? DEFAULT_INITIAL_SNAPSHOT)
const documentClock = snapshot.documentClock ?? snapshot.clock ?? 0
const tombstoneHistoryStartsAtClock = snapshot.tombstoneHistoryStartsAtClock ?? documentClock
this.sql.exec(`
DELETE FROM ${documentsTable};
DELETE FROM ${objectsTable};
DELETE FROM ${tombstonesTable};
`)
// Insert records, routing object-lane types into their partition (a seed snapshot may
// carry object-lane records merged in, e.g. loaded from a separate persistence lane)
for (const doc of snapshot.documents) {
const table = this.objectTypes.has(doc.state.typeName)
? this.stmts.objects
: this.stmts.documents
table.insert.run(doc.state.id, encodeState(doc.state), doc.lastChangedClock)
}
if (snapshot.tombstones) {
for (const [id, clock] of objectMapEntries(snapshot.tombstones)) {
this.stmts.insertTombstone.run(id, clock)
}
}
this.stmts.updateMetadata.run(
documentClock,
tombstoneHistoryStartsAtClock,
JSON.stringify(snapshot.schema)
)
} else {
// One-time sweep: rooms written before the objects table existed may have object-lane
// records sitting in the documents table. Record ids are typeName-prefixed, so a PK
// range scan per object type moves them cheaply (no-op once clean).
for (const typeName of this.objectTypes) {
const lo = `${typeName}:`
// ';' is the next code point after ':' — [lo, hi) covers exactly the `type:` prefix
const hi = `${typeName};`
this.sql.exec(`
INSERT OR REPLACE INTO ${objectsTable} (id, state, lastChangedClock)
SELECT id, state, lastChangedClock FROM ${documentsTable}
WHERE id >= '${lo}' AND id < '${hi}';
DELETE FROM ${documentsTable} WHERE id >= '${lo}' AND id < '${hi}';
`)
}
}
if (onChange) {
this.onChange(onChange)
}
}
private notifier = new MicrotaskNotifier<[TLSyncStorageOnChangeCallbackProps]>()
onChange(callback: (arg: TLSyncStorageOnChangeCallbackProps) => void): () => void {
return this.notifier.register(callback)
}
transaction<T>(
callback: TLSyncStorageTransactionCallback<R, T>,
opts?: TLSyncStorageTransactionOptions
): TLSyncStorageTransactionResult<T, R> {
const clockBefore = this.getClock()
const trackChanges = opts?.emitChanges === 'always'
return this.sql.transaction(() => {
const txn = new SQLiteSyncStorageTransaction<R>(this, this.stmts)
let result: T
let changes: TLSyncForwardDiff<R> | undefined
try {
result = transaction(() => {
return callback(txn)
}) as T
if (trackChanges) {
changes = txn.getChangesSince(clockBefore)?.diff
}
} finally {
txn.close()
}
if (
typeof result === 'object' &&
result &&
'then' in result &&
typeof result.then === 'function'
) {
throw new Error('Transaction must return a value, not a promise')
}
const clockAfter = this.getClock()
const didChange = clockAfter > clockBefore
if (didChange) {
this.notifier.notify({ id: opts?.id, documentClock: clockAfter })
}
return { documentClock: clockAfter, didChange: clockAfter > clockBefore, result, changes }
})
}
getClock(): number {
const clockRow = this.stmts.getDocumentClock.all()[0]
return clockRow?.documentClock ?? 0
}
/** @internal */
_getTombstoneHistoryStartsAtClock(): number {
const clockRow = this.stmts.getTombstoneHistoryStartsAtClock.all()[0]
return clockRow?.tombstoneHistoryStartsAtClock ?? 0
}
/** @internal */
_getSchema(): SerializedSchema {
const clockRow = this.stmts.getSchema.all()[0]
assert(clockRow, 'Storage not initialized - clock row missing')
return JSON.parse(clockRow.schema)
}
/** @internal */
_setSchema(schema: SerializedSchema): void {
this.stmts.setSchema.run(JSON.stringify(schema))
}
/** @internal */
pruneTombstones = throttle(
() => {
// Runs from a timer, so a host that closed the database right after the last delete
// (e.g. `room.close(); db.close()` in onSessionRemoved) would otherwise get an uncaught
// throw from here; pruning is best-effort and simply runs again on the next delete.
try {
const tombstoneCount = this.stmts.countTombstones.all()[0].count as number
if (tombstoneCount > MAX_TOMBSTONES) {
// Get all tombstones sorted by clock ascending (oldest first)
const tombstones = this.stmts.iterateTombstones.all()
const result = computeTombstonePruning({ tombstones, documentClock: this.getClock() })
if (result) {
this.sql.transaction(() => {
this.stmts.setTombstoneHistoryStartsAtClock.run(
result.newTombstoneHistoryStartsAtClock
)
// Delete all tombstones with clock < newTombstoneHistoryStartsAtClock in one operation.
// This works because computeTombstonePruning ensures we never split a clock value.
this.stmts.deleteTombstonesBefore.run(result.newTombstoneHistoryStartsAtClock)
})
}
}
} catch (e) {
console.error('Failed to prune tombstones', e)
}
},
1000,
// prevent this from running synchronously to avoid blocking requests
{ leading: false }
)
getSnapshot(): RoomSnapshot {
// object-lane records live in their own table, so the document snapshot is
// pure-document by construction
return {
tombstoneHistoryStartsAtClock: this._getTombstoneHistoryStartsAtClock(),
documentClock: this.getClock(),
documents: Array.from(this._iterateRecords(this.stmts.documents)),
tombstones: Object.fromEntries(this._iterateTombstones()),
schema: this._getSchema(),
}
}
getObjectsSnapshot(): RoomSnapshot['documents'] {
return Array.from(this._iterateRecords(this.stmts.objects))
}
getObjectsByIds(ids: Iterable<string>): RoomSnapshot['documents'] {
const result: RoomSnapshot['documents'] = []
const seen = new Set<string>()
for (const id of ids) {
if (seen.has(id)) continue
seen.add(id)
const row = this.stmts.objects.getWithClock.all(id)[0]
if (!row) continue
result.push({ state: decodeState<R>(row.state), lastChangedClock: row.lastChangedClock })
}
return result
}
private *_iterateRecords(
table: SQLiteSyncStorage<R>['stmts']['documents']
): IterableIterator<{ state: R; lastChangedClock: number }> {
for (const row of table.iterate.iterate()) {
yield { state: decodeState<R>(row.state), lastChangedClock: row.lastChangedClock }
}
}
private *_iterateTombstones(): IterableIterator<[string, number]> {
for (const row of this.stmts.iterateTombstones.iterate()) {
yield [row.id, row.clock]
}
}
}
/**
* Transaction implementation for SQLiteSyncStorage.
* Provides access to documents, tombstones, and metadata within a transaction.
*
* @internal
*/
class SQLiteSyncStorageTransaction<R extends UnknownRecord> implements TLSyncStorageTransaction<R> {
private _clock: number
private _closed = false
private _didIncrementClock: boolean = false
constructor(
private storage: SQLiteSyncStorage<R>,
private stmts: SQLiteSyncStorage<R>['stmts']
) {
this._clock = this.storage.getClock()
}
/** @internal */
close() {
this._closed = true
}
private assertNotClosed() {
assert(!this._closed, 'Transaction has ended, iterator cannot be consumed')
}
getClock(): number {
return this._clock
}
private getNextClock(): number {
if (!this._didIncrementClock) {
this._didIncrementClock = true
this.stmts.incrementDocumentClock.run()
this._clock = this.storage.getClock()
}
return this._clock
}
/**
* Which record table an id belongs to. Record ids are typeName-prefixed (`comment:abc`),
* so the partition is derivable from the id alone.
*/
private tableFor(id: string) {
const sep = id.indexOf(':')
if (sep === -1) return this.stmts.documents
return this.storage.objectTypes.has(id.slice(0, sep))
? this.stmts.objects
: this.stmts.documents
}
get(id: string): R | undefined {
this.assertNotClosed()
const row = this.tableFor(id).get.all(id)[0]
if (!row) return undefined
return decodeState<R>(row.state)
}
set(id: string, record: R): void {
this.assertNotClosed()
assert(id === record.id, `Record id mismatch: key does not match record.id`)
const clock = this.getNextClock()
// Automatically clear tombstone if it exists
this.stmts.deleteTombstone.run(id)
// route by type: object-lane records live in their own table
const table = this.storage.objectTypes.has(record.typeName)
? this.stmts.objects
: this.stmts.documents
table.insert.run(id, encodeState(record), clock)
}
delete(id: string): void {
this.assertNotClosed()
const table = this.tableFor(id)
// Only create a tombstone if the record actually exists
const exists = table.exists.all(id)[0]
if (!exists) return
const clock = this.getNextClock()
table.delete.run(id)
this.stmts.insertTombstone.run(id, clock)
this.storage.pruneTombstones()
}
// iteration spans both record tables so schema migrations cover object-lane records too
*entries(): IterableIterator<[string, R]> {
this.assertNotClosed()
for (const table of [this.stmts.documents, this.stmts.objects]) {
for (const row of table.iterateEntries.iterate()) {
this.assertNotClosed()
yield [row.id, decodeState<R>(row.state)]
}
}
}
*keys(): IterableIterator<string> {
this.assertNotClosed()
for (const table of [this.stmts.documents, this.stmts.objects]) {
for (const row of table.iterateKeys.iterate()) {
this.assertNotClosed()
yield row.id
}
}
}
*values(): IterableIterator<R> {
this.assertNotClosed()
for (const table of [this.stmts.documents, this.stmts.objects]) {
for (const row of table.iterateValues.iterate()) {
this.assertNotClosed()
yield decodeState<R>(row.state)
}
}
}
getSchema(): SerializedSchema {
this.assertNotClosed()
return this.storage._getSchema()
}
setSchema(schema: SerializedSchema): void {
this.assertNotClosed()
this.storage._setSchema(schema)
}
getChangesSince(sinceClock: number): TLSyncStorageGetChangesSinceResult<R> | undefined {
this.assertNotClosed()
const clock = this.storage.getClock()
if (sinceClock === clock) return undefined
if (sinceClock > clock) {
// something went wrong, wipe the slate clean
sinceClock = -1
}
const diff: TLSyncForwardDiff<R> = { puts: {}, deletes: [] }
const wipeAll = sinceClock < this.storage._getTombstoneHistoryStartsAtClock()
// both record tables share the clock and tombstones, so the change feed spans both
if (wipeAll) {
// If wipeAll, include all records
for (const table of [this.stmts.documents, this.stmts.objects]) {
for (const row of table.iterateValues.iterate()) {
const state = decodeState<R>(row.state)
diff.puts[state.id] = state
}
}
} else {
// Get records changed since clock
for (const table of [this.stmts.documents, this.stmts.objects]) {
for (const row of table.changedSince.iterate(sinceClock)) {
const state = decodeState<R>(row.state)
diff.puts[state.id] = state
}
}
// When wipeAll, deletes are redundant (full state is in puts). Only include tombstones otherwise.
for (const row of this.stmts.getTombstonesChangedSince.iterate(sinceClock)) {
diff.deletes.push(row.id)
}
}
return { diff, wipeAll }
}
}

View File

@@ -0,0 +1,89 @@
import { UnknownRecord } from '@tldraw/store'
import { describe, expect, it, vi } from 'vitest'
import { TLSocketServerSentEvent } from './protocol'
import { ServerSocketAdapter, WebSocketMinimal } from './ServerSocketAdapter'
class MockWebSocket implements WebSocketMinimal {
readyState = 1 // OPEN
send = vi.fn()
close = vi.fn()
}
const pongMessage: TLSocketServerSentEvent<UnknownRecord> = { type: 'pong' }
describe('ServerSocketAdapter', () => {
it('[SA1] isOpen is true exactly when the wrapped socket readyState is 1', () => {
const ws = new MockWebSocket()
const adapter = new ServerSocketAdapter({ ws })
ws.readyState = 0 // CONNECTING
expect(adapter.isOpen).toBe(false)
ws.readyState = 1 // OPEN
expect(adapter.isOpen).toBe(true)
ws.readyState = 2 // CLOSING
expect(adapter.isOpen).toBe(false)
ws.readyState = 3 // CLOSED
expect(adapter.isOpen).toBe(false)
})
it('[SA2] sendMessage JSON-stringifies the message and sends it', () => {
const ws = new MockWebSocket()
const adapter = new ServerSocketAdapter({ ws })
const message: TLSocketServerSentEvent<UnknownRecord> = {
type: 'connect',
hydrationType: 'wipe_all',
connectRequestId: 'test-request-123',
protocolVersion: 1,
schema: { schemaVersion: 1, storeVersion: 0, recordVersions: {} },
diff: {},
serverClock: 0,
isReadonly: false,
}
adapter.sendMessage(message)
expect(ws.send).toHaveBeenCalledTimes(1)
expect(ws.send).toHaveBeenCalledWith(JSON.stringify(message))
})
it('[SA2] sendMessage invokes onBeforeSendMessage with the message and its stringified form before sending', () => {
const ws = new MockWebSocket()
const callOrder: string[] = []
const onBeforeSendMessage = vi.fn(() => {
callOrder.push('callback')
})
ws.send.mockImplementation(() => {
callOrder.push('send')
})
const adapter = new ServerSocketAdapter({ ws, onBeforeSendMessage })
adapter.sendMessage(pongMessage)
expect(onBeforeSendMessage).toHaveBeenCalledTimes(1)
expect(onBeforeSendMessage).toHaveBeenCalledWith(pongMessage, JSON.stringify(pongMessage))
expect(ws.send).toHaveBeenCalledTimes(1)
expect(callOrder).toEqual(['callback', 'send'])
})
it('[SA3] close passes through to the wrapped socket without arguments', () => {
const ws = new MockWebSocket()
const adapter = new ServerSocketAdapter({ ws })
adapter.close()
expect(ws.close).toHaveBeenCalledTimes(1)
expect(ws.close).toHaveBeenCalledWith(undefined, undefined)
})
it('[SA3] close passes through the code and reason to the wrapped socket', () => {
const ws = new MockWebSocket()
const adapter = new ServerSocketAdapter({ ws })
adapter.close(1000)
expect(ws.close).toHaveBeenLastCalledWith(1000, undefined)
adapter.close(1001, 'Going away')
expect(ws.close).toHaveBeenLastCalledWith(1001, 'Going away')
})
})

View File

@@ -0,0 +1,171 @@
import { UnknownRecord } from '@tldraw/store'
import { TLSocketServerSentEvent } from './protocol'
import { TLRoomSocket } from './TLSyncRoom'
/**
* Minimal server-side WebSocket interface that is compatible with various WebSocket implementations.
* This interface abstracts over different WebSocket libraries and platforms to provide a consistent
* API for the ServerSocketAdapter.
*
* Supports:
* - The standard WebSocket interface (Cloudflare, Deno, some Node.js setups)
* - The 'ws' WebSocket interface (Node.js ws library)
* - The Bun.serve socket implementation
*
* @public
* @example
* ```ts
* // Standard WebSocket
* const standardWs: WebSocketMinimal = new WebSocket('ws://localhost:8080')
*
* // Node.js 'ws' library WebSocket
* import WebSocket from 'ws'
* const nodeWs: WebSocketMinimal = new WebSocket('ws://localhost:8080')
*
* // Bun WebSocket (in server context)
* // const bunWs: WebSocketMinimal = server.upgrade(request)
* ```
*/
export interface WebSocketMinimal {
/**
* Optional method to add event listeners for WebSocket events.
* Not all WebSocket implementations provide this method.
*
* @param type - The event type to listen for
* @param listener - The event handler function
*/
// eslint-disable-next-line tldraw/method-signature-style
addEventListener?: (type: 'message' | 'close' | 'error', listener: (event: any) => void) => void
/**
* Optional method to remove event listeners for WebSocket events.
* Not all WebSocket implementations provide this method.
*
* @param type - The event type to stop listening for
* @param listener - The event handler function to remove
*/
// eslint-disable-next-line tldraw/method-signature-style
removeEventListener?: (
type: 'message' | 'close' | 'error',
listener: (event: any) => void
) => void
/**
* Sends a string message through the WebSocket connection.
*
* @param data - The string data to send
*/
// eslint-disable-next-line tldraw/method-signature-style
send: (data: string) => void
/**
* Closes the WebSocket connection.
*
* @param code - Optional close code (default: 1000 for normal closure)
* @param reason - Optional human-readable close reason
*/
// eslint-disable-next-line tldraw/method-signature-style
close: (code?: number, reason?: string) => void
/**
* The current state of the WebSocket connection.
* - 0: CONNECTING
* - 1: OPEN
* - 2: CLOSING
* - 3: CLOSED
*/
readyState: number
}
/**
* Configuration options for creating a ServerSocketAdapter instance.
*
* @internal
*/
export interface ServerSocketAdapterOptions<R extends UnknownRecord> {
/** The underlying WebSocket connection to wrap */
readonly ws: WebSocketMinimal
/**
* Optional callback invoked before each message is sent to the client.
* Useful for logging, metrics, or message transformation.
*
* @param msg - The message object being sent
* @param stringified - The JSON stringified version of the message
*/
// eslint-disable-next-line tldraw/method-signature-style
readonly onBeforeSendMessage?: (msg: TLSocketServerSentEvent<R>, stringified: string) => void
}
/**
* Server-side adapter that wraps various WebSocket implementations to provide a consistent
* TLRoomSocket interface for the TLSyncRoom. This adapter handles the differences between
* WebSocket libraries and platforms, allowing sync-core to work across different server
* environments.
*
* The adapter implements the TLRoomSocket interface, providing methods for sending messages,
* checking connection status, and closing connections.
*
* @internal
* @example
* ```ts
* import { ServerSocketAdapter } from '@tldraw/sync-core'
*
* // Wrap a standard WebSocket
* const adapter = new ServerSocketAdapter({
* ws: webSocketConnection,
* onBeforeSendMessage: (msg, json) => {
* console.log('Sending:', msg.type)
* }
* })
*
* // Use with TLSyncRoom
* room.handleNewSession({
* sessionId: 'session-123',
* socket: adapter,
* isReadonly: false
* })
* ```
*/
export class ServerSocketAdapter<R extends UnknownRecord> implements TLRoomSocket<R> {
/**
* Creates a new ServerSocketAdapter instance.
*
* opts - Configuration options for the adapter
*/
constructor(public readonly opts: ServerSocketAdapterOptions<R>) {}
/**
* Checks if the underlying WebSocket connection is currently open and ready to send messages.
*
* @returns True if the connection is open (readyState === 1), false otherwise
*/
// eslint-disable-next-line tldraw/no-setter-getter
get isOpen(): boolean {
return this.opts.ws.readyState === 1 // ready state open
}
/**
* Sends a sync protocol message to the connected client. The message is JSON stringified
* before being sent through the WebSocket. If configured, the onBeforeSendMessage callback
* is invoked before sending.
*
* @param msg - The sync protocol message to send
*/
// see TLRoomSocket for details on why this accepts a union and not just arrays
sendMessage(msg: TLSocketServerSentEvent<R>) {
const message = JSON.stringify(msg)
this.opts.onBeforeSendMessage?.(msg, message)
this.opts.ws.send(message)
}
/**
* Closes the WebSocket connection with an optional close code and reason.
*
* @param code - Optional close code (default: 1000 for normal closure)
* @param reason - Optional human-readable reason for closing
*/
close(code?: number, reason?: string) {
this.opts.ws.close(code, reason)
}
}

View File

@@ -0,0 +1,58 @@
import { TLSyncErrorCloseEventReason } from './TLSyncClient'
/**
* Specialized error class for synchronization-related failures in tldraw collaboration.
*
* This error is thrown when the sync client encounters fatal errors that prevent
* successful synchronization with the server. It captures both the error message
* and the specific reason code that triggered the failure.
*
* Common scenarios include schema version mismatches, authentication failures,
* network connectivity issues, and server-side validation errors.
*
* @example
* ```ts
* import { TLRemoteSyncError, TLSyncErrorCloseEventReason } from '@tldraw/sync-core'
*
* // Handle sync errors in your application
* syncClient.onSyncError((error) => {
* if (error instanceof TLRemoteSyncError) {
* switch (error.reason) {
* case TLSyncErrorCloseEventReason.NOT_AUTHENTICATED:
* // Redirect user to login
* break
* case TLSyncErrorCloseEventReason.CLIENT_TOO_OLD:
* // Show update required message
* break
* default:
* console.error('Sync error:', error.message)
* }
* }
* })
* ```
*
* @example
* ```ts
* // Server-side: throwing a sync error
* if (!hasPermission(userId, roomId)) {
* throw new TLRemoteSyncError(TLSyncErrorCloseEventReason.FORBIDDEN)
* }
* ```
*
* @public
*/
export class TLRemoteSyncError extends Error {
override name = 'RemoteSyncError'
/**
* Creates a new TLRemoteSyncError with the specified reason.
*
* reason - The specific reason code or custom string describing why the sync failed.
* When using predefined reasons from TLSyncErrorCloseEventReason, the client
* can handle specific error types appropriately. Custom strings allow for
* application-specific error details.
*/
constructor(public readonly reason: TLSyncErrorCloseEventReason | string) {
super(`sync error: ${reason}`)
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,262 @@
import {
BaseRecord,
createMigrationSequence,
createRecordType,
RecordId,
StoreSchema,
} from '@tldraw/store'
import { TLDOCUMENT_ID, TLRecord } from '@tldraw/tlschema'
import { describe, expect, it, vi } from 'vitest'
import {
contractRecords,
contractSchema,
makeContractSnapshot,
makePage,
} from '../test/storageContractSuite'
import { InMemorySyncStorage } from './InMemorySyncStorage'
import {
convertStoreSnapshotToRoomSnapshot,
loadSnapshotIntoStorage,
toNetworkDiff,
TLSyncForwardDiff,
} from './TLSyncStorage'
describe('toNetworkDiff', () => {
const pageA = makePage('a', 'A')
const pageB = { ...makePage('a', 'A'), name: 'B' } as TLRecord
it('[ND2] maps plain puts, update tuples, and deletes to network ops', () => {
const diff: TLSyncForwardDiff<TLRecord> = {
puts: {
[contractRecords[0].id]: contractRecords[0],
[pageA.id]: [pageA, pageB],
},
deletes: ['shape:gone'],
}
expect(toNetworkDiff(diff)).toEqual({
[contractRecords[0].id]: ['put', contractRecords[0]],
[pageA.id]: ['patch', { name: ['put', 'B'] }],
'shape:gone': ['remove'],
})
})
it('[ND2] omits update tuples that compute to no diff', () => {
const diff: TLSyncForwardDiff<TLRecord> = {
puts: { [pageA.id]: [pageA, { ...pageA } as TLRecord] },
deletes: [],
}
expect(toNetworkDiff(diff)).toEqual({})
})
it('[ND2] returns an (possibly empty) object, never null', () => {
expect(toNetworkDiff({ puts: {}, deletes: [] })).toEqual({})
})
})
describe('convertStoreSnapshotToRoomSnapshot', () => {
it('[SL1] passes a RoomSnapshot through by reference', () => {
const roomSnapshot = makeContractSnapshot(contractRecords, { documentClock: 42 })
expect(convertStoreSnapshotToRoomSnapshot(roomSnapshot)).toBe(roomSnapshot)
})
it('[SL1] converts a StoreSnapshot to a clock-zero room snapshot', () => {
const storeSnapshot = {
store: {
[TLDOCUMENT_ID]: contractRecords[0],
[contractRecords[1].id]: contractRecords[1],
} as any,
schema: contractSchema.serialize(),
}
expect(convertStoreSnapshotToRoomSnapshot(storeSnapshot)).toEqual({
clock: 0,
documentClock: 0,
documents: [
{ state: contractRecords[0], lastChangedClock: 0 },
{ state: contractRecords[1], lastChangedClock: 0 },
],
schema: storeSnapshot.schema,
tombstones: {},
})
})
})
describe('loadSnapshotIntoStorage', () => {
it('[SL2] throws when the snapshot has no schema', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const storage = new InMemorySyncStorage<TLRecord>({
snapshot: makeContractSnapshot(contractRecords),
})
expect(() =>
storage.transaction((txn) => {
loadSnapshotIntoStorage(txn, contractSchema, { documents: [], clock: 0 } as any)
})
).toThrow('Schema is required')
consoleSpy.mockRestore()
})
it('[SL3] writes new and changed documents and tombstones the rest', () => {
const extraPage = makePage('extra', 'Extra')
const storage = new InMemorySyncStorage<TLRecord>({
snapshot: makeContractSnapshot([...contractRecords, extraPage]),
})
const changedPage = { ...contractRecords[1], name: 'Renamed' } as TLRecord
const newSnapshot = makeContractSnapshot([contractRecords[0], changedPage])
storage.transaction((txn) => {
loadSnapshotIntoStorage(txn, contractSchema, newSnapshot)
})
expect(storage.documents.get(contractRecords[1].id)?.state).toEqual(changedPage)
expect(storage.documents.has(extraPage.id)).toBe(false)
expect(storage.tombstones.has(extraPage.id)).toBe(true)
})
it('[SL3] skips documents that are deep-equal to the stored state', () => {
const storage = new InMemorySyncStorage<TLRecord>({
snapshot: makeContractSnapshot(contractRecords, { documentClock: 5 }),
})
storage.transaction((txn) => {
loadSnapshotIntoStorage(txn, contractSchema, makeContractSnapshot(contractRecords))
})
// nothing changed, so the clock did not advance
expect(storage.getClock()).toBe(5)
})
it('[SL3] does not delete while a key iterator is open (better-sqlite3 rejects that)', () => {
const extraPage = makePage('extra', 'Extra')
const storage = new InMemorySyncStorage<TLRecord>({
snapshot: makeContractSnapshot([...contractRecords, extraPage]),
})
storage.transaction((txn) => {
// better-sqlite3 throws on any write while a statement iterator is still open; mimic
// that so a delete issued from inside `for (const id of txn.keys())` fails loudly
let openIterators = 0
const guarded: typeof txn = Object.create(txn, {
keys: {
value: function* () {
openIterators++
try {
yield* Array.from(txn.keys())
} finally {
openIterators--
}
},
},
delete: {
value: (id: string) => {
if (openIterators > 0) {
throw new Error('This database connection is busy executing a query')
}
return txn.delete(id)
},
},
})
loadSnapshotIntoStorage(guarded, contractSchema, makeContractSnapshot(contractRecords))
})
expect(storage.documents.has(extraPage.id)).toBe(false)
expect(storage.tombstones.has(extraPage.id)).toBe(true)
})
it('[SL4] persists the snapshot schema and migrates loaded records to the current version', () => {
interface TestRecord extends BaseRecord<'test', RecordId<TestRecord>> {
value: number
migrated?: boolean
}
const TestRecordType = createRecordType<TestRecord>('test', {
validator: { validate: (r) => r as TestRecord },
scope: 'document',
})
const migrations = createMigrationSequence({
sequenceId: 'com.test.record',
retroactive: true,
sequence: [
{
id: 'com.test.record/1',
scope: 'record',
filter: (r: any) => r.typeName === 'test',
up: (record: any) => {
record.migrated = true
},
},
],
})
const oldSchema = StoreSchema.create({ test: TestRecordType })
const newSchema = StoreSchema.create({ test: TestRecordType }, { migrations: [migrations] })
const storage = new InMemorySyncStorage<TestRecord>({
snapshot: {
documents: [],
clock: 0,
documentClock: 0,
schema: newSchema.serialize(),
},
})
const record: TestRecord = { id: 'test:1' as RecordId<TestRecord>, typeName: 'test', value: 1 }
storage.transaction((txn) => {
loadSnapshotIntoStorage(txn, newSchema, {
documents: [{ state: record, lastChangedClock: 0 }],
clock: 0,
documentClock: 0,
schema: oldSchema.serialize(),
})
})
expect((storage.documents.get('test:1')!.state as TestRecord).migrated).toBe(true)
// the schema was migrated up to the current version too
expect(storage.schema.get()).toEqual(newSchema.serialize())
})
it('[SL4] applies record migrations exactly once per record', () => {
const migrationCounts = new Map<string, number>()
interface TestRecord extends BaseRecord<'test', RecordId<TestRecord>> {
value: number
}
const TestRecordType = createRecordType<TestRecord>('test', {
validator: { validate: (r) => r as TestRecord },
scope: 'document',
})
const migrations = createMigrationSequence({
sequenceId: 'com.test.record',
retroactive: true,
sequence: [
{
id: 'com.test.record/1',
scope: 'record',
filter: (r: any) => r.typeName === 'test',
up: (record: any) => {
migrationCounts.set(record.id, (migrationCounts.get(record.id) ?? 0) + 1)
},
},
],
})
const oldSchema = StoreSchema.create({ test: TestRecordType })
const newSchema = StoreSchema.create({ test: TestRecordType }, { migrations: [migrations] })
const numRecords = 100
const testRecords: TestRecord[] = Array.from({ length: numRecords }, (_, i) => ({
id: `test:${i}` as RecordId<TestRecord>,
typeName: 'test' as const,
value: i,
}))
const storage = new InMemorySyncStorage<TestRecord>({
snapshot: {
documents: testRecords.map((r) => ({ state: r, lastChangedClock: 0 })),
clock: 0,
documentClock: 0,
schema: oldSchema.serialize(),
},
})
storage.transaction((txn) => {
newSchema.migrateStorage(txn)
})
expect(migrationCounts.size).toBe(numRecords)
expect([...migrationCounts.values()].every((count) => count === 1)).toBe(true)
})
})

View File

@@ -0,0 +1,233 @@
import { StoreSchema, SynchronousStorage, UnknownRecord } from '@tldraw/store'
import { TLStoreSnapshot } from '@tldraw/tlschema'
import { assert, isEqual, objectMapEntriesIterable, objectMapValues } from '@tldraw/utils'
import { diffRecord, NetworkDiff, RecordOpType } from './diff'
import { RoomSnapshot } from './TLSyncRoom'
/**
* Transaction interface for storage operations. Provides methods to read and modify
* documents, tombstones, and metadata within a transaction.
*
* @public
*/
export interface TLSyncStorageTransaction<R extends UnknownRecord> extends SynchronousStorage<R> {
/**
* Get the current clock value.
* If the clock has incremented during the transaction,
* the incremented value will be returned.
*
* @returns The current clock value
*/
getClock(): number
/**
* Get all changes (document updates and deletions) since a given clock time.
* This is the main method for calculating diffs for client sync.
*
* @param sinceClock - The clock time to get changes since
* @returns Changes since the specified clock time
*/
getChangesSince(sinceClock: number): TLSyncStorageGetChangesSinceResult<R> | undefined
}
/**
* Options for a transaction.
* @public
*/
export interface TLSyncStorageTransactionOptions {
/**
* Use this if you need to identify the transaction for logging or debugging purposes
* or for ignoring certain changes in onChange callbacks
*/
id?: string
/**
* Controls when the storage layer should emit the actual changes that occurred during the transaction.
*
* - `'always'` - Always emit the changes, regardless of whether they were applied verbatim
* - `'when-different'` - Only emit changes if the storage layer modified/embellished the records
* (e.g., added server timestamps, normalized data, etc.)
*
* When changes are emitted, they will be available in the `changes` field of the transaction result.
* This is useful when the storage layer may transform records and the caller needs to know
* what actually changed rather than what was requested.
*/
emitChanges?: 'always' | 'when-different'
}
/**
* Callback type for a transaction.
* The conditional return type ensures that the callback is synchronous.
* @public
*/
export type TLSyncStorageTransactionCallback<R extends UnknownRecord, T> = (
txn: TLSyncStorageTransaction<R>
) => T extends Promise<any>
? {
__error: 'Transaction callbacks cannot be async. Use synchronous operations only.'
}
: T
/**
* Pluggable synchronous transactional storage layer for TLSyncRoom.
* Provides methods for managing documents, tombstones, and clocks within transactions.
*
* @public
*/
export interface TLSyncStorage<R extends UnknownRecord> {
transaction<T>(
callback: TLSyncStorageTransactionCallback<R, T>,
opts?: TLSyncStorageTransactionOptions
): TLSyncStorageTransactionResult<T, R>
getClock(): number
onChange(callback: (arg: TLSyncStorageOnChangeCallbackProps) => unknown): () => void
/** A snapshot of the document lane only — never contains object-store lane records. */
getSnapshot?(): RoomSnapshot
/**
* A snapshot of the object-store lane (see `objectTypes` on the storage), for hosts that
* persist object-lane records separately from the document. Same entry shape as
* `RoomSnapshot['documents']` so a lane can be persisted and re-seeded via a merged snapshot.
*/
getObjectsSnapshot?(): RoomSnapshot['documents']
/**
* The same entries for the given ids, for callers that need a handful of records rather than the
* whole (parsed) lane. Ids with no object-lane record are omitted — document-lane ids included —
* and duplicates yield one entry.
*/
getObjectsByIds?(ids: Iterable<string>): RoomSnapshot['documents']
}
/**
* Properties passed to the onChange callback.
* @public
*/
export interface TLSyncStorageOnChangeCallbackProps {
/**
* The ID of the transaction that caused the change.
* This is useful for ignoring certain changes in onChange callbacks.
*/
id?: string
documentClock: number
}
/**
* Result returned from a storage transaction.
* @public
*/
export interface TLSyncStorageTransactionResult<T, R extends UnknownRecord = UnknownRecord> {
documentClock: number
didChange: boolean
result: T
/**
* The actual changes that occurred during the transaction, if requested via `emitChanges` option.
* This is a RecordsDiff where:
* - `added` contains records that were put (we don't have "from" state for emitted changes)
* - `removed` contains records that were deleted (with placeholder values since we only have IDs)
* - `updated` is empty (emitted changes don't track before/after pairs)
*
* Only populated when:
* - `emitChanges: 'always'` was specified, or
* - `emitChanges: 'when-different'` was specified and the storage layer modified records
*/
changes?: TLSyncForwardDiff<R>
}
/**
* Respresents a diff of puts and deletes.
* @public
*/
export interface TLSyncForwardDiff<R extends UnknownRecord> {
puts: Record<string, R | [before: R, after: R]>
deletes: string[]
}
/**
* @internal
*/
export function toNetworkDiff<R extends UnknownRecord>(diff: TLSyncForwardDiff<R>): NetworkDiff<R> {
const networkDiff: NetworkDiff<R> = {}
for (const [id, put] of objectMapEntriesIterable(diff.puts)) {
if (Array.isArray(put)) {
const patch = diffRecord(put[0], put[1])
if (patch) {
networkDiff[id] = [RecordOpType.Patch, patch]
}
} else {
networkDiff[id] = [RecordOpType.Put, put]
}
}
for (const id of diff.deletes) {
networkDiff[id] = [RecordOpType.Remove]
}
return networkDiff
}
/**
* Result returned from getChangesSince, containing all changes since a given clock time.
* @public
*/
export interface TLSyncStorageGetChangesSinceResult<R extends UnknownRecord> {
/**
* The changes as a TLSyncForwardDiff.
*/
diff: TLSyncForwardDiff<R>
/**
* If true, the client should wipe all local data and replace with the server's state.
* This happens when the client's clock is too old and we've lost tombstone history.
*/
wipeAll: boolean
}
/**
* Loads a snapshot into storage during a transaction.
* Migrates the snapshot to the current schema and loads it into storage.
*
* @public
* @param txn - The transaction to load the snapshot into
* @param schema - The current schema
* @param snapshot - The snapshot to load
*/
export function loadSnapshotIntoStorage<R extends UnknownRecord>(
txn: TLSyncStorageTransaction<R>,
schema: StoreSchema<R, any>,
snapshot: RoomSnapshot | TLStoreSnapshot
) {
snapshot = convertStoreSnapshotToRoomSnapshot(snapshot)
assert(snapshot.schema, 'Schema is required')
const docIds = new Set<string>()
for (const doc of snapshot.documents) {
docIds.add(doc.state.id)
const existing = txn.get(doc.state.id)
if (isEqual(existing, doc.state)) continue
txn.set(doc.state.id, doc.state as R)
}
// materialize the keys first: some SQLite drivers (better-sqlite3) refuse writes while a
// statement iterator is open, so deleting during `keys()` would throw
for (const id of Array.from(txn.keys())) {
if (!docIds.has(id)) {
txn.delete(id)
}
}
txn.setSchema(snapshot.schema)
schema.migrateStorage(txn)
}
export function convertStoreSnapshotToRoomSnapshot(
snapshot: RoomSnapshot | TLStoreSnapshot
): RoomSnapshot {
if ('documents' in snapshot) return snapshot
return {
clock: 0,
documentClock: 0,
documents: objectMapValues(snapshot.store).map((state) => ({
state,
lastChangedClock: 0,
})),
schema: snapshot.schema,
tombstones: {},
}
}

View File

@@ -0,0 +1,459 @@
import { assert } from 'tldraw'
import {
JsonChunkAssembler,
MAX_ASSEMBLED_MESSAGE_CHARS,
MAX_CHUNK_COUNT,
MessageTooLargeError,
chunk,
} from './chunk'
describe('chunk (CH1–CH3)', () => {
describe('size boundary (CH1)', () => {
it('[CH1] returns the message as a single chunk when its length is strictly less than maxSize', () => {
expect(chunk('hello', 100)).toEqual(['hello'])
expect(chunk('x'.repeat(9), 10)).toEqual(['x'.repeat(9)])
})
it('[CH1] chunks a message whose length equals maxSize', () => {
const msg = 'x'.repeat(10)
const result = chunk(msg, 10)
expect(result.length).toBeGreaterThan(1)
expect(result[0]).toMatch(/^\d+_x+$/)
})
it('[CH1] handles empty strings', () => {
expect(chunk('', 100)).toEqual([''])
})
it('[CH1] handles single character strings', () => {
expect(chunk('a', 100)).toEqual(['a'])
})
it('[CH1] uses the default max size when none is provided', () => {
const longString = 'x'.repeat(262145) // larger than the 262144 default
const result = chunk(longString)
expect(result.length).toBeGreaterThan(1)
expect(result[0]).toMatch(/^\d+_x+$/)
})
})
describe('chunk format and ordering (CH2)', () => {
it('[CH2] prefixes chunks with a countdown and puts the start of the message first', () => {
expect(chunk('hello there my good world', 5)).toMatchInlineSnapshot(`
[
"8_h",
"7_ell",
"6_o t",
"5_her",
"4_e m",
"3_y g",
"2_ood",
"1_ wo",
"0_rld",
]
`)
expect(chunk('hello there my good world', 10)).toMatchInlineSnapshot(`
[
"3_h",
"2_ello the",
"1_re my go",
"0_od world",
]
`)
})
it('[CH2] concatenating the chunk bodies in order reconstructs the message', () => {
const original = 'hello world this is a long message'
const reconstructed = chunk(original, 8)
.map((c) => /^(\d+)_(.*)$/s.exec(c)![2])
.join('')
expect(reconstructed).toEqual(original)
})
it('[CH2] preserves unicode characters across chunk boundaries', () => {
const unicodeString = '🎨'.repeat(10) + '📐'.repeat(10) + '🗼️'.repeat(10)
const result = chunk(unicodeString, 20)
expect(result.length).toBeGreaterThan(1)
const reconstructed = result.map((c) => /^(\d+)_(.*)$/s.exec(c)![2]).join('')
expect(reconstructed).toEqual(unicodeString)
})
it('[CH2] reconstructs very large strings', () => {
const veryLargeString = 'a'.repeat(1000000) // 1MB string
const result = chunk(veryLargeString, 10000)
expect(result.length).toBeGreaterThan(1)
const reconstructed = result.map((c) => /^(\d+)_(.*)$/s.exec(c)![2]).join('')
expect(reconstructed).toEqual(veryLargeString)
})
})
describe('chunk size limits (CH3)', () => {
it('[CH3] keeps each chunk at or below maxSize', () => {
const maxSize = 15
for (const c of chunk('hello world this is a long message', maxSize)) {
expect(c.length).toBeLessThanOrEqual(maxSize)
}
expect(chunk('dark and stormy tonight', 4)).toMatchInlineSnapshot(`
[
"12_d",
"11_a",
"10_r",
"9_k ",
"8_an",
"7_d ",
"6_st",
"5_or",
"4_my",
"3_ t",
"2_on",
"1_ig",
"0_ht",
]
`)
})
it('[CH3] carries at least one content character per chunk even when the prefix alone exceeds maxSize', () => {
const chunks = chunk('once upon a time', 1)
expect(chunks).toMatchInlineSnapshot(`
[
"15_o",
"14_n",
"13_c",
"12_e",
"11_ ",
"10_u",
"9_p",
"8_o",
"7_n",
"6_ ",
"5_a",
"4_ ",
"3_t",
"2_i",
"1_m",
"0_e",
]
`)
})
})
describe('surrogate pairs (CH9)', () => {
it('[CH9] never splits a surrogate pair across two chunks', () => {
// WebSocket.send converts each chunk to a USVString, so a lone surrogate would arrive
// as U+FFFD and the reassembled message would carry corrupted text
const emoji = '\u{1F600}'
const isHigh = (code: number) => code >= 0xd800 && code <= 0xdbff
const isLow = (code: number) => code >= 0xdc00 && code <= 0xdfff
const msgs = [
...Array.from(
{ length: 8 },
(_, padding) => 'x'.repeat(padding) + emoji + 'y'.repeat(6) + emoji
),
// adjacent pairs: after a nudge the next boundary lands on another pair
'x' + emoji.repeat(3),
]
for (const msg of msgs) {
for (const maxSize of [3, 4, 5, 6, 7, 8]) {
const chunks = chunk(msg, maxSize)
const bodies = chunks.map((c) => c.slice(c.indexOf('_') + 1))
expect(bodies.join('')).toBe(msg)
for (const [i, body] of bodies.entries()) {
expect(body.length).toBeGreaterThan(0)
expect(isHigh(body.charCodeAt(body.length - 1))).toBe(false)
expect(isLow(body.charCodeAt(0))).toBe(false)
// keeping a pair whole may cost one character over the CH3 bound, never more
expect(chunks[i].length).toBeLessThanOrEqual(
Math.max(maxSize, chunks[i].indexOf('_') + 2) + 1
)
}
}
}
})
})
})
const testObject = {} as any
for (let i = 0; i < 1000; i++) {
testObject['key_' + i] = 'value_' + i
}
describe('JsonChunkAssembler (CH4–CH8)', () => {
describe('whole JSON messages while idle (CH4)', () => {
it('[CH4] parses a message starting with { immediately and returns data and stringified', () => {
const unchunker = new JsonChunkAssembler()
expect(unchunker.handleMessage('{"key": "value"}')).toMatchObject({
data: { key: 'value' },
stringified: '{"key": "value"}',
})
})
it('[CH4] handles an empty JSON object', () => {
const unchunker = new JsonChunkAssembler()
expect(unchunker.handleMessage('{}')).toMatchObject({ data: {}, stringified: '{}' })
})
it('[CH4] throws synchronously on invalid JSON starting with {', () => {
const unchunker = new JsonChunkAssembler()
expect(() => unchunker.handleMessage('{not valid json')).toThrow()
// the assembler is still usable afterwards
expect(unchunker.handleMessage('{"ok": true}')).toMatchObject({ data: { ok: true } })
})
})
describe('JSON messages mid-sequence (CH5)', () => {
it('[CH5] returns an error when a { message interrupts a chunk sequence and resets to idle', () => {
const unchunker = new JsonChunkAssembler()
// start a chunk sequence
expect(unchunker.handleMessage('2_hello')).toBeNull()
// interrupt with a JSON message: both the partial sequence and this
// message are discarded
const result = unchunker.handleMessage('{"interrupt": true}')
assert(result && 'error' in result, 'expected error result')
expect(result.error.message).toBe('Unexpected non-chunk message')
// the assembler reset to idle, so the next message works
expect(unchunker.handleMessage('{"ok": true}')).toMatchObject({ data: { ok: true } })
})
it('[CH5] returns an error when the chunk stream ends abruptly', () => {
const chunks = chunk('{"hello": world"}', 10)
const unchunker = new JsonChunkAssembler()
expect(unchunker.handleMessage(chunks[0])).toBeNull()
expect(unchunker.handleMessage(chunks[1])).toBeNull()
const res = unchunker.handleMessage('{"hello": "world"}')
assert(res, 'expected a result')
assert('error' in res, 'expected an error')
expect(res.error.message).toBe('Unexpected non-chunk message')
// and the next one should be fine
expect(unchunker.handleMessage('{"ok": true}')).toMatchObject({ data: { ok: true } })
})
})
describe('chunk accumulation (CH6)', () => {
it.each([1, 5, 20, 200])(
'[CH6] returns null until the final chunk, then the parsed json (split at %s bytes)',
(size) => {
const chunks = chunk(JSON.stringify(testObject), size)
const unchunker = new JsonChunkAssembler()
for (const c of chunks.slice(0, -1)) {
expect(unchunker.handleMessage(c)).toBeNull()
}
expect(unchunker.handleMessage(chunks[chunks.length - 1])).toMatchObject({
data: testObject,
})
// resets to idle: the next one should be fine
expect(unchunker.handleMessage('{"ok": true}')).toMatchObject({ data: { ok: true } })
}
)
it('[CH6] handles complex nested JSON objects', () => {
const complexObject = {
array: [1, 2, { nested: true }],
null: null,
boolean: false,
number: 3.14,
string: 'hello world',
unicode: '🎨🗼️📐',
deep: {
nested: {
object: {
with: 'many levels',
},
},
},
}
const chunks = chunk(JSON.stringify(complexObject), 50)
const unchunker = new JsonChunkAssembler()
for (const c of chunks.slice(0, -1)) {
expect(unchunker.handleMessage(c)).toBeNull()
}
expect(unchunker.handleMessage(chunks[chunks.length - 1])).toMatchObject({
data: complexObject,
})
})
it('[CH6] handles a single chunk with a 0_ prefix', () => {
const unchunker = new JsonChunkAssembler()
expect(unchunker.handleMessage('0_{"single": "chunk"}')).toMatchObject({
data: { single: 'chunk' },
stringified: '{"single": "chunk"}',
})
})
it('[CH6] handles chunks with empty data parts', () => {
const unchunker = new JsonChunkAssembler()
expect(unchunker.handleMessage('1_')).toBeNull() // empty first chunk
expect(unchunker.handleMessage('0_{"test": true}')).toMatchObject({ data: { test: true } })
})
it('[CH6] returns an error when the assembled json is invalid, then resets to idle', () => {
const chunks = chunk('{"hello": world"}', 5)
const unchunker = new JsonChunkAssembler()
for (const c of chunks.slice(0, -1)) {
expect(unchunker.handleMessage(c)).toBeNull()
}
const node18Error = `Unexpected token w in JSON at position 10`
const node20Error = `Unexpected token 'w', "\\{"hello": world"}" is not valid JSON`
const res = unchunker.handleMessage(chunks[chunks.length - 1])
assert(res, 'expected a result')
assert('error' in res, 'expected an error')
expect(res.error.message).toMatch(new RegExp(`${node18Error}|${node20Error}`))
// and the next one should be fine
expect(unchunker.handleMessage('{"ok": true}')).toMatchObject({ data: { ok: true } })
})
it('[CH6] returns an error object when chunks form invalid json', () => {
const unchunker = new JsonChunkAssembler()
expect(unchunker.handleMessage('1_{"invalid":')).toBeNull()
const result = unchunker.handleMessage('0_ }')
assert(result && 'error' in result, 'expected error result')
expect(result.error).toBeInstanceOf(Error)
})
})
describe('malformed chunk sequences (CH7)', () => {
it('[CH7] returns an error when a chunk is missing from the sequence, then resets to idle', () => {
const chunks = chunk('{"hello": world"}', 10)
const unchunker = new JsonChunkAssembler()
expect(unchunker.handleMessage(chunks[0])).toBeNull()
const res = unchunker.handleMessage(chunks[2])
assert(res, 'expected a result')
assert('error' in res, 'expected an error')
expect(res.error.message).toBe('Chunks received in wrong order')
// and the next one should be fine
expect(unchunker.handleMessage('{"ok": true}')).toMatchObject({ data: { ok: true } })
})
it('[CH7] returns an error for duplicate or out-of-order chunk numbers', () => {
const unchunker = new JsonChunkAssembler()
expect(unchunker.handleMessage('2_part1')).toBeNull()
// the countdown should be 1 here, not 0
const result = unchunker.handleMessage('0_part3')
assert(result && 'error' in result, 'expected error result')
expect(result.error.message).toBe('Chunks received in wrong order')
})
it('[CH7] returns an invalid chunk error for non-json, non-chunk messages', () => {
const unchunker = new JsonChunkAssembler()
const res = unchunker.handleMessage('["yo"]')
assert(res, 'expected a result')
assert('error' in res, 'expected an error')
expect(res.error.message).toContain('Invalid chunk')
// and the next one should be fine
expect(unchunker.handleMessage('{"ok": true}')).toMatchObject({ data: { ok: true } })
})
it('[CH7] returns an invalid chunk error for a malformed chunk number', () => {
const unchunker = new JsonChunkAssembler()
const result = unchunker.handleMessage('abc_invalid_number')
assert(result && 'error' in result, 'expected error result')
expect(result.error.message).toContain('Invalid chunk')
})
it('[CH7] resets to idle after an invalid chunk mid-sequence', () => {
const unchunker = new JsonChunkAssembler()
// start a chunk sequence
expect(unchunker.handleMessage('1_hello')).toBeNull()
// send a malformed chunk to trigger the error
const result = unchunker.handleMessage('invalid_chunk_format')
assert(result && 'error' in result, 'expected error result')
expect(result.error.message).toContain('Invalid chunk')
// normal messages work again
expect(unchunker.handleMessage('{"test": true}')).toMatchObject({ data: { test: true } })
})
})
describe('chunk body contents (CH8)', () => {
it('[CH8] handles unicode line separators U+2028 and U+2029 in chunks', () => {
// These characters are common when copy/pasting from Microsoft Word
// and are valid JSON string content but act as line terminators in JS
const unchunker = new JsonChunkAssembler()
const jsonWithLineSeparators = '{"text": "hello\u2028world\u2029end"}'
const chunks = chunk(jsonWithLineSeparators, 10)
for (const c of chunks.slice(0, -1)) {
expect(unchunker.handleMessage(c)).toBeNull()
}
expect(unchunker.handleMessage(chunks[chunks.length - 1])).toMatchObject({
data: { text: 'hello\u2028world\u2029end' },
})
})
})
describe('assembly bounds (CH10)', () => {
it('[CH10] rejects a first chunk declaring more than MAX_CHUNK_COUNT chunks', () => {
const unchunker = new JsonChunkAssembler()
const result = unchunker.handleMessage(`${MAX_CHUNK_COUNT}_{"a":`)
expect(result).toMatchObject({
error: expect.objectContaining({ message: `Too many chunks: ${MAX_CHUNK_COUNT + 1}` }),
})
expect(unchunker.state).toBe('idle')
})
it('[CH10] accepts a declared count at the limit', () => {
const unchunker = new JsonChunkAssembler()
expect(unchunker.handleMessage(`${MAX_CHUNK_COUNT - 1}_{"a":`)).toBeNull()
expect(unchunker.state).not.toBe('idle')
})
it('[CH10] rejects an assembly once its accumulated bodies pass MAX_ASSEMBLED_MESSAGE_CHARS', () => {
const unchunker = new JsonChunkAssembler()
// A chunk stream that never sends its final chunk. Before CH10 this accumulated
// without limit for as long as the sender kept sending.
const body = 'x'.repeat(1024 * 1024)
const chunksNeeded = Math.ceil(MAX_ASSEMBLED_MESSAGE_CHARS / body.length) + 1
let result = null
for (let i = 0; i < chunksNeeded; i++) {
result = unchunker.handleMessage(`${chunksNeeded - i}_${body}`)
if (result) break
}
expect(result).toMatchObject({ error: expect.any(MessageTooLargeError) })
expect(unchunker.state).toBe('idle')
})
it('[CH10] leaves the assembler usable after rejecting an oversized assembly', () => {
const unchunker = new JsonChunkAssembler()
const body = 'x'.repeat(1024 * 1024)
const chunksNeeded = Math.ceil(MAX_ASSEMBLED_MESSAGE_CHARS / body.length) + 1
for (let i = 0; i < chunksNeeded; i++) {
if (unchunker.handleMessage(`${chunksNeeded - i}_${body}`)) break
}
expect(unchunker.handleMessage('{"test": true}')).toMatchObject({ data: { test: true } })
})
})
})

View File

@@ -0,0 +1,216 @@
// quarter of a megabyte, max possible utf-8 string size
// cloudflare workers only accept messages of max 1mb
const MAX_CLIENT_SENT_MESSAGE_SIZE_BYTES = 1024 * 1024
// utf-8 is max 4 bytes per char
const MAX_BYTES_PER_CHAR = 4
// in the (admittedly impossible) worst case, the max size is 1/4 of a megabyte
const MAX_SAFE_MESSAGE_SIZE = MAX_CLIENT_SENT_MESSAGE_SIZE_BYTES / MAX_BYTES_PER_CHAR
// JsonChunkAssembler holds every chunk of a message until the last one arrives, and the sender
// decides how many that is — the count is just a prefix on the first chunk. Nothing in the
// protocol obliges a sender to ever send the last chunk, so without a ceiling a single connection
// can make the receiver hold an arbitrarily large buffer for as long as the socket stays open.
//
// The cap counts UTF-16 code units, which is what a JS string costs in memory: 16M of them is the
// 32MB of retained string data a room can afford to hold per session and stay inside a Durable
// Object's 128MB instance limit with room for the document itself. It is far above anything
// chunk() produces for a real document push. The character cap is the real bound; the chunk-count
// cap only rejects an absurd declared count on arrival instead of waiting for the characters to
// accumulate, and is loose enough not to constrain a sender that chunks more finely than the
// default.
export const MAX_ASSEMBLED_MESSAGE_CHARS = 16 * 1024 * 1024
export const MAX_CHUNK_COUNT = 100_000
/**
* Thrown when a message would exceed MAX_ASSEMBLED_MESSAGE_CHARS, on either side of the wire.
* Distinct from the other assembly errors because it is fatal: retrying sends the same
* oversized message, so the session is rejected rather than reset.
*
* @internal
*/
export class MessageTooLargeError extends Error {
constructor(chars: number) {
super(`Message too large: ${chars} characters, max ${MAX_ASSEMBLED_MESSAGE_CHARS}`)
this.name = 'MessageTooLargeError'
}
}
/**
* Splits a string into smaller chunks suitable for transmission over WebSockets.
* This function ensures messages don't exceed size limits imposed by platforms like Cloudflare Workers (1MB max).
* Each chunk is prefixed with a number indicating how many more chunks follow.
*
* @param msg - The string to split into chunks
* @param maxSafeMessageSize - Maximum safe size for each chunk in characters. Defaults to quarter megabyte to account for UTF-8 encoding
* @returns Array of chunked strings, each prefixed with "\{number\}_" where number indicates remaining chunks
*
* @example
* ```ts
* // Small message - returns as single chunk
* chunk('hello world') // ['hello world']
*
* // Large message - splits into multiple chunks
* chunk('very long message...', 10)
* // ['2_very long', '1_ message', '0_...']
* ```
*
* @internal
*/
export function chunk(msg: string, maxSafeMessageSize = MAX_SAFE_MESSAGE_SIZE) {
if (msg.length < maxSafeMessageSize) {
return [msg]
} else {
const chunks = []
let chunkNumber = 0
let offset = msg.length
while (offset > 0) {
const prefix = `${chunkNumber}_`
let chunkSize = Math.max(Math.min(maxSafeMessageSize - prefix.length, offset), 1)
// Never split a surrogate pair across chunks: WebSocket.send converts each chunk to a
// USVString, turning a lone surrogate into U+FFFD, so the reassembled JSON silently
// carries corrupted text. Shrink by one so the pair moves whole into the preceding
// chunk; a single-character chunk grows by one instead (one over the CH3 bound, see CH9).
if (
chunkSize < offset &&
isLowSurrogate(msg.charCodeAt(offset - chunkSize)) &&
isHighSurrogate(msg.charCodeAt(offset - chunkSize - 1))
) {
chunkSize += chunkSize > 1 ? -1 : 1
}
chunks.unshift(prefix + msg.slice(offset - chunkSize, offset))
offset -= chunkSize
chunkNumber++
}
return chunks
}
}
function isHighSurrogate(code: number) {
return code >= 0xd800 && code <= 0xdbff
}
function isLowSurrogate(code: number) {
return code >= 0xdc00 && code <= 0xdfff
}
// The 's' flag (dotAll) makes '.' match any character including line terminators
// like U+2028 and U+2029, which are commonly introduced via copy/paste from Word
const chunkRe = /^(\d+)_(.*)$/s
/**
* Assembles chunked JSON messages back into complete objects.
* Handles both regular JSON messages and chunked messages created by the chunk() function.
* Maintains internal state to track partially received chunked messages.
*
* @example
* ```ts
* const assembler = new JsonChunkAssembler()
*
* // Handle regular JSON message
* const result1 = assembler.handleMessage('{"hello": "world"}')
* // Returns: { data: { hello: "world" }, stringified: '{"hello": "world"}' }
*
* // Handle chunked message
* assembler.handleMessage('1_hello') // Returns: null (partial)
* const result2 = assembler.handleMessage('0_ world')
* // Returns: { data: "hello world", stringified: "hello world" }
* ```
*
* @public
*/
export class JsonChunkAssembler {
/**
* Current assembly state - either 'idle' or tracking chunks being received
*/
state:
| 'idle'
| {
chunksReceived: string[]
totalChunks: number
charsReceived: number
} = 'idle'
/**
* Processes a single message, which can be either a complete JSON object or a chunk.
* For complete JSON objects (starting with '\{'), parses immediately.
* For chunks (prefixed with "\{number\}_"), accumulates until all chunks received.
*
* @param msg - The message to process, either JSON or chunk format
* @returns Result object with data/stringified on success, error object on failure, or null for incomplete chunks
* - `\{ data: object, stringified: string \}` - Successfully parsed complete message
* - `\{ error: Error \}` - Parse error, invalid chunk sequence, or a message exceeding
* MAX_ASSEMBLED_MESSAGE_CHARS / MAX_CHUNK_COUNT
* - `null` - Chunk received but more chunks expected
*
* @example
* ```ts
* const assembler = new JsonChunkAssembler()
*
* // Complete JSON message
* const result = assembler.handleMessage('{"key": "value"}')
* if (result && 'data' in result) {
* console.log(result.data) // { key: "value" }
* }
*
* // Chunked message sequence
* assembler.handleMessage('2_hel') // null - more chunks expected
* assembler.handleMessage('1_lo ') // null - more chunks expected
* assembler.handleMessage('0_wor') // { data: "hello wor", stringified: "hello wor" }
* ```
*/
handleMessage(msg: string): { error: Error } | { stringified: string; data: object } | null {
if (msg.startsWith('{')) {
const error = this.state === 'idle' ? undefined : new Error('Unexpected non-chunk message')
this.state = 'idle'
return error ? { error } : { data: JSON.parse(msg), stringified: msg }
} else {
const match = chunkRe.exec(msg)!
if (!match) {
this.state = 'idle'
return { error: new Error('Invalid chunk: ' + JSON.stringify(msg.slice(0, 20) + '...')) }
}
const numChunksRemaining = Number(match[1])
const data = match[2]
if (this.state === 'idle') {
const totalChunks = numChunksRemaining + 1
if (totalChunks > MAX_CHUNK_COUNT) {
// Nothing has been buffered yet, so the state is already idle.
return { error: new Error(`Too many chunks: ${totalChunks}`) }
}
this.state = {
chunksReceived: [data],
totalChunks,
charsReceived: data.length,
}
} else {
this.state.chunksReceived.push(data)
this.state.charsReceived += data.length
if (numChunksRemaining !== this.state.totalChunks - this.state.chunksReceived.length) {
this.state = 'idle'
return { error: new Error(`Chunks received in wrong order`) }
}
}
if (this.state.charsReceived > MAX_ASSEMBLED_MESSAGE_CHARS) {
// Drop what was buffered rather than keeping it until the socket closes.
const charsReceived = this.state.charsReceived
this.state = 'idle'
return { error: new MessageTooLargeError(charsReceived) }
}
if (this.state.chunksReceived.length === this.state.totalChunks) {
try {
const stringified = this.state.chunksReceived.join('')
const data = JSON.parse(stringified)
return { data, stringified }
} catch (e) {
return { error: e as Error }
} finally {
this.state = 'idle'
}
}
return null
}
}
}

View File

@@ -0,0 +1,885 @@
import {
applyObjectDiff,
diffRecord,
getNetworkDiff,
RecordOpType,
ValueOpType,
type ObjectDiff,
} from './diff'
describe('computing diffs (D)', () => {
describe('no changes (D1)', () => {
it('[D1] returns null when prev === next', () => {
const record = { id: 'test:1', x: 100, y: 200 }
expect(diffRecord(record, record)).toBeNull()
})
it('[D1] returns null when there is nothing to change', () => {
const prev = { id: 'test:1', x: 100, props: { color: 'red' }, arr: [1, 2, 3], text: 'hi' }
const next = { id: 'test:1', x: 100, props: { color: 'red' }, arr: [1, 2, 3], text: 'hi' }
expect(diffRecord(prev, next)).toBeNull()
})
})
describe('added and deleted keys (D2)', () => {
it('[D2] produces a delete op for keys missing from next', () => {
const a = { a: 1, b: 2, c: 3 }
const b = { a: 1, b: 2 }
const patch = diffRecord(a, b)
expect(patch).toEqual({ c: [ValueOpType.Delete] })
expect(applyObjectDiff(a, patch!)).toEqual(b)
})
it('[D2] produces a put op for keys added in next', () => {
const a = { a: 1, b: 2 }
const b = { a: 1, b: 2, c: 3 }
const patch = diffRecord(a, b)
expect(patch).toEqual({ c: [ValueOpType.Put, 3] })
expect(applyObjectDiff(a, patch!)).toEqual(b)
})
})
describe('top-level keys vs props/meta (D3)', () => {
it('[D3] puts changed top-level primitive values', () => {
const prev = { id: 'test:1', x: 100, y: 200 }
const next = { id: 'test:1', x: 150, y: 200 }
expect(diffRecord(prev, next)).toEqual({
x: [ValueOpType.Put, 150],
})
})
it('[D3] puts multiple changed top-level values', () => {
const prev = { id: 'test:1', x: 100, y: 200, rotation: 0 }
const next = { id: 'test:1', x: 150, y: 250, rotation: 45 }
expect(diffRecord(prev, next)).toEqual({
x: [ValueOpType.Put, 150],
y: [ValueOpType.Put, 250],
rotation: [ValueOpType.Put, 45],
})
})
it('[D3] a top-level key (not props/meta) holding a plain object gets a whole-value put, never a patch', () => {
const prev = { id: 'test:1', custom: { a: 1, b: 2 } }
const next = { id: 'test:1', custom: { a: 1, b: 3 } }
expect(diffRecord(prev, next)).toEqual({
custom: [ValueOpType.Put, { a: 1, b: 3 }],
})
})
it('[D3] a deep-equal top-level plain object produces no diff', () => {
const prev = { id: 'test:1', custom: { a: 1, b: 2 } }
const next = { id: 'test:1', custom: { a: 1, b: 2 } }
expect(diffRecord(prev, next)).toBeNull()
})
it('[D3] changes inside props are expressed as patch ops', () => {
const prev = { id: 'test:1', props: { color: 'red', size: 'medium' } }
const next = { id: 'test:1', props: { color: 'blue', size: 'medium' } }
expect(diffRecord(prev, next)).toEqual({
props: [ValueOpType.Patch, { color: [ValueOpType.Put, 'blue'] }],
})
})
it('[D3] changes inside meta are expressed as patch ops', () => {
const prev = { id: 'test:1', meta: { tag: 'a' } }
const next = { id: 'test:1', meta: { tag: 'b' } }
expect(diffRecord(prev, next)).toEqual({
meta: [ValueOpType.Patch, { tag: [ValueOpType.Put, 'b'] }],
})
})
it('[D3] handles null and undefined values at the top level', () => {
const prev = { id: 'test:1', optional: 'value', nullable: null }
const next = { id: 'test:1', optional: undefined, nullable: 'value' }
const diff = diffRecord(prev, next)
expect(diff).toBeTruthy()
expect(diff!.optional).toEqual([ValueOpType.Put, undefined])
expect(diff!.nullable).toEqual([ValueOpType.Put, 'value'])
})
})
describe('nested diffs (D4)', () => {
it('[D4] recursively patches object values inside props', () => {
const prev = { id: 'test:1', props: { color: 'red' } }
const next = { id: 'test:1', props: { color: 'red', size: 'large' } }
expect(diffRecord(prev, next)).toEqual({
props: [ValueOpType.Patch, { size: [ValueOpType.Put, 'large'] }],
})
})
it('[D4] deletes keys removed inside props', () => {
const prev = { id: 'test:1', props: { color: 'red', size: 'large' } }
const next = { id: 'test:1', props: { color: 'red' } }
expect(diffRecord(prev, next)).toEqual({
props: [ValueOpType.Patch, { size: [ValueOpType.Delete] }],
})
})
it('[D4] puts primitive values when the props key holds a primitive', () => {
const prev = { id: 'shape:1', props: 'hello' }
const next = { id: 'shape:1', props: 'world' }
expect(diffRecord(prev, next)).toEqual({
props: [ValueOpType.Put, 'world'],
})
})
it('[D4] [D5] appends when the props key holds a growing string', () => {
const prev = { id: 'shape:1', props: 'hello' }
const next = { id: 'shape:1', props: 'hello world' }
expect(diffRecord(prev, next)).toEqual({
props: [ValueOpType.Append, ' world', 5],
})
})
it('[D4] puts number values when the props key holds a number', () => {
const prev = { id: 'shape:1', props: 42 }
const next = { id: 'shape:1', props: 100 }
expect(diffRecord(prev, next)).toEqual({
props: [ValueOpType.Put, 100],
})
})
it('[D4] still patches object values inside props normally', () => {
const prev = { id: 'shape:1', props: { color: 'red' } }
const next = { id: 'shape:1', props: { color: 'blue' } }
expect(diffRecord(prev, next)).toEqual({
props: [ValueOpType.Patch, { color: [ValueOpType.Put, 'blue'] }],
})
})
})
describe('string appends (D5)', () => {
it('[D5] top-level string fields outside props get append ops', () => {
expect(diffRecord({ id: 'a', text: 'hello' }, { id: 'a', text: 'hello world' })).toEqual({
text: [ValueOpType.Append, ' world', 5],
})
})
it('[D5] appends from an empty string', () => {
const prev = { text: '' }
const next = { text: 'Hello' }
expect(diffRecord(prev, next)).toEqual({
text: [ValueOpType.Append, 'Hello', 0],
})
})
it('[D5] uses put when the string is replaced rather than appended', () => {
const prev = { text: 'Hello' }
const next = { text: 'Goodbye' }
expect(diffRecord(prev, next)).toEqual({
text: [ValueOpType.Put, 'Goodbye'],
})
})
it('[D5] uses put when the string is shortened', () => {
const prev = { text: 'Hello world' }
const next = { text: 'Hello' }
expect(diffRecord(prev, next)).toEqual({
text: [ValueOpType.Put, 'Hello'],
})
})
it('[D5] produces no diff for identical strings', () => {
const prev = { text: 'Hello' }
const next = { text: 'Hello' }
expect(diffRecord(prev, next)).toBeNull()
})
it('[D5] handles large text appends', () => {
const prev = { text: 'Start' }
const longText = ' '.repeat(1000) + 'end'
const next = { text: 'Start' + longText }
expect(diffRecord(prev, next)).toEqual({
text: [ValueOpType.Append, longText, 5],
})
})
it('[D5] appends strings nested inside props', () => {
const prev = { id: 'test:1', props: { label: 'Hello' } }
const next = { id: 'test:1', props: { label: 'Hello world' } }
expect(diffRecord(prev, next)).toEqual({
props: [ValueOpType.Patch, { label: [ValueOpType.Append, ' world', 5] }],
})
})
it('[D5] combines string appends with other property changes', () => {
const prev = { text: 'Hello', x: 100 }
const next = { text: 'Hello world', x: 200 }
expect(diffRecord(prev, next)).toEqual({
text: [ValueOpType.Append, ' world', 5],
x: [ValueOpType.Put, 200],
})
})
it('[D5] legacyAppendMode turns string appends into puts', () => {
const prev = { id: 'a', text: 'hello' }
const next = { id: 'a', text: 'hello world' }
expect(diffRecord(prev, next, true)).toEqual({
text: [ValueOpType.Put, 'hello world'],
})
})
it('[D5] legacyAppendMode turns nested string appends into puts', () => {
const prev = { id: 'a', props: { label: 'hello' } }
const next = { id: 'a', props: { label: 'hello world' } }
expect(diffRecord(prev, next, true)).toEqual({
props: [ValueOpType.Patch, { label: [ValueOpType.Put, 'hello world'] }],
})
})
it('[D5] [D7] legacyAppendMode does not affect array appends', () => {
const prev = { arr: [1, 2, 3] }
const next = { arr: [1, 2, 3, 4, 5] }
expect(diffRecord(prev, next, true)).toEqual({
arr: [ValueOpType.Append, [4, 5], 3],
})
})
})
describe('same-length arrays (D6)', () => {
it('[D6] produces no op when no items changed', () => {
const prev = { arr: [1, 2, 3] }
const next = { arr: [1, 2, 3] }
expect(diffRecord(prev, next)).toBeNull()
})
it('[D6] patches when at most max(length/5, 1) items changed', () => {
const prev = { arr: [1, 2, 3, 4, 5] }
const next = { arr: [1, 9, 3, 4, 5] }
expect(diffRecord(prev, next)).toEqual({
arr: [ValueOpType.Patch, { '1': [ValueOpType.Put, 9] }],
})
})
it('[D6] puts the whole array when more than max(length/5, 1) items changed', () => {
// length 5 => threshold is max(1, 1) = 1, so 2 changes exceed it
const prev = { arr: [1, 2, 3, 4, 5] }
const next = { arr: [1, 9, 9, 4, 5] }
expect(diffRecord(prev, next)).toEqual({
arr: [ValueOpType.Put, [1, 9, 9, 4, 5]],
})
})
it('[D6] patches exactly at the threshold for longer arrays', () => {
// length 10 => threshold is max(10/5, 1) = 2, so 2 changes still patch
const prev = { arr: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] }
const next = { arr: [1, 20, 3, 4, 5, 6, 7, 80, 9, 10] }
expect(diffRecord(prev, next)).toEqual({
arr: [ValueOpType.Patch, { '1': [ValueOpType.Put, 20], '7': [ValueOpType.Put, 80] }],
})
})
it('[D6] puts the whole array just above the threshold for longer arrays', () => {
// length 10 => threshold 2, so 3 changes bail out to a whole-array put
const prev = { arr: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] }
const next = { arr: [1, 20, 30, 40, 5, 6, 7, 8, 9, 10] }
expect(diffRecord(prev, next)).toEqual({
arr: [ValueOpType.Put, [1, 20, 30, 40, 5, 6, 7, 8, 9, 10]],
})
})
it('[D6] puts the whole array when all items changed', () => {
const prev = { arr: [1, 2, 3, 4, 5] }
const next = { arr: [6, 7, 8, 9, 10] }
expect(diffRecord(prev, next)).toEqual({
arr: [ValueOpType.Put, [6, 7, 8, 9, 10]],
})
})
it('[D6] recursively diffs changed indexes when both items are truthy objects', () => {
const a = {
arr: [
{ a: 1, b: 2, c: 3 },
{ a: 4, b: 5, c: 6 },
],
}
const b = {
arr: [
{ a: 1, b: 2, c: 3 },
{ a: 4, b: 5, c: 7 },
],
}
expect(diffRecord(a, b)).toEqual({
arr: [ValueOpType.Patch, { '1': [ValueOpType.Patch, { c: [ValueOpType.Put, 7] }] }],
})
})
it('[D6] puts the whole array when too many object items changed', () => {
const a = {
arr: [
{ a: 1, b: 2, c: 3 },
{ a: 4, b: 5, c: 6 },
],
}
const b = {
arr: [
{ a: 1, b: 2, c: 5 },
{ a: 4, b: 5, c: 7 },
],
}
expect(diffRecord(a, b)).toEqual({
arr: [
ValueOpType.Put,
[
{ a: 1, b: 2, c: 5 },
{ a: 4, b: 5, c: 7 },
],
],
})
})
it('[D6] puts a changed index when the old item is falsy', () => {
const prev = { arr: [null, { a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }] }
const next = { arr: [{ z: 9 }, { a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }] }
expect(diffRecord(prev, next)).toEqual({
arr: [ValueOpType.Patch, { '0': [ValueOpType.Put, { z: 9 }] }],
})
})
it('[D6] puts a changed index when the new item is falsy', () => {
const prev = { arr: [{ z: 9 }, { a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }] }
const next = { arr: [null, { a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }] }
expect(diffRecord(prev, next)).toEqual({
arr: [ValueOpType.Patch, { '0': [ValueOpType.Put, null] }],
})
})
})
describe('different-length arrays (D7)', () => {
it('[D7] appends when the array grew with an unchanged prefix', () => {
const prev = { arr: [1, 2, 3] }
const next = { arr: [1, 2, 3, 4, 5] }
expect(diffRecord(prev, next)).toEqual({
arr: [ValueOpType.Append, [4, 5], 3],
})
})
it('[D7] appends from an empty array', () => {
const prev = { arr: [] }
const next = { arr: [1, 2, 3] }
expect(diffRecord(prev, next)).toEqual({
arr: [ValueOpType.Append, [1, 2, 3], 0],
})
})
it('[D7] puts the whole array on truncation', () => {
const prev = { arr: [1, 2, 3] }
const next = { arr: [1, 2] }
expect(diffRecord(prev, next)).toEqual({
arr: [ValueOpType.Put, [1, 2]],
})
})
it('[D7] puts the whole array when truncated to empty', () => {
const prev = { arr: [1, 2, 3] }
const next = { arr: [] }
expect(diffRecord(prev, next)).toEqual({
arr: [ValueOpType.Put, []],
})
})
it('[D7] puts the whole array when the shared prefix changed while growing', () => {
const prev = { arr: [1, 2, 3] }
const next = { arr: [1, 9, 3, 4, 5] }
expect(diffRecord(prev, next)).toEqual({
arr: [ValueOpType.Put, [1, 9, 3, 4, 5]],
})
})
it('[D7] puts the whole array when the prefix changed at the same growth point', () => {
const prev = { arr: [1, 2, 3] }
const next = { arr: [1, 3, 4] }
expect(diffRecord(prev, next)).toEqual({
arr: [ValueOpType.Put, [1, 3, 4]],
})
})
it('[D6] [D7] nested arrays are patchable at the end', () => {
const a = {
arr: [
[1, 2, 3],
[4, 5, 6],
],
}
const b = {
arr: [
[1, 2, 3],
[4, 5, 6, 7, 8],
],
}
expect(diffRecord(a, b)).toEqual({
arr: [ValueOpType.Patch, { '1': [ValueOpType.Append, [7, 8], 3] }],
})
})
it('[D6] [D7] nested arrays are patchable at the beginning', () => {
const a = {
arr: [
[1, 2, 3],
[4, 5, 6],
],
}
const b = {
arr: [
[1, 2, 3, 4, 5, 6],
[4, 5, 6],
],
}
expect(diffRecord(a, b)).toEqual({
arr: [ValueOpType.Patch, { '0': [ValueOpType.Append, [4, 5, 6], 3] }],
})
})
})
describe('complex scenarios', () => {
it('[D3] [D4] [D5] handles shape-like record updates', () => {
const prev = {
id: 'shape:123',
type: 'geo',
x: 100,
y: 200,
props: {
color: 'red',
size: 'medium',
geo: 'rectangle',
},
meta: {},
}
const next = {
id: 'shape:123',
type: 'geo',
x: 150,
y: 200,
props: {
color: 'blue',
size: 'medium',
geo: 'rectangle',
},
meta: { timestamp: Date.now() },
}
const diff = diffRecord(prev, next)
expect(diff).toBeTruthy()
expect(diff!.x).toEqual([ValueOpType.Put, 150])
expect(diff!.props).toEqual([ValueOpType.Patch, { color: [ValueOpType.Put, 'blue'] }])
expect(diff!.meta).toBeTruthy()
// Apply the diff and verify result
const result = applyObjectDiff(prev, diff!)
expect(result).toEqual(next)
})
})
})
describe('applying diffs (AD)', () => {
describe('immutability (AD1)', () => {
it('[AD1] never mutates its input and returns a new object when changes apply', () => {
const obj = { a: 1, b: 2 }
const diff: ObjectDiff = { a: [ValueOpType.Put, 5] }
const result = applyObjectDiff(obj, diff)
expect(result).not.toBe(obj)
expect(result).toEqual({ a: 5, b: 2 })
expect(obj).toEqual({ a: 1, b: 2 })
})
it('[AD1] returns the same reference when no op had an effect', () => {
const obj = { a: 1, b: 2 }
expect(applyObjectDiff(obj, {})).toBe(obj)
})
it('[AD1] [AD2] returns the same reference when a put matches the current value', () => {
const obj = { a: 1, nested: { x: 1 } }
const diff: ObjectDiff = {
a: [ValueOpType.Put, 1],
nested: [ValueOpType.Put, { x: 1 }],
}
expect(applyObjectDiff(obj, diff)).toBe(obj)
})
it('[AD1] unchanged nested values keep their identity in the copy', () => {
const obj = { changed: { x: 1 }, untouched: { y: 2 } }
const diff: ObjectDiff = {
changed: [ValueOpType.Patch, { x: [ValueOpType.Put, 100] }],
}
const result = applyObjectDiff(obj, diff)
expect(result).not.toBe(obj)
expect(result.untouched).toBe(obj.untouched)
expect(result.changed).not.toBe(obj.changed)
expect(result).toEqual({ changed: { x: 100 }, untouched: { y: 2 } })
})
it('[AD1] [AD7] unchanged array items keep their identity in the copy', () => {
const obj = { arr: [{ a: 1 }, { b: 2 }, { c: 3 }] }
const diff: ObjectDiff = {
arr: [ValueOpType.Patch, { '1': [ValueOpType.Patch, { b: [ValueOpType.Put, 20] }] }],
}
const result = applyObjectDiff(obj, diff)
expect(result.arr[1]).toEqual({ b: 20 })
expect(result.arr[0]).toBe(obj.arr[0])
expect(result.arr[2]).toBe(obj.arr[2])
})
})
describe('put ops (AD2)', () => {
it('[AD2] applies puts that change values and adds new keys', () => {
const obj = { a: 1, b: 2 }
const diff: ObjectDiff = {
a: [ValueOpType.Put, 10],
c: [ValueOpType.Put, 30],
}
const result = applyObjectDiff(obj, diff)
expect(result).toEqual({ a: 10, b: 2, c: 30 })
})
it('[AD2] skips puts whose value is deep-equal to the current value', () => {
const obj = { a: { deep: [1, 2, 3] } }
const diff: ObjectDiff = { a: [ValueOpType.Put, { deep: [1, 2, 3] }] }
const result = applyObjectDiff(obj, diff)
expect(result).toBe(obj)
expect(result.a).toBe(obj.a)
})
})
describe('append ops (AD3)', () => {
it('[AD3] applies string appends with a matching offset', () => {
const obj = { text: 'Hello' }
const diff: ObjectDiff = {
text: [ValueOpType.Append, ' world', 5],
}
const result = applyObjectDiff(obj, diff)
expect(result).toEqual({ text: 'Hello world' })
expect(result).not.toBe(obj)
})
it('[AD3] applies appends from an empty string', () => {
const obj = { text: '' }
const diff: ObjectDiff = {
text: [ValueOpType.Append, 'Hello', 0],
}
expect(applyObjectDiff(obj, diff)).toEqual({ text: 'Hello' })
})
it('[AD3] [AD7] applies array appends with a matching offset', () => {
const obj = { arr: [1, 2, 3] }
const diff: ObjectDiff = {
arr: [ValueOpType.Append, [4, 5], 3],
}
expect(applyObjectDiff(obj, diff)).toEqual({ arr: [1, 2, 3, 4, 5] })
})
it('[AD3] ignores append ops with a mismatched offset', () => {
const obj = { text: 'Hello' }
const diff: ObjectDiff = {
text: [ValueOpType.Append, ' world', 10], // wrong offset
}
expect(applyObjectDiff(obj, diff)).toBe(obj)
})
it('[AD3] ignores array append ops with a mismatched offset', () => {
const obj = { arr: [1, 2, 3] }
const diff: ObjectDiff = {
arr: [ValueOpType.Append, [4, 5], 5], // wrong offset
}
expect(applyObjectDiff(obj, diff)).toBe(obj)
})
it('[AD3] ignores append ops on a value of mismatched type', () => {
const obj = { text: 123 }
const diff: ObjectDiff = {
text: [ValueOpType.Append, ' world', 3],
}
expect(applyObjectDiff(obj, diff)).toBe(obj)
})
it('[AD3] ignores a string append op targeting an array', () => {
const obj = { value: [1, 2, 3] }
const diff: ObjectDiff = {
value: [ValueOpType.Append, 'abc', 3],
}
expect(applyObjectDiff(obj, diff)).toBe(obj)
})
it('[AD3] applies multiple append ops in one diff', () => {
const obj = { a: 'Hello', b: 'Foo' }
const diff: ObjectDiff = {
a: [ValueOpType.Append, ' world', 5],
b: [ValueOpType.Append, 'bar', 3],
}
expect(applyObjectDiff(obj, diff)).toEqual({ a: 'Hello world', b: 'Foobar' })
})
})
describe('patch ops (AD4)', () => {
it('[AD4] applies nested object patches', () => {
const obj = { a: 1, nested: { x: 10, y: 20 } }
const diff: ObjectDiff = {
nested: [ValueOpType.Patch, { x: [ValueOpType.Put, 100] }],
}
const result = applyObjectDiff(obj, diff)
expect(result).toEqual({ a: 1, nested: { x: 100, y: 20 } })
expect(result.nested).not.toBe(obj.nested)
})
it('[AD4] applies deeply nested patches', () => {
const obj = {
level1: {
level2: {
level3: { value: 'old' },
},
},
}
const diff: ObjectDiff = {
level1: [
ValueOpType.Patch,
{
level2: [
ValueOpType.Patch,
{
level3: [
ValueOpType.Patch,
{
value: [ValueOpType.Put, 'new'],
},
],
},
],
},
],
}
const result = applyObjectDiff(obj, diff)
expect(result.level1.level2.level3.value).toBe('new')
})
it('[AD4] ignores patches on missing keys', () => {
const obj = { a: 1 }
const diff: ObjectDiff = {
missing: [ValueOpType.Patch, { x: [ValueOpType.Put, 1] }],
}
expect(applyObjectDiff(obj, diff)).toBe(obj)
})
it('[AD4] ignores patches on primitive values', () => {
const obj = { a: 1, s: 'hello', n: null }
const diff: ObjectDiff = {
a: [ValueOpType.Patch, { x: [ValueOpType.Put, 1] }],
s: [ValueOpType.Patch, { x: [ValueOpType.Put, 1] }],
n: [ValueOpType.Patch, { x: [ValueOpType.Put, 1] }],
}
expect(applyObjectDiff(obj, diff)).toBe(obj)
})
})
describe('delete ops (AD5)', () => {
it('[AD5] removes a key when present', () => {
const obj = { a: 1, b: 2, c: 3 }
const diff: ObjectDiff = { b: [ValueOpType.Delete] }
const result = applyObjectDiff(obj, diff)
expect(result).toEqual({ a: 1, c: 3 })
expect('b' in result).toBe(false)
})
it('[AD5] deleting an absent key has no effect', () => {
const obj = { a: 1 }
const diff: ObjectDiff = { b: [ValueOpType.Delete] }
expect(applyObjectDiff(obj, diff)).toBe(obj)
})
})
describe('patching non-objects (AD6)', () => {
it('[AD6] patching null returns the input unchanged', () => {
const diff: ObjectDiff = { a: [ValueOpType.Put, 1] }
expect(applyObjectDiff(null as any, diff)).toBe(null)
})
it('[AD6] patching a primitive returns the input unchanged', () => {
const diff: ObjectDiff = { a: [ValueOpType.Put, 1] }
expect(applyObjectDiff('hello' as any, diff)).toBe('hello')
expect(applyObjectDiff(42 as any, diff)).toBe(42)
})
})
describe('arrays (AD7)', () => {
it('[AD7] clones arrays as arrays and indexes ops by numeric string keys', () => {
const obj = { arr: [{ a: 1 }, { b: 2 }, { c: 3 }] }
const diff: ObjectDiff = {
arr: [
ValueOpType.Patch,
{
'1': [ValueOpType.Patch, { b: [ValueOpType.Put, 20] }],
},
],
}
const result = applyObjectDiff(obj, diff)
expect(Array.isArray(result.arr)).toBe(true)
expect(result.arr).toEqual([{ a: 1 }, { b: 20 }, { c: 3 }])
expect(result.arr).not.toBe(obj.arr)
})
it('[AD7] puts values into array indexes by numeric string key', () => {
const obj = { arr: [1, 2, 3] }
const diff: ObjectDiff = {
arr: [ValueOpType.Patch, { '1': [ValueOpType.Put, 9] }],
}
const result = applyObjectDiff(obj, diff)
expect(Array.isArray(result.arr)).toBe(true)
expect(result.arr).toEqual([1, 9, 3])
})
})
})
describe('getNetworkDiff (ND1)', () => {
it('[ND1] returns null for an empty records diff', () => {
const diff = { added: {}, updated: {}, removed: {} }
expect(getNetworkDiff(diff)).toBeNull()
})
it('[ND1] maps added records to put ops', () => {
const record = { id: 'test:1', type: 'test', data: 'value' }
const diff = {
added: { 'test:1': record },
updated: {},
removed: {},
}
expect(getNetworkDiff(diff)).toEqual({
'test:1': [RecordOpType.Put, record],
})
})
it('[ND1] maps removed records to remove ops', () => {
const diff = {
added: {},
updated: {},
removed: { 'test:1': { id: 'test:1', type: 'test' } },
}
expect(getNetworkDiff(diff)).toEqual({
'test:1': [RecordOpType.Remove],
})
})
it('[ND1] maps updated records to patch ops computed with diffRecord', () => {
const prev = { id: 'test:1', type: 'test', x: 100, y: 200 }
const next = { id: 'test:1', type: 'test', x: 150, y: 200 }
const diff = {
added: {},
updated: { 'test:1': [prev, next] },
removed: {},
}
expect(getNetworkDiff(diff)).toEqual({
'test:1': [RecordOpType.Patch, { x: [ValueOpType.Put, 150] }],
})
})
it('[ND1] omits updated entries that compute to no diff, returning null when nothing remains', () => {
const record = { id: 'test:1', type: 'test', x: 100 }
const diff = {
added: {},
updated: { 'test:1': [record, record] },
removed: {},
}
expect(getNetworkDiff(diff)).toBeNull()
})
it('[ND1] handles mixed operations', () => {
const addedRecord = { id: 'test:1', type: 'test', data: 'new' }
const prevRecord = { id: 'test:2', type: 'test', x: 100 }
const nextRecord = { id: 'test:2', type: 'test', x: 200 }
const removedRecord = { id: 'test:3', type: 'test' }
const diff = {
added: { 'test:1': addedRecord },
updated: { 'test:2': [prevRecord, nextRecord] },
removed: { 'test:3': removedRecord },
}
expect(getNetworkDiff(diff)).toEqual({
'test:1': [RecordOpType.Put, addedRecord],
'test:2': [RecordOpType.Patch, { x: [ValueOpType.Put, 200] }],
'test:3': [RecordOpType.Remove],
})
})
it('[ND1] [D5] produces append patches for string growth in updates', () => {
const prev = { id: 'shape:1', type: 'text', text: 'Hello' }
const next = { id: 'shape:1', type: 'text', text: 'Hello world' }
const recordsDiff = {
added: {},
updated: { 'shape:1': [prev, next] },
removed: {},
}
expect(getNetworkDiff(recordsDiff)).toEqual({
'shape:1': [RecordOpType.Patch, { text: [ValueOpType.Append, ' world', 5] }],
})
})
})

View File

@@ -0,0 +1,402 @@
import { RecordsDiff, UnknownRecord } from '@tldraw/store'
import { isEqual, objectMapEntries, objectMapValues } from '@tldraw/utils'
/**
* Constants representing the types of operations that can be applied to records in network diffs.
* These operations describe how a record has been modified during synchronization.
*
* @internal
*/
export const RecordOpType = {
Put: 'put',
Patch: 'patch',
Remove: 'remove',
} as const
/**
* Union type of all possible record operation types.
*
* @internal
*/
export type RecordOpType = (typeof RecordOpType)[keyof typeof RecordOpType]
/**
* Represents a single operation to be applied to a record during synchronization.
*
* @param R - The record type being operated on
*
* @internal
*/
export type RecordOp<R extends UnknownRecord> =
| [typeof RecordOpType.Put, R]
| [typeof RecordOpType.Patch, ObjectDiff]
| [typeof RecordOpType.Remove]
/**
* A one-way (non-reversible) diff designed for small json footprint. These are mainly intended to
* be sent over the wire. Either as push requests from the client to the server, or as patch
* operations in the opposite direction.
*
* Each key in this object is the id of a record that has been added, updated, or removed.
*
* @internal
*/
export interface NetworkDiff<R extends UnknownRecord> {
[id: string]: RecordOp<R>
}
/**
* Converts a (reversible, verbose) RecordsDiff into a (non-reversible, concise) NetworkDiff
* suitable for transmission over the network. This function optimizes the diff representation
* for minimal bandwidth usage while maintaining all necessary change information.
*
* @param diff - The RecordsDiff containing added, updated, and removed records
* @returns A compact NetworkDiff for network transmission, or null if no changes exist
*
* @example
* ```ts
* const recordsDiff = {
* added: { 'shape:1': newShape },
* updated: { 'shape:2': [oldShape, updatedShape] },
* removed: { 'shape:3': removedShape }
* }
*
* const networkDiff = getNetworkDiff(recordsDiff)
* // Returns: {
* // 'shape:1': ['put', newShape],
* // 'shape:2': ['patch', { x: ['put', 100] }],
* // 'shape:3': ['remove']
* // }
* ```
*
* @internal
*/
export function getNetworkDiff<R extends UnknownRecord>(
diff: RecordsDiff<R>
): NetworkDiff<R> | null {
let res: NetworkDiff<R> | null = null
for (const [k, v] of objectMapEntries(diff.added)) {
if (!res) res = {}
res[k] = [RecordOpType.Put, v]
}
for (const [from, to] of objectMapValues(diff.updated)) {
const diff = diffRecord(from, to)
if (diff) {
if (!res) res = {}
res[to.id] = [RecordOpType.Patch, diff]
}
}
for (const removed of Object.keys(diff.removed)) {
if (!res) res = {}
res[removed] = [RecordOpType.Remove]
}
return res
}
/**
* Constants representing the types of operations that can be applied to individual values
* within object diffs. These operations describe how object properties have changed.
*
* @internal
*/
export const ValueOpType = {
Put: 'put',
Delete: 'delete',
Append: 'append',
Patch: 'patch',
} as const
/**
* Union type of all possible value operation types.
*
* @internal
*/
export type ValueOpType = (typeof ValueOpType)[keyof typeof ValueOpType]
/**
* Operation that replaces a value entirely with a new value.
*
* @internal
*/
export type PutOp = [type: typeof ValueOpType.Put, value: unknown]
/**
* Operation that appends new values to the end of an array or string.
*
* @internal
*/
export type AppendOp = [type: typeof ValueOpType.Append, value: unknown[] | string, offset: number]
/**
* Operation that applies a nested diff to an object or array.
*
* @internal
*/
export type PatchOp = [type: typeof ValueOpType.Patch, diff: ObjectDiff]
/**
* Operation that removes a property from an object.
*
* @internal
*/
export type DeleteOp = [type: typeof ValueOpType.Delete]
/**
* Union type representing any value operation that can be applied during diffing.
*
* @internal
*/
export type ValueOp = PutOp | AppendOp | PatchOp | DeleteOp
/**
* Represents the differences between two objects as a mapping of property names
* to the operations needed to transform one object into another.
*
* @internal
*/
export interface ObjectDiff {
[k: string]: ValueOp
}
/**
* Computes the difference between two record objects, generating an ObjectDiff
* that describes how to transform the previous record into the next record.
* This function is optimized for tldraw records and treats 'props' as a nested object.
*
* @param prev - The previous version of the record
* @param next - The next version of the record
* @param legacyAppendMode - If true, string append operations will be converted to Put operations
* @returns An ObjectDiff describing the changes, or null if no changes exist
*
* @example
* ```ts
* const oldShape = { id: 'shape:1', x: 100, y: 200, props: { color: 'red' } }
* const newShape = { id: 'shape:1', x: 150, y: 200, props: { color: 'blue' } }
*
* const diff = diffRecord(oldShape, newShape)
* // Returns: {
* // x: ['put', 150],
* // props: ['patch', { color: ['put', 'blue'] }]
* // }
* ```
*
* @internal
*/
export function diffRecord(
prev: object,
next: object,
legacyAppendMode = false
): ObjectDiff | null {
return diffObject(prev, next, new Set(['props', 'meta']), legacyAppendMode)
}
function diffObject(
prev: object,
next: object,
nestedKeys: Set<string> | undefined,
legacyAppendMode: boolean
): ObjectDiff | null {
if (prev === next) {
return null
}
let result: ObjectDiff | null = null
for (const key of Object.keys(prev)) {
// if key is not in next then it was deleted
if (!(key in next)) {
if (!result) result = {}
result[key] = [ValueOpType.Delete]
continue
}
const prevValue = (prev as any)[key]
const nextValue = (next as any)[key]
if (
nestedKeys?.has(key) ||
(Array.isArray(prevValue) && Array.isArray(nextValue)) ||
(typeof prevValue === 'string' && typeof nextValue === 'string')
) {
// if key is in both places, then compare values
const diff = diffValue(prevValue, nextValue, legacyAppendMode)
if (diff) {
if (!result) result = {}
result[key] = diff
}
} else if (!isEqual(prevValue, nextValue)) {
if (!result) result = {}
result[key] = [ValueOpType.Put, nextValue]
}
}
for (const key of Object.keys(next)) {
// if key is in next but not in prev then it was added
if (!(key in prev)) {
if (!result) result = {}
result[key] = [ValueOpType.Put, (next as any)[key]]
}
}
return result
}
function diffValue(valueA: unknown, valueB: unknown, legacyAppendMode: boolean): ValueOp | null {
if (Object.is(valueA, valueB)) return null
if (Array.isArray(valueA) && Array.isArray(valueB)) {
return diffArray(valueA, valueB, legacyAppendMode)
} else if (typeof valueA === 'string' && typeof valueB === 'string') {
if (!legacyAppendMode && valueB.startsWith(valueA)) {
const appendedText = valueB.slice(valueA.length)
return [ValueOpType.Append, appendedText, valueA.length]
}
return [ValueOpType.Put, valueB]
} else if (!valueA || !valueB || typeof valueA !== 'object' || typeof valueB !== 'object') {
return isEqual(valueA, valueB) ? null : [ValueOpType.Put, valueB]
} else {
const diff = diffObject(valueA, valueB, undefined, legacyAppendMode)
return diff ? [ValueOpType.Patch, diff] : null
}
}
function diffArray(
prevArray: unknown[],
nextArray: unknown[],
legacyAppendMode: boolean
): PutOp | AppendOp | PatchOp | null {
if (Object.is(prevArray, nextArray)) return null
// if lengths are equal, check for patch operation
if (prevArray.length === nextArray.length) {
// bail out if more than len/5 items need patching
const maxPatchIndexes = Math.max(prevArray.length / 5, 1)
const toPatchIndexes = []
for (let i = 0; i < prevArray.length; i++) {
if (!isEqual(prevArray[i], nextArray[i])) {
toPatchIndexes.push(i)
if (toPatchIndexes.length > maxPatchIndexes) {
return [ValueOpType.Put, nextArray]
}
}
}
if (toPatchIndexes.length === 0) {
// same length and no items changed, so no diff
return null
}
const diff: ObjectDiff = {}
for (const i of toPatchIndexes) {
const prevItem = prevArray[i]
const nextItem = nextArray[i]
if (!prevItem || !nextItem) {
diff[i] = [ValueOpType.Put, nextItem]
} else if (typeof prevItem === 'object' && typeof nextItem === 'object') {
const op = diffValue(prevItem, nextItem, legacyAppendMode)
if (op) {
diff[i] = op
}
} else {
diff[i] = [ValueOpType.Put, nextItem]
}
}
return [ValueOpType.Patch, diff]
}
// if lengths are not equal, check for append operation, and bail out
// to replace whole array if any shared elems changed
for (let i = 0; i < prevArray.length; i++) {
if (!isEqual(prevArray[i], nextArray[i])) {
return [ValueOpType.Put, nextArray]
}
}
return [ValueOpType.Append, nextArray.slice(prevArray.length), prevArray.length]
}
/**
* Applies an ObjectDiff to an object, returning a new object with the changes applied.
* This function handles all value operation types and creates a shallow copy when modifications
* are needed. If no changes are required, the original object is returned.
*
* @param object - The object to apply the diff to
* @param objectDiff - The ObjectDiff containing the operations to apply
* @returns A new object with the diff applied, or the original object if no changes were needed
*
* @example
* ```ts
* const original = { x: 100, y: 200, props: { color: 'red' } }
* const diff = {
* x: ['put', 150],
* props: ['patch', { color: ['put', 'blue'] }]
* }
*
* const updated = applyObjectDiff(original, diff)
* // Returns: { x: 150, y: 200, props: { color: 'blue' } }
* ```
*
* @internal
*/
export function applyObjectDiff<T extends object>(object: T, objectDiff: ObjectDiff): T {
// don't patch nulls
if (!object || typeof object !== 'object') return object
const isArray = Array.isArray(object)
let newObject: any | undefined = undefined
const set = (k: any, v: any) => {
if (!newObject) {
if (isArray) {
newObject = [...object]
} else {
newObject = { ...object }
}
}
if (isArray) {
newObject[Number(k)] = v
} else {
newObject[k] = v
}
}
for (const [key, op] of Object.entries(objectDiff)) {
switch (op[0]) {
case ValueOpType.Put: {
const value = op[1]
if (!isEqual(object[key as keyof T], value)) {
set(key, value)
}
break
}
case ValueOpType.Append: {
const value = op[1]
const offset = op[2]
const currentValue = object[key as keyof T]
if (Array.isArray(currentValue) && Array.isArray(value) && currentValue.length === offset) {
set(key, [...currentValue, ...value])
} else if (
typeof currentValue === 'string' &&
typeof value === 'string' &&
currentValue.length === offset
) {
set(key, currentValue + value)
}
// If validation fails (type mismatch or length mismatch), silently ignore
break
}
case ValueOpType.Patch: {
if (object[key as keyof T] && typeof object[key as keyof T] === 'object') {
const diff = op[1]
const patched = applyObjectDiff(object[key as keyof T] as object, diff)
if (patched !== object[key as keyof T]) {
set(key, patched)
}
}
break
}
case ValueOpType.Delete: {
if (key in object) {
if (!newObject) {
if (isArray) {
console.error("Can't delete array item yet (this should never happen)")
newObject = [...object]
} else {
newObject = { ...object }
}
}
delete newObject[key]
}
}
}
}
return newObject ?? object
}

View File

@@ -0,0 +1,43 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { interval } from './interval'
describe('interval', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('[IN1] invokes the callback every N milliseconds', () => {
const cb = vi.fn()
const dispose = interval(cb, 100)
expect(cb).not.toHaveBeenCalled()
vi.advanceTimersByTime(99)
expect(cb).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(cb).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(300)
expect(cb).toHaveBeenCalledTimes(4)
dispose()
})
it('[IN1] the dispose function stops the interval', () => {
const cb = vi.fn()
const dispose = interval(cb, 100)
vi.advanceTimersByTime(250)
expect(cb).toHaveBeenCalledTimes(2)
dispose()
vi.advanceTimersByTime(1000)
expect(cb).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,44 @@
/**
* Creates a repeating timer that executes a callback at regular intervals and returns a cleanup function.
*
* This utility function wraps the standard `setInterval`/`clearInterval` pattern into a more convenient
* interface that returns a dispose function for cleanup. It's commonly used in the sync system for
* periodic tasks like health checks, ping operations, and session pruning.
*
* @param cb - The callback function to execute at each interval
* @param timeout - The time interval in milliseconds between callback executions
* @returns A cleanup function that stops the interval when called
*
* @example
* ```ts
* // Create a periodic health check
* const stopHealthCheck = interval(() => {
* console.log('Checking server health...')
* checkServerConnection()
* }, 5000)
*
* // Later, stop the health check
* stopHealthCheck()
* ```
*
* @example
* ```ts
* // Use in a disposables array for cleanup management
* class MyClass {
* private disposables = [
* interval(() => this.sendPing(), 30000),
* interval(() => this.pruneSessions(), 2000)
* ]
*
* dispose() {
* this.disposables.forEach(dispose => dispose())
* }
* }
* ```
*
* @public
*/
export function interval(cb: () => void, timeout: number) {
const i = setInterval(cb, timeout)
return () => clearInterval(i)
}

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { getTlsyncProtocolVersion } from './protocol'
import { TLRemoteSyncError } from './TLRemoteSyncError'
import { TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason } from './TLSyncClient'
describe('protocol constants and errors', () => {
it('[PV1] getTlsyncProtocolVersion() returns 8', () => {
expect(getTlsyncProtocolVersion()).toBe(8)
})
it('[PV2] TLSyncErrorCloseEventCode is 4099', () => {
expect(TLSyncErrorCloseEventCode).toBe(4099)
})
describe('TLRemoteSyncError', () => {
it('[PV3] has name RemoteSyncError', () => {
expect(new TLRemoteSyncError(TLSyncErrorCloseEventReason.FORBIDDEN).name).toBe(
'RemoteSyncError'
)
})
it('[PV3] formats the message as "sync error: <reason>" and exposes .reason', () => {
const error = new TLRemoteSyncError(TLSyncErrorCloseEventReason.NOT_AUTHENTICATED)
expect(error.message).toBe('sync error: NOT_AUTHENTICATED')
expect(error.reason).toBe('NOT_AUTHENTICATED')
expect(error).toBeInstanceOf(Error)
})
it('[PV3] works with every TLSyncErrorCloseEventReason value', () => {
for (const reason of Object.values(TLSyncErrorCloseEventReason)) {
const error = new TLRemoteSyncError(reason)
expect(error.reason).toBe(reason)
expect(error.message).toBe(`sync error: ${reason}`)
}
})
it('[PV3] works with custom reason strings', () => {
const error = new TLRemoteSyncError('ROOM_ARCHIVED')
expect(error.reason).toBe('ROOM_ARCHIVED')
expect(error.message).toBe('sync error: ROOM_ARCHIVED')
})
})
})

View File

@@ -0,0 +1,290 @@
import { SerializedSchema, UnknownRecord } from '@tldraw/store'
import { NetworkDiff, ObjectDiff, RecordOpType } from './diff'
const TLSYNC_PROTOCOL_VERSION = 8
/**
* Gets the current tldraw sync protocol version number.
*
* This version number is used during WebSocket connection handshake to ensure
* client and server compatibility. When versions don't match, the connection
* will be rejected with an incompatibility error.
*
* @returns The current protocol version number
*
* @example
* ```ts
* const version = getTlsyncProtocolVersion()
* console.log(`Using protocol version: ${version}`)
* ```
*
* @internal
*/
export function getTlsyncProtocolVersion() {
return TLSYNC_PROTOCOL_VERSION
}
/**
* Access level for object-store lane record types (see `objectTypes` on the room).
*
* Object-lane records (e.g. comments) are gated by this per-session value instead of the
* document-lane `isReadonly` flag, so a session can be allowed to write objects without
* being allowed to edit the document ("can comment but not edit"), or vice versa.
*
* @public
*/
export type TLObjectStoreAccess = 'read' | 'write'
/**
* Constants defining the different types of protocol incompatibility reasons.
*
* These values indicate why a client-server connection was rejected due to
* version or compatibility issues. Each reason helps diagnose specific problems
* during the connection handshake.
*
* @example
* ```ts
* if (error.reason === TLIncompatibilityReason.ClientTooOld) {
* showUpgradeMessage('Please update your client')
* }
* ```
*
* @internal
* @deprecated Replaced by websocket .close status/reason
*/
export const TLIncompatibilityReason = {
ClientTooOld: 'clientTooOld',
ServerTooOld: 'serverTooOld',
InvalidRecord: 'invalidRecord',
InvalidOperation: 'invalidOperation',
} as const
/**
* Union type representing all possible incompatibility reason values.
*
* This type represents the different reasons why a client-server connection
* might fail due to protocol or version mismatches.
*
* @example
* ```ts
* function handleIncompatibility(reason: TLIncompatibilityReason) {
* switch (reason) {
* case 'clientTooOld':
* return 'Client needs to be updated'
* case 'serverTooOld':
* return 'Server needs to be updated'
* }
* }
* ```
*
* @internal
* @deprecated replaced by websocket .close status/reason
*/
/* eslint-disable @typescript-eslint/no-deprecated */
export type TLIncompatibilityReason =
(typeof TLIncompatibilityReason)[keyof typeof TLIncompatibilityReason]
/* eslint-enable @typescript-eslint/no-deprecated */
/**
* Union type representing all possible message types that can be sent from server to client.
*
* This encompasses the complete set of server-originated WebSocket messages in the tldraw
* sync protocol, including connection establishment, data synchronization, and error handling.
*
* @param R - The record type being synchronized (extends UnknownRecord)
*
* @example
* ```ts
* syncClient.onReceiveMessage((message: TLSocketServerSentEvent<MyRecord>) => {
* switch (message.type) {
* case 'connect':
* console.log('Connected to room with clock:', message.serverClock)
* break
* case 'data':
* console.log('Received data updates:', message.data)
* break
* }
* })
* ```
*
* @internal
*/
export type TLSocketServerSentEvent<R extends UnknownRecord> =
| {
type: 'connect'
hydrationType: 'wipe_all' | 'wipe_presence'
connectRequestId: string
protocolVersion: number
schema: SerializedSchema
diff: NetworkDiff<R>
serverClock: number
isReadonly: boolean
/**
* Write access for object-store lane record types, when the room has an object lane.
* Optional and additive: servers without an object lane (or older servers) omit it, and
* older clients ignore it — no protocol version bump needed.
*/
objectAccess?: TLObjectStoreAccess
}
| {
type: 'incompatibility_error'
// eslint-disable-next-line @typescript-eslint/no-deprecated
reason: TLIncompatibilityReason
}
| {
type: 'pong'
}
| { type: 'data'; data: TLSocketServerSentDataEvent<R>[] }
| { type: 'custom'; data: any }
| TLSocketServerSentDataEvent<R>
/**
* Union type representing data-related messages sent from server to client.
*
* These messages handle the core synchronization operations: applying patches from
* other clients and confirming the results of client push operations.
*
* @param R - The record type being synchronized (extends UnknownRecord)
*
* @example
* ```ts
* function handleDataEvent(event: TLSocketServerSentDataEvent<MyRecord>) {
* if (event.type === 'patch') {
* // Apply changes from other clients
* applyNetworkDiff(event.diff)
* } else if (event.type === 'push_result') {
* // Handle result of our push request
* if (event.action === 'commit') {
* console.log('Changes accepted by server')
* }
* }
* }
* ```
*
* @internal
*/
export type TLSocketServerSentDataEvent<R extends UnknownRecord> =
| {
type: 'patch'
diff: NetworkDiff<R>
serverClock: number
}
| {
type: 'push_result'
clientClock: number
serverClock: number
action: 'discard' | 'commit' | { rebaseWithDiff: NetworkDiff<R> }
}
/**
* Interface defining a client-to-server push request message.
*
* Push requests are sent when the client wants to synchronize local changes
* with the server. They contain document changes and optionally presence updates
* (like cursor position or user selection).
*
* @param R - The record type being synchronized (extends UnknownRecord)
*
* @example
* ```ts
* const pushRequest: TLPushRequest<MyRecord> = {
* type: 'push',
* clientClock: 15,
* diff: {
* 'shape:abc123': [RecordOpType.Patch, { x: [ValueOpType.Put, 100] }]
* },
* presence: [RecordOpType.Put, { cursor: { x: 150, y: 200 } }]
* }
* socket.sendMessage(pushRequest)
* ```
*
* @internal
*/
export interface TLPushRequest<R extends UnknownRecord> {
type: 'push'
clientClock: number
diff?: NetworkDiff<R>
presence?: [typeof RecordOpType.Patch, ObjectDiff] | [typeof RecordOpType.Put, R]
}
/**
* Interface defining a client-to-server connection request message.
*
* This message initiates a WebSocket connection to a sync room. It includes
* the client's schema, protocol version, and last known server clock for
* proper synchronization state management.
*
* @example
* ```ts
* const connectRequest: TLConnectRequest = {
* type: 'connect',
* connectRequestId: 'conn-123',
* lastServerClock: 42,
* protocolVersion: getTlsyncProtocolVersion(),
* schema: mySchema.serialize()
* }
* socket.sendMessage(connectRequest)
* ```
*
* @internal
*/
export interface TLConnectRequest {
type: 'connect'
connectRequestId: string
lastServerClock: number
protocolVersion: number
schema: SerializedSchema
}
/**
* Interface defining a client-to-server ping request message.
*
* Ping requests are used to measure network latency and ensure the connection
* is still active. The server responds with a 'pong' message.
*
* @example
* ```ts
* const pingRequest: TLPingRequest = { type: 'ping' }
* socket.sendMessage(pingRequest)
*
* // Server will respond with { type: 'pong' }
* ```
*
* @internal
*/
export interface TLPingRequest {
type: 'ping'
}
/**
* Union type representing all possible message types that can be sent from client to server.
*
* This encompasses the complete set of client-originated WebSocket messages in the tldraw
* sync protocol, covering connection establishment, data synchronization, and connectivity checks.
*
* @param R - The record type being synchronized (extends UnknownRecord)
*
* @example
* ```ts
* function sendMessage(message: TLSocketClientSentEvent<MyRecord>) {
* switch (message.type) {
* case 'connect':
* console.log('Establishing connection...')
* break
* case 'push':
* console.log('Pushing changes:', message.diff)
* break
* case 'ping':
* console.log('Checking connection latency')
* break
* }
* socket.send(JSON.stringify(message))
* }
* ```
*
* @internal
*/
export type TLSocketClientSentEvent<R extends UnknownRecord> =
| TLPushRequest<R>
| TLConnectRequest
| TLPingRequest

View File

@@ -0,0 +1,159 @@
import { BaseRecord, RecordId, createRecordType } from '@tldraw/store'
import { vi } from 'vitest'
import { ValueOpType, type ObjectDiff } from './diff'
import { applyAndDiffRecord, diffAndValidateRecord, validateRecord } from './recordDiff'
import { TLSyncError, TLSyncErrorCloseEventReason } from './TLSyncClient'
interface TestRecord extends BaseRecord<'test', RecordId<TestRecord>> {
value: number
text: string
}
function setup() {
const validate = vi.fn((record: unknown): TestRecord => {
const r = record as TestRecord
if (r.value < 0) {
throw new Error('value must not be negative')
}
return r
})
const recordType = createRecordType<TestRecord>('test', {
validator: { validate },
scope: 'document',
})
const record = (value: number, text = 'hello'): TestRecord => ({
id: 'test:a' as TestRecord['id'],
typeName: 'test',
value,
text,
})
return { validate, recordType, record }
}
describe('diffAndValidateRecord (RV1, RV2)', () => {
it('[RV1] returns undefined when the records produce no diff', () => {
const { recordType, record } = setup()
expect(diffAndValidateRecord(record(1), record(1), recordType)).toBeUndefined()
})
it('[RV1] does not call the validator when there is no diff', () => {
const { validate, recordType, record } = setup()
const prev = record(1)
diffAndValidateRecord(prev, prev, recordType)
diffAndValidateRecord(prev, record(1), recordType)
expect(validate).not.toHaveBeenCalled()
})
it('[RV2] validates the new state when there is a diff', () => {
const { validate, recordType, record } = setup()
const next = record(2)
const diff = diffAndValidateRecord(record(1), next, recordType)
expect(diff).toEqual({ value: [ValueOpType.Put, 2] })
expect(validate).toHaveBeenCalledTimes(1)
expect(validate).toHaveBeenCalledWith(next)
})
it('[RV2] rethrows a validator throw as TLSyncError with reason INVALID_RECORD', () => {
const { recordType, record } = setup()
let error: unknown
try {
diffAndValidateRecord(record(1), record(-1), recordType)
} catch (e) {
error = e
}
expect(error).toBeInstanceOf(TLSyncError)
expect((error as TLSyncError).reason).toBe(TLSyncErrorCloseEventReason.INVALID_RECORD)
expect((error as TLSyncError).message).toBe('value must not be negative')
})
})
describe('applyAndDiffRecord (RV3)', () => {
it('[RV3] returns undefined when the patch applies to a reference-identical result', () => {
const { validate, recordType, record } = setup()
const prev = record(1)
// a put of the current value has no effect (AD2), so applyObjectDiff
// returns the same reference
const patch: ObjectDiff = { value: [ValueOpType.Put, 1] }
expect(applyAndDiffRecord(prev, patch, recordType)).toBeUndefined()
expect(validate).not.toHaveBeenCalled()
})
it('[RV3] returns the recomputed effective diff and the new state', () => {
const { recordType, record } = setup()
const prev = record(1)
const patch: ObjectDiff = { value: [ValueOpType.Put, 2] }
const result = applyAndDiffRecord(prev, patch, recordType)
expect(result).toBeDefined()
const [actualDiff, newState] = result!
expect(actualDiff).toEqual({ value: [ValueOpType.Put, 2] })
expect(newState).toEqual(record(2))
// the input is not mutated (AD1)
expect(prev).toEqual(record(1))
})
it('[RV3] drops no-op ops from the returned diff, keeping only the real change', () => {
const { recordType, record } = setup()
const prev = record(1, 'hello')
// the text put is a no-op (same value); the value put is a real change
const patch: ObjectDiff = {
text: [ValueOpType.Put, 'hello'],
value: [ValueOpType.Put, 5],
}
const result = applyAndDiffRecord(prev, patch, recordType)
expect(result).toBeDefined()
const [actualDiff, newState] = result!
expect(actualDiff).toEqual({ value: [ValueOpType.Put, 5] })
expect(newState).toEqual(record(5, 'hello'))
})
it('[RV3] [RV2] validates the patched state and wraps validator throws', () => {
const { recordType, record } = setup()
const patch: ObjectDiff = { value: [ValueOpType.Put, -10] }
let error: unknown
try {
applyAndDiffRecord(record(1), patch, recordType)
} catch (e) {
error = e
}
expect(error).toBeInstanceOf(TLSyncError)
expect((error as TLSyncError).reason).toBe(TLSyncErrorCloseEventReason.INVALID_RECORD)
})
})
describe('validateRecord (RV4)', () => {
it('[RV4] returns normally for a valid record', () => {
const { validate, recordType, record } = setup()
const state = record(1)
expect(() => validateRecord(state, recordType)).not.toThrow()
expect(validate).toHaveBeenCalledWith(state)
})
it('[RV4] wraps a validator throw as TLSyncError with reason INVALID_RECORD', () => {
const { recordType, record } = setup()
let error: unknown
try {
validateRecord(record(-1), recordType)
} catch (e) {
error = e
}
expect(error).toBeInstanceOf(TLSyncError)
expect((error as TLSyncError).reason).toBe(TLSyncErrorCloseEventReason.INVALID_RECORD)
expect((error as TLSyncError).message).toBe('value must not be negative')
})
})

View File

@@ -0,0 +1,73 @@
import { RecordType, UnknownRecord } from '@tldraw/store'
import { ObjectDiff, applyObjectDiff, diffRecord } from './diff'
import { TLSyncError, TLSyncErrorCloseEventReason } from './TLSyncClient'
/**
* Validate a record and compute the diff between two states.
* Returns null if the states are identical.
*
* @param prevState - The previous record state
* @param newState - The new record state
* @param recordType - The record type definition for validation
* @param legacyAppendMode - If true, string append operations will be converted to Put operations
* @returns Result containing the diff and new state, or null if no changes, or validation error
*
* @internal
*/
export function diffAndValidateRecord<R extends UnknownRecord>(
prevState: R,
newState: R,
recordType: RecordType<R, any>,
legacyAppendMode = false
) {
const diff = diffRecord(prevState, newState, legacyAppendMode)
if (!diff) return
try {
recordType.validate(newState)
} catch (error: any) {
throw new TLSyncError(error.message, TLSyncErrorCloseEventReason.INVALID_RECORD)
}
return diff
}
/**
* Apply a diff to a record state, validate the result, and compute the final diff.
* Returns null if the diff produces no changes.
*
* @param prevState - The previous record state
* @param diff - The object diff to apply
* @param recordType - The record type definition for validation
* @param legacyAppendMode - If true, string append operations will be converted to Put operations
* @returns Result containing the final diff and new state, or null if no changes, or validation error
*
* @internal
*/
export function applyAndDiffRecord<R extends UnknownRecord>(
prevState: R,
diff: ObjectDiff,
recordType: RecordType<R, any>,
legacyAppendMode = false
): [ObjectDiff, R] | undefined {
const newState = applyObjectDiff(prevState, diff)
if (newState === prevState) return
const actualDiff = diffAndValidateRecord(prevState, newState, recordType, legacyAppendMode)
if (!actualDiff) return
return [actualDiff, newState]
}
/**
* Validate a record without computing a diff. Used when creating new records.
*
* @param state - The record state to validate
* @param recordType - The record type definition for validation
* @returns Result indicating success or validation error
*
* @internal
*/
export function validateRecord<R extends UnknownRecord>(state: R, recordType: RecordType<R, any>) {
try {
recordType.validate(state)
} catch (error: any) {
throw new TLSyncError(error.message, TLSyncErrorCloseEventReason.INVALID_RECORD)
}
}

View File

@@ -0,0 +1,52 @@
import { RoomSnapshot } from './TLSyncRoom'
/**
* Database schema interface for persisting tldraw room snapshots in Supabase.
*
* This interface defines the structure used to store collaborative drawing rooms
* in a Supabase database, containing both metadata and the complete room state.
* The room snapshot includes all documents, tombstones, and synchronization clocks
* needed to restore a collaborative session.
*
* @param id - Unique identifier for the persisted room record in the database
* @param slug - Human-readable URL slug or identifier for the room (e.g., "my-drawing-123")
* @param drawing - Complete room snapshot containing all synchronized state and metadata
*
* @example
* ```ts
* // Saving a room snapshot to Supabase
* const roomSnapshot = syncRoom.getSnapshot()
* const persistedData: PersistedRoomSnapshotForSupabase = {
* id: crypto.randomUUID(),
* slug: 'collaborative-whiteboard-session',
* drawing: roomSnapshot
* }
*
* await supabase
* .from('rooms')
* .insert(persistedData)
* ```
*
* @example
* ```ts
* // Loading a room snapshot from Supabase
* const { data } = await supabase
* .from('rooms')
* .select('*')
* .eq('slug', roomSlug)
* .single()
*
* const roomData = data as PersistedRoomSnapshotForSupabase
* const room = new TLSyncRoom({
* schema: mySchema,
* snapshot: roomData.drawing
* })
* ```
*
* @internal
*/
export interface PersistedRoomSnapshotForSupabase {
id: string
slug: string
drawing: RoomSnapshot
}

View File

@@ -0,0 +1,406 @@
import {
Editor,
PageRecordType,
TLPage,
TLPageId,
TLShape,
TLShapeId,
TLStore,
VecModel,
createShapeId,
defaultAddFontsFromNode,
defaultBindingUtils,
defaultShapeUtils,
defaultTools,
tipTapDefaultExtensions,
} from 'tldraw'
import { RandomSource } from './RandomSource'
export type Op =
| {
type: 'create-box'
parentId?: TLShapeId
x: number
y: number
width: number
height: number
}
| {
type: 'create-frame'
x: number
y: number
width: number
height: number
}
| {
type: 'group-selection'
}
| {
type: 'ungroup-selection'
}
| {
type: 'create-arrow'
start: VecModel
end: VecModel
}
| {
type: 'delete-shape'
id: TLShapeId
}
| {
type: 'create-page'
id: TLPageId
}
| {
type: 'delete-page'
id: TLPageId
}
| {
type: 'undo'
}
| {
type: 'redo'
}
| {
type: 'switch-page'
id: TLPageId
}
| {
type: 'select-shape'
id: TLShapeId
}
| {
type: 'deselect-shape'
id: TLShapeId
}
| {
type: 'move-selection'
dx: number
dy: number
}
| {
type: 'delete-selection'
}
| {
type: 'move-selected-shapes-to-page'
pageId: TLPageId
}
| {
type: 'mark-stopping-point'
}
export class FuzzEditor extends RandomSource {
editor: Editor
constructor(
public readonly id: string,
_seed: number,
public readonly store: TLStore
) {
super(_seed)
this.editor = new Editor({
shapeUtils: defaultShapeUtils,
bindingUtils: defaultBindingUtils,
tools: defaultTools,
initialState: 'select',
store,
getContainer: () => document.createElement('div'),
options: {
text: {
addFontsFromNode: defaultAddFontsFromNode,
tipTapConfig: {
extensions: tipTapDefaultExtensions,
},
},
},
})
}
ops: Op[] = []
getRandomShapeId({ selected }: { selected?: boolean } = {}): TLShapeId | undefined {
return this.randomElement<TLShape>(
selected ? this.editor.getSelectedShapes() : this.editor.getCurrentPageShapes()
)?.id
}
getRandomOp(): Op {
const op = this.randomAction<Op>(
[
() => {
const x = this.randomInt(1000)
const y = this.randomInt(1000)
const width = this.randomInt(1, 1000)
const height = this.randomInt(1, 1000)
let parentId: TLShapeId | undefined
if (this.randomInt(2) === 0) {
parentId = this.randomElement<TLShape>(
this.editor.getCurrentPageShapes().filter((s) => s.type === 'frame')
)?.id
}
return { type: 'create-box', x, y, width, height, parentId }
},
() => {
const x = this.randomInt(1000)
const y = this.randomInt(1000)
const width = this.randomInt(1, 1000)
const height = this.randomInt(1, 1000)
return { type: 'create-frame', x, y, width, height }
},
// Need to disable arrows for the time being, the cleanup logic leads to state inconsistency.
// We need a better way to handle state updates.
// () => {
// let start: TLArrowTerminal = {
// type: 'point',
// x: this.randomInt(1000),
// y: this.randomInt(1000),
// }
// let end: TLArrowTerminal = {
// type: 'point',
// x: this.randomInt(1000),
// y: this.randomInt(1000),
// }
// if (this.randomInt(2) === 0) {
// const boundShapeId = this.getRandomShapeId()
// if (boundShapeId) {
// start = {
// type: 'binding',
// boundShapeId: boundShapeId,
// isExact: true,
// normalizedAnchor: { x: 0.5, y: 0.5 },
// }
// }
// }
// if (this.randomInt(2) === 0) {
// const boundShapeId = this.getRandomShapeId()
// if (boundShapeId) {
// end = {
// type: 'binding',
// boundShapeId: boundShapeId,
// isExact: true,
// normalizedAnchor: { x: 0.5, y: 0.5 },
// }
// }
// }
// return { type: 'create-arrow', start, end }
// },
() => {
const id = this.getRandomShapeId()
if (id) {
return { type: 'delete-shape', id }
}
return this.getRandomOp()
},
() => {
return { type: 'create-page', id: PageRecordType.createId() }
},
() => {
const id = this.randomElement<TLPage>(this.editor.getPages())?.id
if (id) {
return { type: 'delete-page', id }
}
return this.getRandomOp()
},
() => {
return { type: 'undo' }
},
() => {
return { type: 'redo' }
},
() => {
return { type: 'mark-stopping-point' }
},
() => {
if (this.editor.getSelectedShapes().length > 1) {
return { type: 'group-selection' }
}
return this.getRandomOp()
},
() => {
if (this.editor.getSelectedShapes().some((s) => s.type === 'group')) {
return { type: 'ungroup-selection' }
}
return this.getRandomOp()
},
() => {
const id = this.randomElement<TLPage>(this.editor.getPages())?.id
if (id) {
return { type: 'switch-page', id }
}
return this.getRandomOp()
},
() => {
const id = this.getRandomShapeId()
if (id) {
return { type: 'select-shape', id }
}
return this.getRandomOp()
},
() => {
const id = this.getRandomShapeId({ selected: true })
if (id) {
return { type: 'deselect-shape', id }
}
return this.getRandomOp()
},
() => {
if (this.editor.getSelectedShapes().length) {
const dx = this.randomInt(1000)
const dy = this.randomInt(1000)
return { type: 'move-selection', dx, dy }
}
return this.getRandomOp()
},
() => {
if (this.editor.getSelectedShapes().length) {
return { type: 'delete-selection' }
}
return this.getRandomOp()
},
() => {
if (this.editor.getSelectedShapes().length) {
const pageId = this.randomElement<TLPage>(
this.editor.getPages().filter((p) => p.id !== this.editor.getCurrentPageId())
)?.id
if (pageId) {
return { type: 'move-selected-shapes-to-page', pageId }
}
}
return this.getRandomOp()
},
],
true
)
this.ops.push(op)
return op
}
applyOp(op: Op) {
switch (op.type) {
case 'create-box': {
this.editor.createShape({
type: 'geo',
id: createShapeId(),
x: op.x,
y: op.y,
parentId: op.parentId,
props: {
w: op.width,
h: op.height,
},
})
break
}
case 'create-frame': {
this.editor.createShape({
type: 'frame',
id: createShapeId(),
x: op.x,
y: op.y,
props: {
w: op.width,
h: op.height,
},
})
break
}
case 'create-arrow': {
this.editor.createShape({
type: 'arrow',
id: createShapeId(),
x: 0,
y: 0,
props: {
start: op.start as any,
end: op.end as any,
},
})
break
}
case 'delete-shape': {
this.editor.deleteShape(op.id)
break
}
case 'create-page': {
this.editor.createPage({ id: op.id, name: op.id })
break
}
case 'delete-page': {
this.editor.deletePage(op.id)
break
}
case 'undo': {
this.editor.undo()
break
}
case 'redo': {
this.editor.redo()
break
}
case 'group-selection': {
this.editor.groupShapes(this.editor.getSelectedShapeIds())
break
}
case 'ungroup-selection': {
this.editor.ungroupShapes(this.editor.getSelectedShapeIds())
break
}
case 'mark-stopping-point': {
this.editor.markHistoryStoppingPoint()
break
}
case 'switch-page': {
this.editor.setCurrentPage(op.id)
break
}
case 'select-shape': {
this.editor.select(op.id)
break
}
case 'deselect-shape': {
this.editor.deselect(op.id)
break
}
case 'move-selection': {
this.editor.updateShapes(
this.editor.getSelectedShapes().map((s) => ({
...s,
x: s.x + op.dx,
y: s.y + op.dy,
}))
)
break
}
case 'delete-selection': {
this.editor.deleteShapes(this.editor.getSelectedShapeIds())
break
}
case 'move-selected-shapes-to-page': {
this.editor.moveShapesToPage(this.editor.getSelectedShapeIds(), op.pageId)
break
}
default:
throw new Error(`Unknown op type: ${JSON.stringify((op as any).type)}`)
}
}
}

View File

@@ -0,0 +1,46 @@
export class RandomSource {
constructor(private _seed: number) {}
randomInt(): number
randomInt(lessThan: number): number
randomInt(fromInclusive: number, toExclusive: number): number
randomInt(lo?: number, hi?: number) {
if (lo === undefined) {
lo = Number.MAX_SAFE_INTEGER
}
if (hi === undefined) {
hi = lo
lo = 0
}
this._seed = (this._seed * 9301 + 49297) % 233280
// float is a number between 0 and 1
const float = this._seed / 233280
return lo + Math.floor(float * (hi - lo))
}
randomAction<Result>(
// eslint-disable-next-line tldraw/method-signature-style
choices: Array<(() => Result) | { weight: number; do: () => any }>,
randomWeights?: boolean
): Result {
type Choice = (typeof choices)[number]
const getWeightFromChoice = (choice: Choice) =>
'weight' in choice ? choice.weight : randomWeights ? this.randomInt(0, 10) : 1
const weights = choices.map(getWeightFromChoice)
const totalWeight = weights.reduce((total, w) => total + w, 0)
const randomWeight = this.randomInt(totalWeight)
let weight = 0
for (let i = 0; i < choices.length; i++) {
weight += weights[i]
const choice = choices[i]
if (randomWeight < weight) {
return 'do' in choice ? choice.do() : choice()
}
}
throw new Error('unreachable')
}
randomElement<Elem>(items: Elem[]): Elem | undefined {
return items[this.randomInt(items.length)]
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,285 @@
import { computed } from '@tldraw/state'
import {
BaseRecord,
RecordId,
RecordsDiff,
Store,
StoreSchema,
createRecordType,
} from '@tldraw/store'
import { isEqual } from '@tldraw/utils'
import { afterEach, describe, expect, it } from 'vitest'
import { ValueOpType } from '../lib/diff'
import { TLPushRequest } from '../lib/protocol'
import { TLSyncClient } from '../lib/TLSyncClient'
import { TestServer } from './TestServer'
import { TestSocketPair } from './TestSocketPair'
// These tests pin two invariants of TLSyncClient.rebase that hold *emergently* — they fall out of
// the tldraw Store's mergeRemoteChanges / history squashing rather than being asserted anywhere.
// They only manifest on STAGGERED commit acks: when the client has several pending (unacked) pushes
// and the server commits them one at a time, each ack triggering its own rebase() while the later
// pushes are still in flight.
//
// Invariant 1 — a staggered commit ack emits no net change to store listeners. rebase() rewinds the
// speculative changes, applies the committed push, then re-applies the still-pending pushes, all
// inside a single store.mergeRemoteChanges. The rewind and re-apply must cancel, so a listener
// registered before the ack observes no net document change (either no history entry, or a
// net-zero 'remote' entry whose before/after are value-equal), while the store still holds the full
// optimistic state.
//
// Invariant 2 — after such an ack, speculativeChanges must span *from the committed (server-acked)
// state*, not from a mid-flight state. reverseRecordsDiff(speculativeChanges) is used as the rewind
// on the next incoming message; if its `from` were a mid-flight state, base-relative ops (string
// Append ops, per-index array patches — see diffArray in ../lib/diff.ts) would be applied to the
// wrong base and silently corrupt records, because the apply guards skip mismatched appends rather
// than erroring. To exercise those base-relative ops, every change here is append-shaped.
//
// Harness: a real TestServer / TestSocketPair (as in TLSyncRoom.test.ts). Because the throttles are
// pass-through under NODE_ENV=test, every store change, push, and rebase runs synchronously — no
// fake timers needed. We deliver each client push to the server individually so the server emits one
// push_result at a time, letting us drive genuinely staggered acks.
interface TestDoc extends BaseRecord<'doc', RecordId<TestDoc>> {
text: string
items: number[]
}
const TestDocType = createRecordType<TestDoc>('doc', {
scope: 'document',
validator: { validate: (value) => value as TestDoc },
})
interface TestPresence extends BaseRecord<'presence', RecordId<TestPresence>> {
name: string
}
const TestPresenceType = createRecordType<TestPresence>('presence', {
scope: 'presence',
validator: { validate: (value) => value as TestPresence },
})
const testSchema = StoreSchema.create<TestDoc | TestPresence>({
doc: TestDocType,
presence: TestPresenceType,
})
type R = TestDoc | TestPresence
interface Instance {
server: TestServer<R>
socketPair: TestSocketPair<R>
client: TLSyncClient<R>
store: Store<R>
docId: RecordId<TestDoc>
}
const disposables: Array<() => void> = []
afterEach(() => {
while (disposables.length) disposables.pop()!()
})
/**
* Stand up a connected client + server whose single confirmed (server-acked) document is `doc0`.
* Pre-seeding the server means the connect hydration installs `doc0` as a synced record, so the
* subsequent appends are updates against a known server base rather than fresh creations.
*/
function makeInstance(doc0: TestDoc): Instance {
const server = new TestServer<R>(testSchema, {
documents: [{ state: doc0, lastChangedClock: 0 }],
clock: 0,
documentClock: 0,
schema: testSchema.serialize(),
})
const socketPair = new TestSocketPair<R>('rebase-test', server)
socketPair.connect()
const store = new Store<R>({ schema: testSchema, props: {} })
const client = new TLSyncClient<R>({
store,
socket: socketPair.clientSocket,
presence: computed('presence', () => null),
onLoad: () => {},
onSyncError: (reason) => {
throw new Error(`unexpected sync error: ${reason}`)
},
})
disposables.push(() => client.close())
// complete the connect handshake (no pushes are in flight yet, so this only hydrates)
while (socketPair.getNeedsFlushing()) {
socketPair.flushClientSentEvents()
socketPair.flushServerSentEvents()
}
expect(store.get(doc0.id)).toEqual(doc0)
return { server, socketPair, client, store, docId: doc0.id }
}
function getQueuedPushes(inst: Instance): TLPushRequest<R>[] {
return inst.socketPair.clientSentEventQueue.filter(
(m): m is TLPushRequest<R> => m.type === 'push'
)
}
function pendingPushCount(inst: Instance): number {
return (inst.client as any).pendingPushRequests.length
}
function speculativeChanges(inst: Instance): RecordsDiff<R> {
return (inst.client as any).speculativeChanges
}
/**
* Deliver exactly one queued client push to the server, then hand the server's resulting
* push_result(s) back to the client — driving a single rebase() while the later pushes stay
* pending. Returns the push_results the client received. flushDebouncingMessages resets the
* server's data-message debounce so each push_result is emitted immediately in its own message.
*/
function ackNextPush(inst: Instance) {
const { server, socketPair } = inst
server.flushDebouncingMessages()
const clientMsg = socketPair.clientSentEventQueue.shift()
if (!clientMsg || clientMsg.type !== 'push') {
throw new Error(`expected a queued push, got ${clientMsg?.type ?? 'nothing'}`)
}
socketPair.didReceiveFromClient!(clientMsg)
server.flushDebouncingMessages()
const serverMsgs = socketPair.serverSentEventQueue.splice(0)
for (const msg of serverMsgs) socketPair.callbacks.onReceiveMessage!(msg)
const results: any[] = []
for (const msg of serverMsgs) {
if (msg.type === 'data') {
for (const d of msg.data) if (d.type === 'push_result') results.push(d)
}
}
return results
}
/** True when a diff represents no net change: no adds, no removes, and every update is value-equal. */
function isNetZero(diff: RecordsDiff<R>): boolean {
if (Object.keys(diff.added).length > 0) return false
if (Object.keys(diff.removed).length > 0) return false
return Object.values(diff.updated).every(([from, to]) => isEqual(from, to))
}
describe('TLSyncClient staggered rebase invariants', () => {
it('invariant 1: a staggered commit ack for an in-flight push emits no net document change', () => {
const inst = makeInstance(TestDocType.create({ text: '', items: [] }))
const { store, docId } = inst
// three sequential appends to the same record — three in-flight pushes
store.update(docId, (d) => ({ ...d, text: d.text + 'A' }))
store.update(docId, (d) => ({ ...d, text: d.text + 'B' }))
store.update(docId, (d) => ({ ...d, text: d.text + 'C' }))
const pushes = getQueuedPushes(inst)
expect(pushes.map((p) => p.clientClock)).toEqual([0, 1, 2])
// the pushes carry base-relative Append ops (not base-independent Puts)
expect(pushes[0].diff![docId][0]).toBe('patch')
expect((pushes[0].diff![docId][1] as any).text).toEqual([ValueOpType.Append, 'A', 0])
expect(pendingPushCount(inst)).toBe(3)
const before = store.get(docId)
expect((before as TestDoc).text).toBe('ABC')
// register a listener AFTER the optimistic state is in place, then commit only the first push
const received: Array<{ changes: RecordsDiff<R>; source: string }> = []
const unlisten = store.listen((entry) => received.push(entry as any), {
scope: 'document',
source: 'all',
})
const results = ackNextPush(inst)
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({ type: 'push_result', clientClock: 0, action: 'commit' })
// (a) the ack produced no net change for listeners — any entry seen is a net-zero remote entry
for (const entry of received) {
expect(entry.source).toBe('remote')
expect(isNetZero(entry.changes)).toBe(true)
}
// (b) the store still holds the full optimistic state after all three local changes
expect(store.get(docId)).toEqual(before)
expect((store.get(docId) as TestDoc).text).toBe('ABC')
// the ack drained exactly one pending push; the other two remain in flight
expect(pendingPushCount(inst)).toBe(2)
unlisten()
})
it('invariant 2: after a staggered commit, speculativeChanges spans from the committed state, not a mid-flight one', () => {
const inst = makeInstance(TestDocType.create({ text: '', items: [] }))
const { store, docId } = inst
store.update(docId, (d) => ({ ...d, text: d.text + 'A' }))
store.update(docId, (d) => ({ ...d, text: d.text + 'B' }))
store.update(docId, (d) => ({ ...d, text: d.text + 'C' }))
expect((store.get(docId) as TestDoc).text).toBe('ABC')
// commit push 1 only (the other two stay pending)
expect(ackNextPush(inst).map((r) => (r as any).action)).toEqual(['commit'])
expect(pendingPushCount(inst)).toBe(2)
// speculativeChanges must now be the squash of the two REMAINING pushes, with `from` equal to
// the committed (server-acked) state — the state after push 1 ('A'), NOT after push 2 ('AB').
const spec1 = speculativeChanges(inst)
expect((spec1.updated[docId][0] as TestDoc).text).toBe('A')
expect((spec1.updated[docId][1] as TestDoc).text).toBe('ABC')
// the optimistic state is untouched by the ack
expect((store.get(docId) as TestDoc).text).toBe('ABC')
// commit push 2: `from` advances to the new committed state ('AB'), still base-correct
expect(ackNextPush(inst).map((r) => (r as any).action)).toEqual(['commit'])
expect(pendingPushCount(inst)).toBe(1)
const spec2 = speculativeChanges(inst)
expect((spec2.updated[docId][0] as TestDoc).text).toBe('AB')
expect((spec2.updated[docId][1] as TestDoc).text).toBe('ABC')
expect((store.get(docId) as TestDoc).text).toBe('ABC')
// commit push 3: nothing pending, so speculativeChanges is empty and the value has converged
expect(ackNextPush(inst).map((r) => (r as any).action)).toEqual(['commit'])
expect(pendingPushCount(inst)).toBe(0)
expect(speculativeChanges(inst).updated[docId]).toBeUndefined()
expect((store.get(docId) as TestDoc).text).toBe('ABC')
// the server converged to the exact final value across the staggered commits
const serverDoc = inst.server.storage.getSnapshot().documents.find((d) => d.state.id === docId)
?.state as TestDoc
expect(serverDoc.text).toBe('ABC')
})
it('invariant 2: base-relative array appends converge across staggered commit acks', () => {
const inst = makeInstance(TestDocType.create({ text: '', items: [] }))
const { store, docId } = inst
store.update(docId, (d) => ({ ...d, items: [...d.items, 1] }))
store.update(docId, (d) => ({ ...d, items: [...d.items, 2] }))
store.update(docId, (d) => ({ ...d, items: [...d.items, 3] }))
expect((store.get(docId) as TestDoc).items).toEqual([1, 2, 3])
// the array pushes carry base-relative Append ops (offset-relative to the diff base)
const pushes = getQueuedPushes(inst)
expect((pushes[0].diff![docId][1] as any).items).toEqual([ValueOpType.Append, [1], 0])
expect((pushes[1].diff![docId][1] as any).items).toEqual([ValueOpType.Append, [2], 1])
// commit push 1: `from` of the remaining speculative diff is the committed array state [1]
expect(ackNextPush(inst).map((r) => (r as any).action)).toEqual(['commit'])
const spec1 = speculativeChanges(inst)
expect((spec1.updated[docId][0] as TestDoc).items).toEqual([1])
expect((spec1.updated[docId][1] as TestDoc).items).toEqual([1, 2, 3])
expect((store.get(docId) as TestDoc).items).toEqual([1, 2, 3])
// commit the rest, one staggered ack at a time; base-relative appends stay aligned
expect(ackNextPush(inst).map((r) => (r as any).action)).toEqual(['commit'])
expect((store.get(docId) as TestDoc).items).toEqual([1, 2, 3])
expect(ackNextPush(inst).map((r) => (r as any).action)).toEqual(['commit'])
expect(pendingPushCount(inst)).toBe(0)
expect((store.get(docId) as TestDoc).items).toEqual([1, 2, 3])
const serverDoc = inst.server.storage.getSnapshot().documents.find((d) => d.state.id === docId)
?.state as TestDoc
expect(serverDoc.items).toEqual([1, 2, 3])
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
import { StoreSchema, UnknownRecord } from '@tldraw/store'
import { InMemorySyncStorage } from '../lib/InMemorySyncStorage'
import { TLObjectStoreAccess } from '../lib/protocol'
import { RoomSnapshot, TLSyncRoom } from '../lib/TLSyncRoom'
import { TestSocketPair } from './TestSocketPair'
type TestRoomOptions<R extends UnknownRecord> = Omit<
ConstructorParameters<typeof TLSyncRoom<R, undefined>>[0],
'schema' | 'storage'
>
export class TestServer<R extends UnknownRecord, P = unknown> {
room: TLSyncRoom<R, undefined>
storage: InMemorySyncStorage<R>
constructor(schema: StoreSchema<R, P>, snapshot?: RoomSnapshot, roomOpts?: TestRoomOptions<R>) {
// Use provided snapshot or create an empty one with the current schema
this.storage = new InMemorySyncStorage<R>({
snapshot: snapshot ?? {
documents: [],
clock: 0,
documentClock: 0,
schema: schema.serialize(),
},
})
this.room = new TLSyncRoom<R, undefined>({ schema, storage: this.storage, ...roomOpts })
}
connect(
socketPair: TestSocketPair<R>,
opts?: { isReadonly?: boolean; objectAccess?: TLObjectStoreAccess }
): void {
this.room.handleNewSession({
sessionId: socketPair.id,
socket: socketPair.roomSocket,
meta: undefined,
isReadonly: opts?.isReadonly ?? false,
objectAccess: opts?.objectAccess,
})
socketPair.clientSocket.connectionStatus = 'online'
socketPair.didReceiveFromClient = (msg) => {
this.room.handleMessage(socketPair.id, msg)
}
socketPair.clientDisconnected = () => {
this.room.handleClose(socketPair.id)
}
socketPair.callbacks.onStatusChange?.({ status: 'online' })
}
flushDebouncingMessages() {
for (const sessionId of this.room.sessions.keys()) {
this.room._flushDataMessages(sessionId)
}
}
}

View File

@@ -0,0 +1,128 @@
import { UnknownRecord } from '@tldraw/store'
import { structuredClone } from '@tldraw/utils'
import { TLSocketClientSentEvent, TLSocketServerSentEvent } from '../lib/protocol'
import {
TLPersistentClientSocket,
TLSocketStatusListener,
TLSyncErrorCloseEventCode,
TLSyncErrorCloseEventReason,
} from '../lib/TLSyncClient'
import { TLRoomSocket } from '../lib/TLSyncRoom'
import { TestServer } from './TestServer'
export class TestSocketPair<R extends UnknownRecord> {
clientSentEventQueue: TLSocketClientSentEvent<R>[] = []
serverSentEventQueue: TLSocketServerSentEvent<R>[] = []
flushServerSentEvents() {
const queue = this.serverSentEventQueue
this.serverSentEventQueue = []
queue.forEach((msg) => {
this.callbacks.onReceiveMessage?.(msg)
})
}
flushClientSentEvents() {
const queue = this.clientSentEventQueue
this.clientSentEventQueue = []
queue.forEach((msg) => {
this.didReceiveFromClient?.(msg)
})
}
async flushAllEvents() {
await Promise.resolve()
while (this.getNeedsFlushing()) {
this.flushClientSentEvents()
this.flushServerSentEvents()
}
}
getNeedsFlushing() {
return this.serverSentEventQueue.length > 0 || this.clientSentEventQueue.length > 0
}
roomSocket: TLRoomSocket<R> = {
close: (code?: number, reason?: string) => {
this.flushServerSentEvents()
this.disconnect(code, reason)
},
get isOpen() {
return true
},
sendMessage: (msg: TLSocketServerSentEvent<R>) => {
if (!this.callbacks.onReceiveMessage) {
throw new Error('Socket is closed')
}
if (this.clientSocket.connectionStatus !== 'online') {
// client was closed, drop the packet
return
}
// cloning because callers might reuse the same message object
this.serverSentEventQueue.push(structuredClone(msg))
},
}
didReceiveFromClient?: (msg: TLSocketClientSentEvent<R>) => void = undefined
clientDisconnected?: () => void = undefined
clientSocket: TLPersistentClientSocket<TLSocketClientSentEvent<R>, TLSocketServerSentEvent<R>> = {
connectionStatus: 'offline',
onStatusChange: (cb) => {
this.callbacks.onStatusChange = cb
return () => {
this.callbacks.onStatusChange = null
}
},
onReceiveMessage: (cb) => {
this.callbacks.onReceiveMessage = cb
return () => {
this.callbacks.onReceiveMessage = null
}
},
sendMessage: (msg) => {
if (this.clientSocket.connectionStatus !== 'online') {
throw new Error('trying to send before open')
}
// cloning because callers might reuse the same message object
this.clientSentEventQueue.push(structuredClone(msg))
},
restart: () => {
this.disconnect()
this.connect()
},
close: () => {
this.disconnect()
},
}
callbacks = {
onReceiveMessage: null as null | ((msg: TLSocketServerSentEvent<R>) => void),
onStatusChange: null as null | TLSocketStatusListener,
}
// eslint-disable-next-line tldraw/no-setter-getter
get isConnected() {
return this.clientSocket.connectionStatus === 'online'
}
connect() {
this.server.connect(this)
}
disconnect(code?: number, reason?: string) {
this.clientSocket.connectionStatus = 'offline'
this.serverSentEventQueue = []
this.clientSentEventQueue = []
if (code === TLSyncErrorCloseEventCode) {
this.callbacks.onStatusChange?.({
status: 'error',
reason: reason ?? TLSyncErrorCloseEventReason.UNKNOWN_ERROR,
})
} else {
this.callbacks.onStatusChange?.({ status: 'offline' })
}
this.clientDisconnected?.()
}
constructor(
public readonly id: string,
public readonly server: TestServer<R>
) {}
}

View File

@@ -0,0 +1,714 @@
import { BaseRecord, RecordId, StoreSchema, createRecordType } from '@tldraw/store'
import { vi, type Mock } from 'vitest'
import { RecordOpType, ValueOpType } from '../lib/diff'
import { InMemorySyncStorage } from '../lib/InMemorySyncStorage'
import {
TLConnectRequest,
TLPushRequest,
TLSocketServerSentEvent,
getTlsyncProtocolVersion,
} from '../lib/protocol'
import { TLSocketRoom } from '../lib/TLSocketRoom'
import { TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason } from '../lib/TLSyncClient'
import { RoomSnapshot, TLRoomSocket, TLSyncRoom } from '../lib/TLSyncRoom'
// A generic object-lane test schema: `doc` records live in the document lane, `note` records are
// served through the object-store lane (see `objectTypes` below). Deliberately not comment-shaped —
// the lane is a generic primitive.
interface DocRecord extends BaseRecord<'doc', RecordId<DocRecord>> {
title: string
}
interface NoteRecord extends BaseRecord<'note', RecordId<NoteRecord>> {
text: string
}
interface PresenceRecord extends BaseRecord<'presence', RecordId<PresenceRecord>> {
name: string
}
const Doc = createRecordType<DocRecord>('doc', {
scope: 'document',
validator: { validate: (value) => value as DocRecord },
})
const Note = createRecordType<NoteRecord>('note', {
scope: 'document',
validator: { validate: (value) => value as NoteRecord },
})
const Presence = createRecordType<PresenceRecord>('presence', {
scope: 'presence',
validator: { validate: (value) => value as PresenceRecord },
})
type R = DocRecord | NoteRecord | PresenceRecord
const schema = StoreSchema.create<R>({ doc: Doc, note: Note, presence: Presence })
type MockSocket = TLRoomSocket<any> & {
__messages: TLSocketServerSentEvent<any>[]
sendMessage: Mock
close: Mock
}
function makeSocket(): MockSocket {
const socket: MockSocket = {
__messages: [],
// cloning because the room reuses/clears message objects after sending
sendMessage: vi.fn((msg: TLSocketServerSentEvent<any>) => {
socket.__messages.push(structuredClone(msg))
}),
close: vi.fn(() => {
socket.isOpen = false
}),
isOpen: true,
}
return socket
}
function sentDataMessages(socket: MockSocket): any[] {
return socket.__messages.flatMap((msg: any) => (msg.type === 'data' ? msg.data : []))
}
function clearSocket(socket: MockSocket) {
socket.sendMessage.mockClear()
socket.__messages.length = 0
}
const disposables: Array<() => void> = []
afterEach(() => {
for (const dispose of disposables) dispose()
disposables.length = 0
})
function makeRoom(
opts: {
snapshot?: RoomSnapshot
objectTypes?: readonly string[]
onCommittedChanges?(args: { diff: any; documentClock: number }): void
authorizeRecord?: {
[typeName: string]: (args: {
session: { sessionId: string; isReadonly: boolean; meta: any }
type: 'create' | 'update' | 'delete'
prev: any
next: any
}) => any
}
log?: any
} = {}
) {
const storage = new InMemorySyncStorage<R>({
snapshot: opts.snapshot ?? {
documents: [],
clock: 0,
documentClock: 0,
schema: schema.serialize(),
},
// keep the storage partition in step with the room's object lane
objectTypes: opts.objectTypes,
})
const room = new TLSyncRoom<R, undefined>({
schema,
storage,
objectTypes: opts.objectTypes,
onCommittedChanges: opts.onCommittedChanges,
authorizeRecord: opts.authorizeRecord,
log: opts.log,
})
disposables.push(() => room.close())
return { storage, room }
}
function connectSession(
room: TLSyncRoom<any, any>,
sessionId: string,
opts: {
isReadonly?: boolean
objectAccess?: 'read' | 'write'
clear?: boolean
meta?: any
} = {}
): MockSocket {
const socket = makeSocket()
room.handleNewSession({
sessionId,
socket,
meta: opts.meta,
isReadonly: opts.isReadonly ?? false,
objectAccess: opts.objectAccess,
})
room.handleMessage(sessionId, {
type: 'connect',
connectRequestId: 'connect-' + sessionId,
lastServerClock: 0,
protocolVersion: getTlsyncProtocolVersion(),
schema: room.serializedSchema,
} satisfies TLConnectRequest)
if (opts.clear !== false) {
clearSocket(socket)
}
return socket
}
function push(
room: TLSyncRoom<any, any>,
sessionId: string,
diff: TLPushRequest<any>['diff'],
clientClock = 1
) {
room.handleMessage(sessionId, { type: 'push', clientClock, diff } satisfies TLPushRequest<any>)
}
function lastPushResult(socket: MockSocket) {
return sentDataMessages(socket)
.filter((m) => m.type === 'push_result')
.at(-1)
}
function storedIds(storage: InMemorySyncStorage<R>) {
// span both partitions: the document snapshot no longer contains object-lane records
return [...storage.getSnapshot().documents, ...storage.getObjectsSnapshot()]
.map((d) => d.state.id)
.sort()
}
describe('object store lane', () => {
describe('type partition', () => {
it('excludes object types from documentTypes and exposes them via objectTypes', () => {
const { room } = makeRoom({ objectTypes: ['note'] })
expect(room.documentTypes.has('doc')).toBe(true)
expect(room.documentTypes.has('note')).toBe(false)
expect(room.objectTypes.has('note')).toBe(true)
})
it('keeps documentTypes intact when no objectTypes are configured', () => {
const { room } = makeRoom()
expect(room.documentTypes.has('doc')).toBe(true)
expect(room.documentTypes.has('note')).toBe(true)
expect(room.objectTypes.size).toBe(0)
})
it('rejects object types that are not registered in the schema', () => {
expect(() => makeRoom({ objectTypes: ['nonexistent'] })).toThrow(
"object type 'nonexistent' is not registered"
)
})
it('rejects object types that are not document-scoped', () => {
expect(() => makeRoom({ objectTypes: ['presence'] })).toThrow(
"object type 'presence' must have scope 'document'"
)
})
})
describe('object writes', () => {
it('commits object puts, broadcasts them, and hydrates them on connect', () => {
const { room, storage } = makeRoom({ objectTypes: ['note'] })
const writer = connectSession(room, 'writer')
const observer = connectSession(room, 'observer')
const note = Note.create({ text: 'hello' })
push(room, 'writer', { [note.id]: [RecordOpType.Put, note] })
expect(lastPushResult(writer)).toMatchObject({ action: 'commit' })
expect(storedIds(storage)).toEqual([note.id])
// broadcast to the other session
room._flushDataMessages('observer')
expect(
sentDataMessages(observer).find(
(m) => m.type === 'patch' && m.diff[note.id]?.[0] === RecordOpType.Put
)
).toBeDefined()
// a fresh connect hydrates the object record
const late = connectSession(room, 'late', { clear: false })
const connectMsg = late.__messages.find((m: any) => m.type === 'connect') as any
expect(connectMsg.diff[note.id]).toEqual([RecordOpType.Put, note])
})
it('still rejects unknown record types for writable sessions', () => {
const { room } = makeRoom({ objectTypes: ['note'] })
const socket = connectSession(room, 'writer')
push(room, 'writer', {
'mystery:1': [RecordOpType.Put, { id: 'mystery:1', typeName: 'mystery' } as any],
})
expect(socket.close).toHaveBeenCalledWith(
TLSyncErrorCloseEventCode,
TLSyncErrorCloseEventReason.INVALID_RECORD
)
})
it('skips unknown record types for denied sessions without rejecting (legacy readonly behavior)', () => {
const { room, storage } = makeRoom({ objectTypes: ['note'] })
const socket = connectSession(room, 'reader', { isReadonly: true })
push(room, 'reader', {
'mystery:1': [RecordOpType.Put, { id: 'mystery:1', typeName: 'mystery' } as any],
})
expect(socket.close).not.toHaveBeenCalled()
expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
expect(storedIds(storage)).toEqual([])
})
})
describe('permission axes', () => {
it('lets a readonly session with object write access write objects but not documents', () => {
const { room, storage } = makeRoom({ objectTypes: ['note'] })
// "can comment but not edit"
const socket = connectSession(room, 'commenter', {
isReadonly: true,
objectAccess: 'write',
})
const doc = Doc.create({ title: 'nope' })
const note = Note.create({ text: 'yep' })
push(room, 'commenter', {
[doc.id]: [RecordOpType.Put, doc],
[note.id]: [RecordOpType.Put, note],
})
// only the object op survives, so the client is rebased onto it
expect(lastPushResult(socket)).toMatchObject({
action: { rebaseWithDiff: { [note.id]: [RecordOpType.Put, note] } },
})
expect(storedIds(storage)).toEqual([note.id])
})
it('blocks object writes for objectAccess: read while document writes still commit', () => {
const { room, storage } = makeRoom({ objectTypes: ['note'] })
const socket = connectSession(room, 'editor', { objectAccess: 'read' })
const doc = Doc.create({ title: 'yep' })
const note = Note.create({ text: 'nope' })
push(room, 'editor', {
[doc.id]: [RecordOpType.Put, doc],
[note.id]: [RecordOpType.Put, note],
})
expect(lastPushResult(socket)).toMatchObject({
action: { rebaseWithDiff: { [doc.id]: [RecordOpType.Put, doc] } },
})
expect(storedIds(storage)).toEqual([doc.id])
})
it('discards object-only pushes entirely for objectAccess: read', () => {
const { room, storage } = makeRoom({ objectTypes: ['note'] })
const socket = connectSession(room, 'reader', { objectAccess: 'read' })
const note = Note.create({ text: 'nope' })
push(room, 'reader', { [note.id]: [RecordOpType.Put, note] })
expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
expect(storedIds(storage)).toEqual([])
})
it('gates object patches and removes by objectAccess', () => {
const note = Note.create({ text: 'original' })
const { room, storage } = makeRoom({
objectTypes: ['note'],
snapshot: {
documents: [{ state: note, lastChangedClock: 0 }],
clock: 0,
documentClock: 0,
schema: schema.serialize(),
},
})
const reader = connectSession(room, 'reader', { objectAccess: 'read' })
push(room, 'reader', {
[note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, 'tampered'] }],
})
expect(lastPushResult(reader)).toMatchObject({ action: 'discard' })
push(room, 'reader', { [note.id]: [RecordOpType.Remove] }, 2)
expect(lastPushResult(reader)).toMatchObject({ action: 'discard' })
expect(storedIds(storage)).toEqual([note.id])
// a readonly-but-object-writable session can remove the object record
connectSession(room, 'commenter', { isReadonly: true, objectAccess: 'write' })
push(room, 'commenter', { [note.id]: [RecordOpType.Remove] })
expect(storedIds(storage)).toEqual([])
})
})
describe('storage partition', () => {
it('keeps object records out of the document snapshot but in the objects snapshot', () => {
const { room, storage } = makeRoom({ objectTypes: ['note'] })
connectSession(room, 'writer')
const doc = Doc.create({ title: 'canvas' })
const note = Note.create({ text: 'annotation' })
push(room, 'writer', {
[doc.id]: [RecordOpType.Put, doc],
[note.id]: [RecordOpType.Put, note],
})
// the document snapshot is pure-document by construction
expect(storage.getSnapshot().documents.map((d) => d.state.id)).toEqual([doc.id])
// the object lane is persisted separately
expect(storage.getObjectsSnapshot().map((d) => d.state.id)).toEqual([note.id])
// but a fresh connect still hydrates both (shared clock + change feed)
const late = connectSession(room, 'late', { clear: false })
const connectMsg = late.__messages.find((m: any) => m.type === 'connect') as any
expect(Object.keys(connectMsg.diff).sort()).toEqual([doc.id, note.id].sort())
})
it('[US1][US3] updateStore sees and can delete object-lane records', async () => {
// eslint-disable-next-line @typescript-eslint/no-deprecated
const socketRoom = new TLSocketRoom<R, void>({
schema,
storage: new InMemorySyncStorage<R>({
snapshot: { documents: [], clock: 0, documentClock: 0, schema: schema.serialize() },
objectTypes: ['note'],
}),
objectTypes: ['note'],
})
disposables.push(() => socketRoom.close())
const doc = Doc.create({ title: 'canvas' })
const note = Note.create({ text: 'annotation' })
socketRoom.storage.transaction((txn) => {
txn.set(doc.id, doc)
txn.set(note.id, note)
})
// the update context reads both lanes, not just the document snapshot
// eslint-disable-next-line @typescript-eslint/no-deprecated
await socketRoom.updateStore((store) => {
expect(store.get(note.id)).toEqual(note)
expect(
store
.getAll()
.map((r) => r.id)
.sort()
).toEqual([doc.id, note.id].sort())
store.delete(note.id)
})
expect(socketRoom.getRecord(note.id)).toBeUndefined()
expect(socketRoom.getRecord(doc.id)).toEqual(doc)
})
})
describe('connect message', () => {
it('carries the session objectAccess', () => {
const { room } = makeRoom({ objectTypes: ['note'] })
const writer = connectSession(room, 'writer', { clear: false })
const writerConnect = writer.__messages.find((m: any) => m.type === 'connect') as any
expect(writerConnect.objectAccess).toBe('write')
const reader = connectSession(room, 'reader', { objectAccess: 'read', clear: false })
const readerConnect = reader.__messages.find((m: any) => m.type === 'connect') as any
expect(readerConnect.objectAccess).toBe('read')
})
})
describe('onCommittedChanges projection hook', () => {
it('fires with the committed object diff', async () => {
const onCommittedChanges = vi.fn()
const { room } = makeRoom({ objectTypes: ['note'], onCommittedChanges })
connectSession(room, 'writer')
const note = Note.create({ text: 'hello' })
push(room, 'writer', { [note.id]: [RecordOpType.Put, note] })
await Promise.resolve() // the hook fires on a microtask
expect(onCommittedChanges).toHaveBeenCalledTimes(1)
expect(onCommittedChanges.mock.calls[0][0].diff.puts[note.id]).toEqual(note)
})
it('does not fire when every op in the push was denied', async () => {
const onCommittedChanges = vi.fn()
const { room } = makeRoom({ objectTypes: ['note'], onCommittedChanges })
connectSession(room, 'reader', { objectAccess: 'read' })
const note = Note.create({ text: 'nope' })
push(room, 'reader', { [note.id]: [RecordOpType.Put, note] })
await Promise.resolve()
expect(onCommittedChanges).not.toHaveBeenCalled()
})
})
describe('authorizeRecord', () => {
it('rejects a presence typeName key at construction', () => {
expect(() => makeRoom({ authorizeRecord: { presence: ({ next }: any) => next } })).toThrow(
/presence/
)
})
// A per-type authorizer like dotcom's: stamp the note's text from the session's user id on
// create, keep it immutable on update, allow deletes.
const authorizeNote = ({ session, type, prev, next }: any) => {
if (type === 'create') {
const userId = session.meta?.userId
if (!userId) return null
return { ...next, text: `by:${userId}` }
}
if (type === 'update') return next.text === prev.text ? next : null
return prev // delete allowed
}
function withNote(authorize: any, extra: any = {}) {
return makeRoom({ objectTypes: ['note'], authorizeRecord: { note: authorize }, ...extra })
}
function seededWithNote(authorize: any) {
const note = Note.create({ text: 'by:user-alice' })
const { room, storage } = withNote(authorize, {
snapshot: {
documents: [{ state: note, lastChangedClock: 0 }],
clock: 0,
documentClock: 0,
schema: schema.serialize(),
},
})
return { room, storage, note }
}
const storedNote = (storage: InMemorySyncStorage<R>, id: string) =>
storage.getObjectsSnapshot().find((d) => d.state.id === id)?.state as any
it('exposes the session isReadonly flag to the authorizer', () => {
const authorize = vi.fn(({ next }: any) => next)
const { room } = withNote(authorize)
// a canvas-readonly session can still write the object lane (objectAccess defaults to
// 'write'), which is exactly why authorizers need to see isReadonly independently
connectSession(room, 'reader', { isReadonly: true, meta: { userId: 'u' } })
const note = Note.create({ text: 'x' })
push(room, 'reader', { [note.id]: [RecordOpType.Put, note] })
expect(authorize.mock.calls[0][0].session.isReadonly).toBe(true)
authorize.mockClear()
connectSession(room, 'writer', { isReadonly: false, meta: { userId: 'u' } })
const note2 = Note.create({ text: 'y' })
push(room, 'writer', { [note2.id]: [RecordOpType.Put, note2] })
expect(authorize.mock.calls[0][0].session.isReadonly).toBe(false)
})
it('stamps a created record from the session, overriding the client value', () => {
const { room, storage } = withNote(authorizeNote)
const socket = connectSession(room, 'alice', { meta: { userId: 'user-alice' } })
const note = Note.create({ text: 'forged' })
push(room, 'alice', { [note.id]: [RecordOpType.Put, note] })
// the server rewrote the record, so it rebases the client onto the stamped value
expect(lastPushResult(socket)).toMatchObject({
action: {
rebaseWithDiff: { [note.id]: [RecordOpType.Put, { ...note, text: 'by:user-alice' }] },
},
})
expect(storedNote(storage, note.id)?.text).toBe('by:user-alice')
})
it('rejects a create the authorizer denies (returns null)', () => {
const { room, storage } = withNote(authorizeNote)
const socket = connectSession(room, 'anon', { meta: { userId: null } })
const note = Note.create({ text: 'nope' })
push(room, 'anon', { [note.id]: [RecordOpType.Put, note] })
expect(socket.close).not.toHaveBeenCalled()
expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
expect(storedIds(storage)).toEqual([])
})
it('only runs for registered types — other records write through untouched', () => {
const seen: string[] = []
const { room, storage } = withNote(({ next }: any) => {
seen.push('note')
return next
})
connectSession(room, 'writer', { meta: { userId: 'u' } })
const doc = Doc.create({ title: 'hi' })
push(room, 'writer', { [doc.id]: [RecordOpType.Put, doc] })
expect(seen).toEqual([])
expect(storedIds(storage)).toEqual([doc.id])
})
it('authorizes document-lane record types', () => {
// authorize `doc`, a document-lane record
const { room, storage } = makeRoom({
objectTypes: ['note'],
authorizeRecord: {
doc: ({ type, next }: any) => (type === 'create' ? { ...next, title: 'stamped' } : next),
},
})
connectSession(room, 'writer', { meta: { userId: 'u' } })
const doc = Doc.create({ title: 'client' })
push(room, 'writer', { [doc.id]: [RecordOpType.Put, doc] })
const stored = storage.getSnapshot().documents.find((d) => d.state.id === doc.id)
?.state as any
expect(stored?.title).toBe('stamped')
})
it('vetoes an update that changes an immutable field (patch)', () => {
const { room, storage, note } = seededWithNote(authorizeNote)
const socket = connectSession(room, 'mallory', { meta: { userId: 'user-mallory' } })
push(room, 'mallory', {
[note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, 'tampered'] }],
})
expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
expect(storedNote(storage, note.id)?.text).toBe('by:user-alice')
})
it('allows an update the authorizer permits (patch)', () => {
const { room, storage, note } = seededWithNote(({ type, prev, next }: any) =>
type === 'delete' ? prev : next
)
connectSession(room, 'alice', { meta: { userId: 'u' } })
push(room, 'alice', {
[note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, 'edited'] }],
})
expect(storedNote(storage, note.id)?.text).toBe('edited')
})
it('does not consult the authorizer for a no-op patch', () => {
const authorize = vi.fn(({ type, prev, next }: any) => (type === 'delete' ? prev : next))
const { room, note } = seededWithNote(authorize)
connectSession(room, 'alice', { meta: { userId: 'u' } })
authorize.mockClear()
push(room, 'alice', {
[note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, note.text] }],
})
expect(authorize).not.toHaveBeenCalled()
})
it('vetoes a delete the authorizer rejects', () => {
const { room, storage, note } = seededWithNote(({ type, next }: any) =>
type === 'delete' ? null : next
)
const socket = connectSession(room, 'mallory', { meta: { userId: 'm' } })
push(room, 'mallory', { [note.id]: [RecordOpType.Remove] })
expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
expect(storedIds(storage)).toEqual([note.id])
})
it('vetoes a put that changes the typeName of an existing record', () => {
const { room, storage, note } = seededWithNote(authorizeNote)
const socket = connectSession(room, 'mallory', { meta: { userId: 'user-mallory' } })
// a `doc`-shaped record reusing the note's id: the authorizer lookup keys off the incoming
// typeName, so the swap would consult the doc authorizer (here: none) instead of note's
push(room, 'mallory', {
[note.id]: [RecordOpType.Put, { id: note.id, typeName: 'doc', title: 'swapped' } as any],
})
expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
expect(storedNote(storage, note.id)?.typeName).toBe('note')
})
// The typeName guard must not depend on authorizers being configured: the write gate keys
// off the *incoming* typeName, so relabeling an existing object-lane record as a document
// type would otherwise let an objectAccess:'read' session write through the document lane.
it('vetoes a typeName swap even with no authorizeRecord configured', () => {
const note = Note.create({ text: 'by:user-alice' })
const { room, storage } = makeRoom({
objectTypes: ['note'],
snapshot: {
documents: [{ state: note, lastChangedClock: 0 }],
clock: 0,
documentClock: 0,
schema: schema.serialize(),
},
})
// denied on the object lane, but allowed on the document lane
const socket = connectSession(room, 'mallory', {
objectAccess: 'read',
meta: { userId: 'user-mallory' },
})
push(room, 'mallory', {
[note.id]: [RecordOpType.Put, { id: note.id, typeName: 'doc', title: 'swapped' } as any],
})
expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
expect(storedNote(storage, note.id)?.typeName).toBe('note')
expect(storedNote(storage, note.id)?.text).toBe('by:user-alice')
})
it('vetoes a put over an existing record that changes an immutable field', () => {
const { room, storage, note } = seededWithNote(authorizeNote)
const socket = connectSession(room, 'mallory', { meta: { userId: 'user-mallory' } })
push(room, 'mallory', { [note.id]: [RecordOpType.Put, { ...note, text: 'tampered' }] })
expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
expect(storedNote(storage, note.id)?.text).toBe('by:user-alice')
})
it('ignores the record returned by an update authorizer (allow/veto only)', () => {
const { room, storage, note } = seededWithNote(({ type, prev, next }: any) => {
if (type === 'update') return { ...next, text: 'stamped-on-update' }
return type === 'delete' ? prev : next
})
connectSession(room, 'alice', { meta: { userId: 'u' } })
push(room, 'alice', { [note.id]: [RecordOpType.Put, { ...note, text: 'edited' }] })
expect(storedNote(storage, note.id)?.text).toBe('edited')
})
it('rejects the write (fail closed) when the authorizer throws, without crashing the push', () => {
const { room, storage } = withNote(() => {
throw new Error('boom')
})
const socket = connectSession(room, 'alice', { meta: { userId: 'u' } })
const note = Note.create({ text: 'x' })
expect(() => push(room, 'alice', { [note.id]: [RecordOpType.Put, note] })).not.toThrow()
expect(lastPushResult(socket)).toMatchObject({ action: 'discard' })
expect(storedIds(storage)).toEqual([])
})
it('passes the change type for create, update, and delete', () => {
const types: string[] = []
const note = Note.create({ text: 'x' })
const { room } = withNote(({ type, prev, next }: any) => {
types.push(type)
return type === 'delete' ? prev : next
})
connectSession(room, 'alice', { meta: { userId: 'u' } })
push(room, 'alice', { [note.id]: [RecordOpType.Put, note] }, 1)
push(room, 'alice', { [note.id]: [RecordOpType.Patch, { text: [ValueOpType.Put, 'y'] }] }, 2)
push(room, 'alice', { [note.id]: [RecordOpType.Remove] }, 3)
expect(types).toEqual(['create', 'update', 'delete'])
})
it('logs a warning when a write is vetoed', () => {
const warn = vi.fn()
const note = Note.create({ text: 'by:user-alice' })
const { room } = makeRoom({
objectTypes: ['note'],
authorizeRecord: { note: () => null },
log: { warn },
})
connectSession(room, 'mallory', { meta: { userId: 'user-mallory' } })
push(room, 'mallory', { [note.id]: [RecordOpType.Put, note] })
expect(warn).toHaveBeenCalledTimes(1)
expect(warn.mock.calls[0].join(' ')).toContain(note.id)
})
})
})

View File

@@ -0,0 +1,719 @@
import { DocumentRecordType, PageRecordType, TLDOCUMENT_ID, TLRecord } from '@tldraw/tlschema'
import { createTLSchema } from '@tldraw/tlschema'
import { IndexKey, ZERO_INDEX_KEY } from '@tldraw/utils'
import { vi } from 'vitest'
import { MAX_TOMBSTONES, TOMBSTONE_PRUNE_BUFFER_SIZE } from '../lib/InMemorySyncStorage'
import { RoomSnapshot } from '../lib/TLSyncRoom'
import { TLSyncStorage, TLSyncStorageOnChangeCallbackProps } from '../lib/TLSyncStorage'
export const contractSchema = createTLSchema()
export function makeContractSnapshot(
records: TLRecord[],
others: Partial<RoomSnapshot> = {}
): RoomSnapshot {
return {
documents: records.map((r) => ({ state: r, lastChangedClock: 0 })),
clock: 0,
documentClock: 0,
schema: contractSchema.serialize(),
...others,
}
}
export const contractRecords = [
DocumentRecordType.create({ id: TLDOCUMENT_ID }),
PageRecordType.create({
index: ZERO_INDEX_KEY,
name: 'Page 1',
id: PageRecordType.createId('page_1'),
}),
]
export function makePage(id: string, name = id, index = 'a2') {
return PageRecordType.create({
id: PageRecordType.createId(id),
name,
index: index as IndexKey,
})
}
export interface StorageContractFactory {
create(opts?: {
snapshot?: RoomSnapshot
objectTypes?: readonly string[]
onChange?(arg: TLSyncStorageOnChangeCallbackProps): void
}): TLSyncStorage<TLRecord>
}
/**
* The shared behavior suite for the storage contract (SPEC.md section 10, SS rules).
* Runs against both InMemorySyncStorage and SQLiteSyncStorage.
*/
export function registerStorageContractTests(factory: StorageContractFactory) {
const create = (snapshot?: RoomSnapshot) => factory.create(snapshot ? { snapshot } : undefined)
describe('storage contract', () => {
it('[SS1] seeds from DEFAULT_INITIAL_SNAPSHOT when constructed without a snapshot', () => {
const storage = factory.create()
const snapshot = storage.getSnapshot!()
expect(snapshot.documentClock).toBe(0)
expect(snapshot.documents.map((d) => d.state.typeName).sort()).toEqual(['document', 'page'])
expect(snapshot.documents.map((d) => d.lastChangedClock)).toEqual([0, 0])
})
describe('object-store lane partition', () => {
// the storage doesn't care about record semantics, so 'page' stands in as a
// generic object-lane type here
const createPartitioned = (snapshot?: RoomSnapshot) =>
factory.create({ snapshot, objectTypes: ['page'] })
it('partitions object-lane records out of getSnapshot into getObjectsSnapshot', () => {
const storage = createPartitioned(
makeContractSnapshot(contractRecords, { documentClock: 5 })
)
const snapshot = storage.getSnapshot!()
expect(snapshot.documents.map((d) => d.state.typeName)).toEqual(['document'])
// clock + schema are unaffected by the partition
expect(snapshot.documentClock).toBe(5)
expect(storage.getObjectsSnapshot!().map((d) => d.state.typeName)).toEqual(['page'])
})
it('returns an empty objects snapshot when no objectTypes are configured', () => {
const storage = create(makeContractSnapshot(contractRecords))
expect(storage.getSnapshot!().documents.length).toBe(2)
expect(storage.getObjectsSnapshot!()).toEqual([])
})
it('routes writes by type and shares the clock, tombstones, and change feed', () => {
const storage = createPartitioned(makeContractSnapshot(contractRecords))
const newPage = makePage('lane')
storage.transaction((txn) => txn.set(newPage.id, newPage))
// the write landed in the object partition, not the document snapshot
expect(storage.getSnapshot!().documents.map((d) => d.state.id)).toEqual([TLDOCUMENT_ID])
expect(storage.getObjectsSnapshot!().map((d) => d.state.id)).toContain(newPage.id)
// reads and the change feed span both partitions
storage.transaction((txn) => {
expect(txn.get(newPage.id)).toEqual(newPage)
expect(txn.getChangesSince(0)!.diff.puts[newPage.id]).toEqual(newPage)
})
// deleting an object-lane record writes a shared tombstone
const clockBeforeDelete = storage.getClock()
storage.transaction((txn) => txn.delete(newPage.id))
expect(storage.getObjectsSnapshot!().map((d) => d.state.id)).not.toContain(newPage.id)
storage.transaction((txn) => {
expect(txn.getChangesSince(clockBeforeDelete)!.diff.deletes).toContain(newPage.id)
})
})
it('iterates both partitions so schema migrations cover object-lane records', () => {
const storage = createPartitioned(makeContractSnapshot(contractRecords))
storage.transaction((txn) => {
const ids = Array.from(txn.keys()).sort()
expect(ids).toEqual(contractRecords.map((r) => r.id).sort())
expect(
Array.from(txn.values())
.map((r) => r.id)
.sort()
).toEqual(ids)
expect(
Array.from(txn.entries())
.map(([id]) => id)
.sort()
).toEqual(ids)
})
})
it('returns targeted object-lane entries by id from getObjectsByIds', () => {
const storage = createPartitioned(makeContractSnapshot(contractRecords))
const newPage = makePage('lane')
storage.transaction((txn) => txn.set(newPage.id, newPage))
const pageId = contractRecords[1].id
const entries = storage.getObjectsByIds!([newPage.id, pageId])
expect(entries.map((d) => d.state.id).sort()).toEqual([newPage.id, pageId].sort())
// same entry shape as getObjectsSnapshot: state + lastChangedClock
const laneEntry = entries.find((d) => d.state.id === newPage.id)!
expect(laneEntry.state).toEqual(newPage)
expect(laneEntry.lastChangedClock).toBe(storage.getClock())
// unknown ids and document-lane ids are omitted; duplicates yield one entry
expect(storage.getObjectsByIds!(['page:nope', TLDOCUMENT_ID])).toEqual([])
expect(storage.getObjectsByIds!([newPage.id, newPage.id]).length).toBe(1)
// a deleted record stops being returned
storage.transaction((txn) => txn.delete(newPage.id))
expect(storage.getObjectsByIds!([newPage.id])).toEqual([])
})
it('round-trips through a merged snapshot (persist lanes separately, re-seed together)', () => {
const storage = createPartitioned(makeContractSnapshot(contractRecords))
const merged = {
...storage.getSnapshot!(),
documents: [...storage.getSnapshot!().documents, ...storage.getObjectsSnapshot!()],
}
const reseeded = createPartitioned(merged)
expect(reseeded.getSnapshot!().documents.map((d) => d.state.typeName)).toEqual(['document'])
expect(reseeded.getObjectsSnapshot!().map((d) => d.state.typeName)).toEqual(['page'])
})
})
describe('transactions', () => {
it('[SS2] returns the callback result along with clock metadata', () => {
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 7 }))
const res = storage.transaction((txn) => txn.get(TLDOCUMENT_ID))
expect(res).toEqual({
documentClock: 7,
didChange: false,
result: expect.objectContaining({ id: TLDOCUMENT_ID }),
changes: undefined,
})
})
it('[SS3] didChange is true exactly when the clock advanced', () => {
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 10 }))
const read = storage.transaction((txn) => void txn.get(TLDOCUMENT_ID))
expect(read.didChange).toBe(false)
expect(read.documentClock).toBe(10)
const write = storage.transaction((txn) =>
txn.set(contractRecords[0].id, contractRecords[0])
)
expect(write.didChange).toBe(true)
expect(write.documentClock).toBe(11)
expect(storage.getClock()).toBe(11)
})
it('[SS4] a callback that returns a promise throws', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const storage = create(makeContractSnapshot(contractRecords))
expect(() => storage.transaction(() => Promise.resolve() as any)).toThrow(
'Transaction must return a value, not a promise'
)
consoleSpy.mockRestore()
})
it('[SS5] a throwing callback rolls back documents, tombstones, the schema, and the clock', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 10 }))
const newPage = makePage('new')
expect(() =>
storage.transaction((txn) => {
txn.set(newPage.id, newPage)
txn.delete(contractRecords[1].id)
txn.setSchema({ ...contractSchema.serialize(), schemaVersion: 99 as any })
throw new Error('Oops after write!')
})
).toThrow('Oops after write!')
const snapshot = storage.getSnapshot!()
expect(storage.getClock()).toBe(10)
expect(snapshot.documents.find((d) => d.state.id === newPage.id)).toBeUndefined()
expect(snapshot.documents.find((d) => d.state.id === contractRecords[1].id)).toBeDefined()
expect(snapshot.tombstones).toEqual({})
expect(snapshot.schema).toEqual(contractSchema.serialize())
consoleSpy.mockRestore()
})
it('[SS5] a throwing callback does not notify onChange', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const listener = vi.fn()
const storage = factory.create({
snapshot: makeContractSnapshot(contractRecords),
onChange: listener,
})
await Promise.resolve()
expect(() =>
storage.transaction((txn) => {
txn.set(contractRecords[1].id, { ...contractRecords[1], name: 'changed' } as TLRecord)
throw new Error('rollback')
})
).toThrow('rollback')
await Promise.resolve()
await Promise.resolve()
expect(listener).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('[SS8] txn.getClock returns the start clock, then the incremented clock after a write', () => {
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 42 }))
storage.transaction((txn) => {
expect(txn.getClock()).toBe(42)
const newPage = makePage('new')
txn.set(newPage.id, newPage)
expect(txn.getClock()).toBe(43)
})
expect(storage.getClock()).toBe(43)
})
it('[SS9] the clock increments exactly once per writing transaction', () => {
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 10 }))
storage.transaction((txn) => {
const p1 = makePage('p1', 'P1', 'a2')
const p2 = makePage('p2', 'P2', 'a3')
txn.set(p1.id, p1)
expect(txn.getClock()).toBe(11)
txn.set(p2.id, p2)
expect(txn.getClock()).toBe(11)
txn.delete(contractRecords[1].id)
expect(txn.getClock()).toBe(11)
})
expect(storage.getClock()).toBe(11)
})
})
describe('reads and writes', () => {
it('[SS10] get returns the record, or undefined when absent', () => {
const storage = create(makeContractSnapshot(contractRecords))
storage.transaction((txn) => {
expect(txn.get(TLDOCUMENT_ID)).toEqual(contractRecords[0])
expect(txn.get('nonexistent')).toBeUndefined()
})
})
it('[SS11] set creates and updates records, stamping lastChangedClock', () => {
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 5 }))
const newPage = makePage('new_page', 'New Page')
storage.transaction((txn) => {
txn.set(newPage.id, newPage)
})
const entry = storage.getSnapshot!().documents.find((d) => d.state.id === newPage.id)
expect(entry).toEqual({ state: newPage, lastChangedClock: 6 })
const updated = { ...newPage, name: 'Updated' } as TLRecord
storage.transaction((txn) => txn.set(newPage.id, updated))
expect(storage.getSnapshot!().documents.find((d) => d.state.id === newPage.id)).toEqual({
state: updated,
lastChangedClock: 7,
})
})
it('[SS11] set asserts that the key matches record.id', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const storage = create(makeContractSnapshot(contractRecords))
const page = makePage('actual_id')
expect(() =>
storage.transaction((txn) => {
txn.set('different:key', page)
})
).toThrow('Record id mismatch')
consoleSpy.mockRestore()
})
it('[SS11] set clears the tombstone when re-creating a deleted record', () => {
const page = makePage('page_to_delete')
const storage = create(makeContractSnapshot([...contractRecords, page]))
storage.transaction((txn) => txn.delete(page.id))
expect(Object.keys(storage.getSnapshot!().tombstones!)).toEqual([page.id])
storage.transaction((txn) => txn.set(page.id, page))
expect(storage.getSnapshot!().tombstones).toEqual({})
expect(storage.getSnapshot!().documents.find((d) => d.state.id === page.id)).toBeDefined()
})
it('[SS12] delete removes the record and writes a tombstone at the new clock', () => {
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 10 }))
const pageId = contractRecords[1].id
storage.transaction((txn) => txn.delete(pageId))
const snapshot = storage.getSnapshot!()
expect(snapshot.documents.find((d) => d.state.id === pageId)).toBeUndefined()
expect(snapshot.tombstones).toEqual({ [pageId]: 11 })
})
it('[SS12] deleting an absent id is a complete no-op', () => {
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 10 }))
const { didChange, documentClock } = storage.transaction((txn) =>
txn.delete('nonexistent:record')
)
expect(didChange).toBe(false)
expect(documentClock).toBe(10)
expect(storage.getSnapshot!().tombstones).toEqual({})
})
})
describe('iteration', () => {
it('[SS13] entries/keys/values iterate the documents', () => {
const storage = create(makeContractSnapshot(contractRecords))
const ids = contractRecords.map((r) => r.id).sort()
storage.transaction((txn) => {
expect(
Array.from(txn.entries())
.map(([id]) => id)
.sort()
).toEqual(ids)
expect(Array.from(txn.keys()).sort()).toEqual(ids)
expect(
Array.from(txn.values())
.map((v) => v.id)
.sort()
).toEqual(ids)
})
})
it.each(['entries', 'keys', 'values'] as const)(
'[SS13] consuming the %s iterator after the transaction ends throws',
(method) => {
const storage = create(makeContractSnapshot(contractRecords))
let iterator!: Iterator<unknown>
storage.transaction((txn) => {
iterator = txn[method]()[Symbol.iterator]()
iterator.next()
})
expect(() => iterator.next()).toThrow('Transaction has ended')
}
)
})
describe('schema', () => {
it('[SS14] getSchema and setSchema read and write the persisted schema', () => {
const snapshot = makeContractSnapshot(contractRecords)
const storage = create(snapshot)
const newSchema = { ...contractSchema.serialize(), schemaVersion: 99 as any }
storage.transaction((txn) => {
expect(txn.getSchema()).toEqual(snapshot.schema)
txn.setSchema(newSchema)
expect(txn.getSchema()).toEqual(newSchema)
})
expect(storage.getSnapshot!().schema).toEqual(newSchema)
})
})
describe('getChangesSince', () => {
const seeded = () =>
create(
makeContractSnapshot(contractRecords, {
documents: [
{ state: contractRecords[0], lastChangedClock: 5 },
{ state: contractRecords[1], lastChangedClock: 10 },
],
tombstones: { 'shape:deleted1': 5, 'shape:deleted2': 12 },
documentClock: 15,
tombstoneHistoryStartsAtClock: 0,
})
)
it('[SS15] returns undefined when sinceClock equals the current clock', () => {
seeded().transaction((txn) => {
expect(txn.getChangesSince(15)).toBeUndefined()
})
})
it('[SS15] returns puts for documents changed strictly after sinceClock', () => {
seeded().transaction((txn) => {
const changes = txn.getChangesSince(5)!
expect(changes.wipeAll).toBe(false)
expect(Object.keys(changes.diff.puts)).toEqual([contractRecords[1].id])
})
})
it('[SS15] returns deletes for tombstones strictly after sinceClock', () => {
seeded().transaction((txn) => {
const changes = txn.getChangesSince(5)!
expect(changes.diff.deletes).toEqual(['shape:deleted2'])
})
})
it('[SS15] a sinceClock in the future is treated as "everything changed"', () => {
seeded().transaction((txn) => {
const changes = txn.getChangesSince(100)!
expect(changes.wipeAll).toBe(true)
expect(Object.keys(changes.diff.puts).length).toBe(2)
})
})
it('[SS15] wipeAll is true when sinceClock predates tombstone history, with all documents and no deletes', () => {
const storage = create(
makeContractSnapshot(contractRecords, {
tombstones: { 'shape:deleted1': 12 },
documentClock: 20,
tombstoneHistoryStartsAtClock: 10,
})
)
storage.transaction((txn) => {
const changes = txn.getChangesSince(9)!
expect(changes.wipeAll).toBe(true)
expect(Object.keys(changes.diff.puts).length).toBe(2)
expect(changes.diff.deletes).toEqual([])
})
})
it('[SS15] wipeAll boundary: sinceClock equal to tombstoneHistoryStartsAtClock is incremental', () => {
const storage = create(
makeContractSnapshot(contractRecords, {
documentClock: 20,
tombstoneHistoryStartsAtClock: 10,
})
)
storage.transaction((txn) => {
expect(txn.getChangesSince(10)!.wipeAll).toBe(false)
expect(txn.getChangesSince(9)!.wipeAll).toBe(true)
})
})
it('[SS15] sees writes made earlier in the same transaction', () => {
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 5 }))
const newPage = makePage('new')
storage.transaction((txn) => {
txn.set(newPage.id, newPage)
txn.delete(contractRecords[1].id)
const changes = txn.getChangesSince(5)!
expect(Object.keys(changes.diff.puts)).toEqual([newPage.id])
expect(changes.diff.deletes).toEqual([contractRecords[1].id])
})
})
})
describe('emitChanges', () => {
it('[SS7] emitChanges: "always" returns the forward diff of the transaction', () => {
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 5 }))
const newPage = makePage('new')
const { changes } = storage.transaction(
(txn) => {
txn.set(newPage.id, newPage)
txn.delete(contractRecords[1].id)
},
{ emitChanges: 'always' }
)
expect(changes).toEqual({
puts: { [newPage.id]: newPage },
deletes: [contractRecords[1].id],
})
})
it('[SS7] emitChanges: "when-different" never emits (changes apply verbatim)', () => {
const storage = create(makeContractSnapshot(contractRecords))
const newPage = makePage('new')
const { changes } = storage.transaction(
(txn) => {
txn.set(newPage.id, newPage)
},
{ emitChanges: 'when-different' }
)
expect(changes).toBeUndefined()
})
it('[SS7] changes is undefined when emitChanges is not requested', () => {
const storage = create(makeContractSnapshot(contractRecords))
const newPage = makePage('new')
const { changes } = storage.transaction((txn) => {
txn.set(newPage.id, newPage)
})
expect(changes).toBeUndefined()
})
})
describe('onChange', () => {
it('[SS6] notifies listeners on a microtask with the new clock and transaction id', async () => {
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 10 }))
const listener = vi.fn()
storage.onChange(listener)
await Promise.resolve()
const newPage = makePage('new')
storage.transaction(
(txn) => {
txn.set(newPage.id, newPage)
},
{ id: 'my-transaction-id' }
)
expect(listener).not.toHaveBeenCalled() // deferred to a microtask
await Promise.resolve()
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith({ id: 'my-transaction-id', documentClock: 11 })
})
it('[SS6] the id is undefined when the transaction has none', async () => {
const storage = create(makeContractSnapshot(contractRecords))
const listener = vi.fn()
storage.onChange(listener)
await Promise.resolve()
const newPage = makePage('new')
storage.transaction((txn) => txn.set(newPage.id, newPage))
await Promise.resolve()
expect(listener).toHaveBeenCalledWith({ id: undefined, documentClock: 1 })
})
it('[SS6] read-only transactions do not notify', async () => {
const storage = create(makeContractSnapshot(contractRecords))
const listener = vi.fn()
storage.onChange(listener)
await Promise.resolve()
storage.transaction((txn) => void txn.get(TLDOCUMENT_ID))
await Promise.resolve()
expect(listener).not.toHaveBeenCalled()
})
it('[SS6] unsubscribing prevents future notifications', async () => {
const storage = create(makeContractSnapshot(contractRecords))
const listener = vi.fn()
const unsubscribe = storage.onChange(listener)
await Promise.resolve()
unsubscribe()
const newPage = makePage('new')
storage.transaction((txn) => txn.set(newPage.id, newPage))
await Promise.resolve()
expect(listener).not.toHaveBeenCalled()
})
it('[SS6] a listener registered in the same callstack as a change does not receive it', async () => {
const storage = create(makeContractSnapshot(contractRecords))
const newPage = makePage('new')
storage.transaction((txn) => txn.set(newPage.id, newPage))
const listener = vi.fn()
storage.onChange(listener)
await Promise.resolve()
await Promise.resolve()
expect(listener).not.toHaveBeenCalled()
})
it('[SS6] an onChange callback passed to the constructor is registered the same way', async () => {
const listener = vi.fn()
const storage = factory.create({
snapshot: makeContractSnapshot(contractRecords),
onChange: listener,
})
await Promise.resolve()
const newPage = makePage('new')
storage.transaction((txn) => txn.set(newPage.id, newPage))
await Promise.resolve()
expect(listener).toHaveBeenCalledWith({ id: undefined, documentClock: 1 })
})
})
describe('getSnapshot', () => {
it('[SS16] returns the full room snapshot reflecting committed transactions', () => {
const storage = create(
makeContractSnapshot(contractRecords, {
documentClock: 15,
tombstoneHistoryStartsAtClock: 5,
tombstones: { 'shape:deleted': 10 },
})
)
const newPage = makePage('new')
storage.transaction((txn) => txn.set(newPage.id, newPage))
const snapshot = storage.getSnapshot!()
expect(snapshot.documentClock).toBe(16)
expect(snapshot.tombstoneHistoryStartsAtClock).toBe(5)
expect(snapshot.documents.length).toBe(3)
expect(snapshot.tombstones).toEqual({ 'shape:deleted': 10 })
expect(snapshot.schema).toEqual(contractSchema.serialize())
})
})
describe('snapshot fallbacks', () => {
it('[SS17] falls back to the legacy clock field when documentClock is missing', () => {
const snapshot = makeContractSnapshot(contractRecords) as any
delete snapshot.documentClock
snapshot.clock = 15
const storage = create(snapshot)
expect(storage.getClock()).toBe(15)
})
it('[SS17] falls back to 0 when neither documentClock nor clock is present', () => {
const snapshot = makeContractSnapshot(contractRecords) as any
delete snapshot.documentClock
delete snapshot.clock
const storage = create(snapshot)
expect(storage.getClock()).toBe(0)
})
it('[SS17] tombstoneHistoryStartsAtClock defaults to the document clock', () => {
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 20 }))
expect(storage.getSnapshot!().tombstoneHistoryStartsAtClock).toBe(20)
})
})
describe('tombstone pruning', () => {
const makeTombstones = (count: number) => {
const tombstones: Record<string, number> = {}
for (let i = 0; i < count; i++) {
tombstones[`shape:doc${i}`] = i + 1
}
return tombstones
}
const flushPrune = (storage: TLSyncStorage<TLRecord>) => {
const prune = (storage as any).pruneTombstones
prune()
prune.flush()
}
it('[SS18] does not prune at or below MAX_TOMBSTONES', () => {
const storage = create(
makeContractSnapshot(contractRecords, {
tombstones: makeTombstones(MAX_TOMBSTONES),
documentClock: MAX_TOMBSTONES + 1,
tombstoneHistoryStartsAtClock: 0,
})
)
flushPrune(storage)
expect(Object.keys(storage.getSnapshot!().tombstones!).length).toBe(MAX_TOMBSTONES)
expect(storage.getSnapshot!().tombstoneHistoryStartsAtClock).toBe(0)
})
it('[SS18] prunes the oldest tombstones past the buffer and advances the history clock', () => {
const total = MAX_TOMBSTONES + 500
const storage = create(
makeContractSnapshot(contractRecords, {
tombstones: makeTombstones(total),
documentClock: total + 1,
tombstoneHistoryStartsAtClock: 0,
})
)
flushPrune(storage)
const snapshot = storage.getSnapshot!()
const remaining = Object.entries(snapshot.tombstones!)
expect(remaining.length).toBe(MAX_TOMBSTONES - TOMBSTONE_PRUNE_BUFFER_SIZE)
// the oldest were deleted; everything left is at or after the new history start
const historyClock = snapshot.tombstoneHistoryStartsAtClock!
expect(historyClock).toBeGreaterThan(0)
for (const [, clock] of remaining) {
expect(clock).toBeGreaterThanOrEqual(historyClock)
}
})
it('[SS18] never splits tombstones sharing a clock value', () => {
// 10 tombstones share the clock value at the cutoff boundary
const total = MAX_TOMBSTONES + 1
const expectedCutoff = TOMBSTONE_PRUNE_BUFFER_SIZE + 1
const overflow = 10
const boundary = expectedCutoff + overflow
const tombstones: Record<string, number> = {}
for (let i = 0; i < total; i++) {
tombstones[`shape:doc${i}`] = i < boundary ? 1 : 2
}
const storage = create(
makeContractSnapshot(contractRecords, {
tombstones,
documentClock: 3,
tombstoneHistoryStartsAtClock: 0,
})
)
flushPrune(storage)
const remaining = Object.values(storage.getSnapshot!().tombstones!)
expect(remaining.length).toBe(MAX_TOMBSTONES - TOMBSTONE_PRUNE_BUFFER_SIZE - overflow)
expect(remaining.every((clock) => clock === 2)).toBe(true)
})
})
})
}

View File

@@ -0,0 +1,310 @@
import {
Editor,
TLRecord,
TLStore,
UserRecordType,
computed,
createPresenceStateDerivation,
createTLSchema,
createTLStore,
createUserId,
isRecordsDiffEmpty,
mockUniqueId,
uniqueId,
} from 'tldraw'
import uuid from 'uuid-by-string'
import readable from 'uuid-readable'
import { vi } from 'vitest'
import { prettyPrintDiff } from '../../../tldraw/src/test/testutils/pretty'
import { TLSyncClient } from '../lib/TLSyncClient'
import { FuzzEditor, Op } from './FuzzEditor'
import { RandomSource } from './RandomSource'
import { TestServer } from './TestServer'
import { TestSocketPair } from './TestSocketPair'
const schema = createTLSchema()
vi.mock('@tldraw/editor/src/lib/editor/managers/TickManager/TickManager.ts', () => {
return {
TickManager: class {
start() {
// noop
}
},
}
})
// @ts-expect-error
global.requestAnimationFrame = (cb: () => any) => {
cb()
}
let source = new RandomSource(0)
const nanoid = () => {
return readable.short(uuid(source.randomInt().toString(16))).replaceAll(' ', '_')
}
const reseed = (seed: number) => {
source = new RandomSource(seed)
}
mockUniqueId(nanoid)
const disposables: Array<() => void> = []
afterEach(() => {
for (const dispose of disposables) {
dispose()
}
disposables.length = 0
})
class FuzzTestInstance extends RandomSource {
store: TLStore
editor: FuzzEditor | null = null
client: TLSyncClient<TLRecord>
socketPair: TestSocketPair<TLRecord>
id: string
hasLoaded = false
constructor(
public readonly seed: number,
server: TestServer<TLRecord>
) {
super(seed)
this.id = uniqueId()
this.store = createTLStore({ schema, id: this.id })
this.socketPair = new TestSocketPair(this.id, server)
this.client = new TLSyncClient<TLRecord>({
store: this.store,
socket: this.socketPair.clientSocket,
onSyncError: (reason) => {
throw new Error('onSyncError:' + reason)
},
onLoad: () => {
this.editor = new FuzzEditor(this.id, this.seed, this.store)
},
presence: createPresenceStateDerivation(
computed('', () =>
UserRecordType.create({
id: createUserId(this.id),
name: 'test',
color: 'red',
})
)
)(this.store),
})
disposables.push(() => {
this.client.close()
})
}
}
function assertPeerStoreIsUsable(peer: FuzzTestInstance) {
const diffToEnsureUsable = peer.store.extractingChanges(() => peer.store.ensureStoreIsUsable())
if (!isRecordsDiffEmpty(diffToEnsureUsable)) {
throw new Error(`store of ${peer.id} was not usable\n${prettyPrintDiff(diffToEnsureUsable)}`)
}
}
let totalNumShapes = 0
let totalNumPages = 0
function arrowsAreSound(editor: Editor) {
const arrows = editor.getCurrentPageShapes().filter((s) => s.type === 'arrow')
for (const arrow of arrows) {
const bindings = editor.getBindingsFromShape(arrow, 'arrow')
const terminalsSeen = new Set()
for (const binding of bindings) {
if (terminalsSeen.has(binding.props.terminal)) {
return false
}
terminalsSeen.add(binding.props.terminal)
if (!editor.store.has(binding.toId)) {
return false
}
}
}
return true
}
function runTest(seed: number) {
reseed(seed)
const server = new TestServer(schema)
const instance = new FuzzTestInstance(seed, server)
const peers = [instance, new FuzzTestInstance(instance.randomInt(), server)]
const numExtraPeers = instance.randomInt(MAX_PEERS - 2)
for (let i = 0; i < numExtraPeers; i++) {
peers.push(new FuzzTestInstance(instance.randomInt(), server))
}
const allOk = (when: string) => {
if (peers.some((p) => p.editor?.editor && !p.editor?.editor.getCurrentPage())) {
throw new Error(`not all peer editors have current page (${when})`)
}
if (peers.some((p) => p.editor?.editor && !p.editor?.editor.getCurrentPageState())) {
throw new Error(`not all peer editors have page states (${when})`)
}
if (
peers.some(
(p) => p.client.isConnectedToRoom && p.socketPair.clientSocket.connectionStatus !== 'online'
)
) {
throw new Error(`peer client connection status mismatch (${when})`)
}
if (peers.some((p) => p.editor?.editor && !arrowsAreSound(p.editor.editor))) {
throw new Error(`peer editor arrows are not sound (${when})`)
}
const numOtherPeersConnected = peers.filter((p) => p.hasLoaded).length - 1
if (
peers.some(
(p) =>
p.hasLoaded &&
p.editor?.editor.store.query.ids('instance_presence').get().size !==
numOtherPeersConnected
)
) {
throw new Error(`not all peer editors have instance presence (${when})`)
}
}
const ops: Array<{ peerId: string; op: Op; id: number }> = []
try {
for (let i = 0; i < NUM_OPS_PER_TEST; i++) {
const peer = peers[instance.randomInt(peers.length)]
if (peer.editor) {
const op = peer.editor.getRandomOp()
ops.push({ peerId: peer.id, op, id: ops.length })
allOk('before applyOp')
peer.editor.applyOp(op)
assertPeerStoreIsUsable(peer)
allOk('after applyOp')
server.flushDebouncingMessages()
if (peer.socketPair.isConnected && peer.randomInt(6) === 0) {
// randomly disconnect a peer
peer.socketPair.disconnect()
allOk('disconnect')
} else if (!peer.socketPair.isConnected && peer.randomInt(2) === 0) {
// randomly reconnect a peer
peer.socketPair.connect()
allOk('connect')
}
} else if (!peer.socketPair.isConnected && peer.randomInt(2) === 0) {
peer.socketPair.connect()
allOk('connect 2')
}
const peersThatNeedFlushing = peers.filter((p) => p.socketPair.getNeedsFlushing())
for (const peer of peersThatNeedFlushing) {
if (peer.randomInt(10) < 4) {
allOk('before flush server ' + i)
peer.socketPair.flushServerSentEvents()
allOk('flush server ' + i)
} else if (peer.randomInt(10) < 2) {
peer.socketPair.flushClientSentEvents()
allOk('flush client')
}
}
}
// bring all clients online and flush all messages to make sure everyone has seen all messages
while (peers.some((p) => !p.socketPair.isConnected)) {
for (const peer of peers) {
if (!peer.socketPair.isConnected && peer.randomInt(2) === 0) {
peer.socketPair.connect()
allOk('final connect')
assertPeerStoreIsUsable(peer)
}
}
}
while (peers.some((p) => p.socketPair.getNeedsFlushing())) {
server.flushDebouncingMessages()
for (const peer of peers) {
if (peer.socketPair.getNeedsFlushing()) {
peer.socketPair.flushServerSentEvents()
allOk('final flushServer')
peer.socketPair.flushClientSentEvents()
allOk('final flushClient')
assertPeerStoreIsUsable(peer)
}
}
}
// peers should all be usable without changes:
for (const peer of peers) {
assertPeerStoreIsUsable(peer)
}
// all stores should be the same
for (let i = 1; i < peers.length; i++) {
const expected = peers[i - 1]
const actual = peers[i]
try {
expect(actual.store.serialize('document')).toEqual(expected.store.serialize('document'))
} catch (e: any) {
throw new Error(`received = ${actual.id}, expected = ${expected.id}\n${e.message}`)
}
}
totalNumPages += peers[0].store.query.ids('page').get().size
totalNumShapes += peers[0].store.query.ids('shape').get().size
} catch (e) {
console.error('seed', seed)
console.error(
'peers',
JSON.stringify(
peers.map((p) => p.id),
null,
2
)
)
console.error('ops', JSON.stringify(ops, null, '\t'))
throw e
}
}
const NUM_TESTS = 50
const NUM_OPS_PER_TEST = 100
const MAX_PEERS = 4
test('seed 8360926944486245 - undo/redo page integrity regression', () => {
runTest(8360926944486245)
})
test('seed 3467175630814895 - undo/redo page integrity regression', () => {
runTest(3467175630814895)
})
test('seed 6820615056006575 - undo/redo page integrity regression', () => {
runTest(6820615056006575)
})
test('seed 5279266392988747 - undo/redo page integrity regression', () => {
runTest(5279266392988747)
})
test('seed 8090628137862085 - duplicate index key regression', () => {
runTest(8090628137862085)
})
for (let i = 0; i < NUM_TESTS; i++) {
const seed = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)
test(`seed ${seed}`, () => {
runTest(seed)
})
}
test('totalNumPages', () => {
expect(totalNumPages).not.toBe(0)
})
test('totalNumShapes', () => {
expect(totalNumShapes).not.toBe(0)
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"extends": "../../internal/config/tsconfig.base.json",
"include": ["src"],
"exclude": ["node_modules", "docs", ".tsbuild*"],
"compilerOptions": { "outDir": "./.tsbuild", "rootDir": "src" },
"references": [
{ "path": "../editor" },
{ "path": "../state" },
{ "path": "../store" },
{ "path": "../tldraw" },
{ "path": "../tlschema" },
{ "path": "../utils" }
]
}

View File

@@ -0,0 +1,10 @@
/// <reference types="vitest" />
import { mergeConfig } from 'vitest/config'
import baseConfig from '../../internal/config/vitest/node-preset'
// sync-core package needs custom WebSocket resolution and enhanced jsdom setup
export default mergeConfig(baseConfig, {
test: {
setupFiles: ['../../internal/config/vitest/setup.ts', './setupVitest.js'],
},
})