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
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:
1001
packages/sync-core/src/lib/ClientWebSocketAdapter.test.ts
Normal file
1001
packages/sync-core/src/lib/ClientWebSocketAdapter.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
765
packages/sync-core/src/lib/ClientWebSocketAdapter.ts
Normal file
765
packages/sync-core/src/lib/ClientWebSocketAdapter.ts
Normal 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:')
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
94
packages/sync-core/src/lib/DurableObjectSqliteSyncWrapper.ts
Normal file
94
packages/sync-core/src/lib/DurableObjectSqliteSyncWrapper.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
315
packages/sync-core/src/lib/InMemorySyncStorage.test.ts
Normal file
315
packages/sync-core/src/lib/InMemorySyncStorage.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
452
packages/sync-core/src/lib/InMemorySyncStorage.ts
Normal file
452
packages/sync-core/src/lib/InMemorySyncStorage.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
254
packages/sync-core/src/lib/MicrotaskNotifier.test.ts
Normal file
254
packages/sync-core/src/lib/MicrotaskNotifier.test.ts
Normal 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)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
38
packages/sync-core/src/lib/MicrotaskNotifier.ts
Normal file
38
packages/sync-core/src/lib/MicrotaskNotifier.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
305
packages/sync-core/src/lib/NodeSqliteWrapper.test.ts
Normal file
305
packages/sync-core/src/lib/NodeSqliteWrapper.test.ts
Normal 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 }])
|
||||
})
|
||||
})
|
||||
})
|
||||
102
packages/sync-core/src/lib/NodeSqliteWrapper.ts
Normal file
102
packages/sync-core/src/lib/NodeSqliteWrapper.ts
Normal 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
|
||||
}
|
||||
}
|
||||
148
packages/sync-core/src/lib/RoomSession.ts
Normal file
148
packages/sync-core/src/lib/RoomSession.ts
Normal 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>[]
|
||||
})
|
||||
325
packages/sync-core/src/lib/SQLiteSyncStorage.test.ts
Normal file
325
packages/sync-core/src/lib/SQLiteSyncStorage.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
749
packages/sync-core/src/lib/SQLiteSyncStorage.ts
Normal file
749
packages/sync-core/src/lib/SQLiteSyncStorage.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
89
packages/sync-core/src/lib/ServerSocketAdapter.test.ts
Normal file
89
packages/sync-core/src/lib/ServerSocketAdapter.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
171
packages/sync-core/src/lib/ServerSocketAdapter.ts
Normal file
171
packages/sync-core/src/lib/ServerSocketAdapter.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
58
packages/sync-core/src/lib/TLRemoteSyncError.ts
Normal file
58
packages/sync-core/src/lib/TLRemoteSyncError.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
1114
packages/sync-core/src/lib/TLSocketRoom.ts
Normal file
1114
packages/sync-core/src/lib/TLSocketRoom.ts
Normal file
File diff suppressed because it is too large
Load Diff
1773
packages/sync-core/src/lib/TLSyncClient.test.ts
Normal file
1773
packages/sync-core/src/lib/TLSyncClient.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1027
packages/sync-core/src/lib/TLSyncClient.ts
Normal file
1027
packages/sync-core/src/lib/TLSyncClient.ts
Normal file
File diff suppressed because it is too large
Load Diff
1700
packages/sync-core/src/lib/TLSyncRoom.ts
Normal file
1700
packages/sync-core/src/lib/TLSyncRoom.ts
Normal file
File diff suppressed because it is too large
Load Diff
262
packages/sync-core/src/lib/TLSyncStorage.test.ts
Normal file
262
packages/sync-core/src/lib/TLSyncStorage.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
233
packages/sync-core/src/lib/TLSyncStorage.ts
Normal file
233
packages/sync-core/src/lib/TLSyncStorage.ts
Normal 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: {},
|
||||
}
|
||||
}
|
||||
459
packages/sync-core/src/lib/chunk.test.ts
Normal file
459
packages/sync-core/src/lib/chunk.test.ts
Normal 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 } })
|
||||
})
|
||||
})
|
||||
})
|
||||
216
packages/sync-core/src/lib/chunk.ts
Normal file
216
packages/sync-core/src/lib/chunk.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
885
packages/sync-core/src/lib/diff.test.ts
Normal file
885
packages/sync-core/src/lib/diff.test.ts
Normal 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] }],
|
||||
})
|
||||
})
|
||||
})
|
||||
402
packages/sync-core/src/lib/diff.ts
Normal file
402
packages/sync-core/src/lib/diff.ts
Normal 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
|
||||
}
|
||||
43
packages/sync-core/src/lib/interval.test.ts
Normal file
43
packages/sync-core/src/lib/interval.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
44
packages/sync-core/src/lib/interval.ts
Normal file
44
packages/sync-core/src/lib/interval.ts
Normal 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)
|
||||
}
|
||||
43
packages/sync-core/src/lib/protocol.test.ts
Normal file
43
packages/sync-core/src/lib/protocol.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
290
packages/sync-core/src/lib/protocol.ts
Normal file
290
packages/sync-core/src/lib/protocol.ts
Normal 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
|
||||
159
packages/sync-core/src/lib/recordDiff.test.ts
Normal file
159
packages/sync-core/src/lib/recordDiff.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
73
packages/sync-core/src/lib/recordDiff.ts
Normal file
73
packages/sync-core/src/lib/recordDiff.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
52
packages/sync-core/src/lib/server-types.ts
Normal file
52
packages/sync-core/src/lib/server-types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user