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

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
}