@tldraw/sync-core 4.2.2 → 4.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist-cjs/index.d.ts +58 -483
  2. package/dist-cjs/index.js +3 -13
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/RoomSession.js.map +1 -1
  5. package/dist-cjs/lib/TLSocketRoom.js +69 -117
  6. package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
  7. package/dist-cjs/lib/TLSyncClient.js +0 -7
  8. package/dist-cjs/lib/TLSyncClient.js.map +2 -2
  9. package/dist-cjs/lib/TLSyncRoom.js +688 -357
  10. package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
  11. package/dist-cjs/lib/chunk.js +2 -2
  12. package/dist-cjs/lib/chunk.js.map +1 -1
  13. package/dist-esm/index.d.mts +58 -483
  14. package/dist-esm/index.mjs +5 -20
  15. package/dist-esm/index.mjs.map +2 -2
  16. package/dist-esm/lib/RoomSession.mjs.map +1 -1
  17. package/dist-esm/lib/TLSocketRoom.mjs +70 -121
  18. package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
  19. package/dist-esm/lib/TLSyncClient.mjs +0 -7
  20. package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
  21. package/dist-esm/lib/TLSyncRoom.mjs +702 -370
  22. package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
  23. package/dist-esm/lib/chunk.mjs +2 -2
  24. package/dist-esm/lib/chunk.mjs.map +1 -1
  25. package/package.json +11 -12
  26. package/src/index.ts +3 -32
  27. package/src/lib/ClientWebSocketAdapter.test.ts +0 -3
  28. package/src/lib/RoomSession.test.ts +0 -1
  29. package/src/lib/RoomSession.ts +0 -2
  30. package/src/lib/TLSocketRoom.ts +114 -228
  31. package/src/lib/TLSyncClient.ts +0 -12
  32. package/src/lib/TLSyncRoom.ts +913 -473
  33. package/src/lib/chunk.ts +2 -2
  34. package/src/test/FuzzEditor.ts +5 -4
  35. package/src/test/TLSocketRoom.test.ts +49 -255
  36. package/src/test/TLSyncRoom.test.ts +534 -1024
  37. package/src/test/TestServer.ts +1 -12
  38. package/src/test/customMessages.test.ts +1 -1
  39. package/src/test/presenceMode.test.ts +6 -6
  40. package/src/test/pruneTombstones.test.ts +178 -0
  41. package/src/test/syncFuzz.test.ts +4 -2
  42. package/src/test/upgradeDowngrade.test.ts +8 -290
  43. package/src/test/validation.test.ts +10 -15
  44. package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js +0 -55
  45. package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js.map +0 -7
  46. package/dist-cjs/lib/InMemorySyncStorage.js +0 -287
  47. package/dist-cjs/lib/InMemorySyncStorage.js.map +0 -7
  48. package/dist-cjs/lib/MicrotaskNotifier.js +0 -50
  49. package/dist-cjs/lib/MicrotaskNotifier.js.map +0 -7
  50. package/dist-cjs/lib/NodeSqliteWrapper.js +0 -48
  51. package/dist-cjs/lib/NodeSqliteWrapper.js.map +0 -7
  52. package/dist-cjs/lib/SQLiteSyncStorage.js +0 -428
  53. package/dist-cjs/lib/SQLiteSyncStorage.js.map +0 -7
  54. package/dist-cjs/lib/TLSyncStorage.js +0 -76
  55. package/dist-cjs/lib/TLSyncStorage.js.map +0 -7
  56. package/dist-cjs/lib/recordDiff.js +0 -52
  57. package/dist-cjs/lib/recordDiff.js.map +0 -7
  58. package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs +0 -35
  59. package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs.map +0 -7
  60. package/dist-esm/lib/InMemorySyncStorage.mjs +0 -272
  61. package/dist-esm/lib/InMemorySyncStorage.mjs.map +0 -7
  62. package/dist-esm/lib/MicrotaskNotifier.mjs +0 -30
  63. package/dist-esm/lib/MicrotaskNotifier.mjs.map +0 -7
  64. package/dist-esm/lib/NodeSqliteWrapper.mjs +0 -28
  65. package/dist-esm/lib/NodeSqliteWrapper.mjs.map +0 -7
  66. package/dist-esm/lib/SQLiteSyncStorage.mjs +0 -414
  67. package/dist-esm/lib/SQLiteSyncStorage.mjs.map +0 -7
  68. package/dist-esm/lib/TLSyncStorage.mjs +0 -56
  69. package/dist-esm/lib/TLSyncStorage.mjs.map +0 -7
  70. package/dist-esm/lib/recordDiff.mjs +0 -32
  71. package/dist-esm/lib/recordDiff.mjs.map +0 -7
  72. package/src/lib/DurableObjectSqliteSyncWrapper.ts +0 -95
  73. package/src/lib/InMemorySyncStorage.ts +0 -387
  74. package/src/lib/MicrotaskNotifier.test.ts +0 -429
  75. package/src/lib/MicrotaskNotifier.ts +0 -38
  76. package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +0 -270
  77. package/src/lib/NodeSqliteSyncWrapper.test.ts +0 -272
  78. package/src/lib/NodeSqliteWrapper.ts +0 -99
  79. package/src/lib/SQLiteSyncStorage.ts +0 -627
  80. package/src/lib/TLSyncStorage.ts +0 -216
  81. package/src/lib/computeTombstonePruning.test.ts +0 -352
  82. package/src/lib/recordDiff.ts +0 -73
  83. package/src/test/InMemorySyncStorage.test.ts +0 -1684
  84. package/src/test/SQLiteSyncStorage.test.ts +0 -1378
@@ -1,95 +0,0 @@
1
- import {
2
- type TLSqliteInputValue,
3
- type TLSqliteRow,
4
- type TLSyncSqliteStatement,
5
- type TLSyncSqliteWrapper,
6
- type TLSyncSqliteWrapperConfig,
7
- } from './SQLiteSyncStorage'
8
-
9
- /**
10
- * Mimics a prepared statement interface for Durable Objects SQLite.
11
- * Rather than actually preparing the statement, it just stores the SQL and
12
- * executes it fresh each time. This is still fast because DO SQLite maintains
13
- * an internal LRU cache of prepared statements.
14
- */
15
- class DurableObjectStatement<
16
- TResult extends TLSqliteRow | void,
17
- TParams extends TLSqliteInputValue[],
18
- > implements TLSyncSqliteStatement<TResult, TParams>
19
- {
20
- constructor(
21
- private sql: {
22
- exec(sql: string, ...bindings: unknown[]): Iterable<any> & { toArray(): any[] }
23
- },
24
- private query: string
25
- ) {}
26
-
27
- iterate(...bindings: TParams): IterableIterator<TResult> {
28
- const result = this.sql.exec(this.query, ...bindings)
29
- return result[Symbol.iterator]() as IterableIterator<TResult>
30
- }
31
-
32
- all(...bindings: TParams): TResult[] {
33
- return this.sql.exec(this.query, ...bindings).toArray()
34
- }
35
-
36
- run(...bindings: TParams): void {
37
- this.sql.exec(this.query, ...bindings)
38
- }
39
- }
40
-
41
- /**
42
- * A wrapper around Cloudflare Durable Object's SqlStorage that implements TLSyncSqliteWrapper.
43
- *
44
- * Use this wrapper with SQLiteSyncStorage to persist tldraw sync state using
45
- * Cloudflare Durable Object's built-in SQLite storage. This provides automatic
46
- * persistence that survives Durable Object hibernation and restarts.
47
- *
48
- * @example
49
- * ```ts
50
- * import { SQLiteSyncStorage, DurableObjectSqliteSyncWrapper } from '@tldraw/sync-core'
51
- *
52
- * // In your Durable Object class:
53
- * class MyDurableObject extends DurableObject {
54
- * private storage: SQLiteSyncStorage
55
- *
56
- * constructor(ctx: DurableObjectState, env: Env) {
57
- * super(ctx, env)
58
- * const sql = new DurableObjectSqliteSyncWrapper(ctx.storage)
59
- * this.storage = new SQLiteSyncStorage({ sql })
60
- * }
61
- * }
62
- * ```
63
- *
64
- * @example
65
- * ```ts
66
- * // With table prefix to avoid conflicts with other tables
67
- * const sql = new DurableObjectSqliteSyncWrapper(this.ctx.storage, { tablePrefix: 'tldraw_' })
68
- * // Creates tables: tldraw_documents, tldraw_tombstones, tldraw_metadata
69
- * ```
70
- *
71
- * @public
72
- */
73
- export class DurableObjectSqliteSyncWrapper implements TLSyncSqliteWrapper {
74
- constructor(
75
- private storage: {
76
- sql: { exec(sql: string, ...bindings: unknown[]): Iterable<any> & { toArray(): any[] } }
77
- transactionSync(callback: () => any): any
78
- },
79
- public config?: TLSyncSqliteWrapperConfig
80
- ) {}
81
-
82
- exec(sql: string): void {
83
- this.storage.sql.exec(sql)
84
- }
85
-
86
- prepare<TResult extends TLSqliteRow | void = void, TParams extends TLSqliteInputValue[] = []>(
87
- sql: string
88
- ): TLSyncSqliteStatement<TResult, TParams> {
89
- return new DurableObjectStatement<TResult, TParams>(this.storage.sql, sql)
90
- }
91
-
92
- transaction<T>(callback: () => T): T {
93
- return this.storage.transactionSync(callback)
94
- }
95
- }
@@ -1,387 +0,0 @@
1
- import { atom, Atom, transaction } from '@tldraw/state'
2
- import { AtomMap, devFreeze, SerializedSchema, UnknownRecord } from '@tldraw/store'
3
- import {
4
- createTLSchema,
5
- DocumentRecordType,
6
- PageRecordType,
7
- TLDOCUMENT_ID,
8
- TLPageId,
9
- } from '@tldraw/tlschema'
10
- import { assert, IndexKey, objectMapEntries, throttle } from '@tldraw/utils'
11
- import { MicrotaskNotifier } from './MicrotaskNotifier'
12
- import { RoomSnapshot } from './TLSyncRoom'
13
- import {
14
- TLSyncForwardDiff,
15
- TLSyncStorage,
16
- TLSyncStorageGetChangesSinceResult,
17
- TLSyncStorageOnChangeCallbackProps,
18
- TLSyncStorageTransaction,
19
- TLSyncStorageTransactionCallback,
20
- TLSyncStorageTransactionOptions,
21
- TLSyncStorageTransactionResult,
22
- } from './TLSyncStorage'
23
-
24
- /** @internal */
25
- export const TOMBSTONE_PRUNE_BUFFER_SIZE = 1000
26
- /** @internal */
27
- export const MAX_TOMBSTONES = 5000
28
-
29
- /**
30
- * Result of computing which tombstones to prune.
31
- * @internal
32
- */
33
- export interface TombstonePruneResult {
34
- /** The new value for tombstoneHistoryStartsAtClock */
35
- newTombstoneHistoryStartsAtClock: number
36
- /** IDs of tombstones to delete */
37
- idsToDelete: string[]
38
- }
39
-
40
- /**
41
- * Computes which tombstones should be pruned, avoiding partial history for any clock value.
42
- * Returns null if no pruning is needed (tombstone count <= maxTombstones).
43
- *
44
- * @param tombstones - Array of tombstones sorted by clock ascending (oldest first)
45
- * @param documentClock - Current document clock (used as fallback if all tombstones are deleted)
46
- * @param maxTombstones - Maximum number of tombstones to keep (default: MAX_TOMBSTONES)
47
- * @param pruneBufferSize - Extra tombstones to prune beyond the threshold (default: TOMBSTONE_PRUNE_BUFFER_SIZE)
48
- * @returns Pruning result or null if no pruning needed
49
- *
50
- * @internal
51
- */
52
- export function computeTombstonePruning({
53
- tombstones,
54
- documentClock,
55
- maxTombstones = MAX_TOMBSTONES,
56
- pruneBufferSize = TOMBSTONE_PRUNE_BUFFER_SIZE,
57
- }: {
58
- tombstones: Array<{ id: string; clock: number }>
59
- documentClock: number
60
- maxTombstones?: number
61
- pruneBufferSize?: number
62
- }): TombstonePruneResult | null {
63
- if (tombstones.length <= maxTombstones) {
64
- return null
65
- }
66
-
67
- // Determine how many to delete, avoiding partial history for a clock value
68
- let cutoff = pruneBufferSize + tombstones.length - maxTombstones
69
- while (
70
- cutoff < tombstones.length &&
71
- tombstones[cutoff - 1]?.clock === tombstones[cutoff]?.clock
72
- ) {
73
- cutoff++
74
- }
75
-
76
- // Set history start to the oldest remaining tombstone's clock
77
- // (or documentClock if we're deleting everything)
78
- const oldestRemaining = tombstones[cutoff]
79
- const newTombstoneHistoryStartsAtClock = oldestRemaining?.clock ?? documentClock
80
-
81
- // Collect the oldest tombstones to delete (first cutoff entries)
82
- const idsToDelete = tombstones.slice(0, cutoff).map((t) => t.id)
83
-
84
- return { newTombstoneHistoryStartsAtClock, idsToDelete }
85
- }
86
-
87
- /**
88
- * Default initial snapshot for a new room.
89
- * @public
90
- */
91
- export const DEFAULT_INITIAL_SNAPSHOT = {
92
- documentClock: 0,
93
- tombstoneHistoryStartsAtClock: 0,
94
- schema: createTLSchema().serialize(),
95
- documents: [
96
- {
97
- state: DocumentRecordType.create({ id: TLDOCUMENT_ID }),
98
- lastChangedClock: 0,
99
- },
100
- {
101
- state: PageRecordType.create({
102
- id: 'page:page' as TLPageId,
103
- name: 'Page 1',
104
- index: 'a1' as IndexKey,
105
- }),
106
- lastChangedClock: 0,
107
- },
108
- ],
109
- }
110
-
111
- /**
112
- * In-memory implementation of TLSyncStorage using AtomMap for documents and tombstones,
113
- * and atoms for clock values. This is the default storage implementation used by TLSyncRoom.
114
- *
115
- * @public
116
- */
117
- export class InMemorySyncStorage<R extends UnknownRecord> implements TLSyncStorage<R> {
118
- /** @internal */
119
- documents: AtomMap<string, { state: R; lastChangedClock: number }>
120
- /** @internal */
121
- tombstones: AtomMap<string, number>
122
- /** @internal */
123
- schema: Atom<SerializedSchema>
124
- /** @internal */
125
- documentClock: Atom<number>
126
- /** @internal */
127
- tombstoneHistoryStartsAtClock: Atom<number>
128
-
129
- private notifier = new MicrotaskNotifier<[TLSyncStorageOnChangeCallbackProps]>()
130
- onChange(callback: (arg: TLSyncStorageOnChangeCallbackProps) => unknown): () => void {
131
- return this.notifier.register(callback)
132
- }
133
-
134
- constructor({
135
- snapshot = DEFAULT_INITIAL_SNAPSHOT,
136
- onChange,
137
- }: {
138
- snapshot?: RoomSnapshot
139
- onChange?(arg: TLSyncStorageOnChangeCallbackProps): unknown
140
- } = {}) {
141
- const maxClockValue = Math.max(
142
- 0,
143
- ...Object.values(snapshot.tombstones ?? {}),
144
- ...Object.values(snapshot.documents.map((d) => d.lastChangedClock))
145
- )
146
- this.documents = new AtomMap(
147
- 'room documents',
148
- snapshot.documents.map((d) => [
149
- d.state.id,
150
- { state: devFreeze(d.state) as R, lastChangedClock: d.lastChangedClock },
151
- ])
152
- )
153
- const documentClock = Math.max(maxClockValue, snapshot.documentClock ?? snapshot.clock ?? 0)
154
-
155
- this.documentClock = atom('document clock', documentClock)
156
- // math.min to make sure the tombstone history starts at or before the document clock
157
- const tombstoneHistoryStartsAtClock = Math.min(
158
- snapshot.tombstoneHistoryStartsAtClock ?? documentClock,
159
- documentClock
160
- )
161
- this.tombstoneHistoryStartsAtClock = atom(
162
- 'tombstone history starts at clock',
163
- tombstoneHistoryStartsAtClock
164
- )
165
- // eslint-disable-next-line @typescript-eslint/no-deprecated
166
- this.schema = atom('schema', snapshot.schema ?? createTLSchema().serializeEarliestVersion())
167
- this.tombstones = new AtomMap(
168
- 'room tombstones',
169
- // If the tombstone history starts now (or we didn't have the
170
- // tombstoneHistoryStartsAtClock) then there are no tombstones
171
- tombstoneHistoryStartsAtClock === documentClock
172
- ? []
173
- : objectMapEntries(snapshot.tombstones ?? {})
174
- )
175
- if (onChange) {
176
- this.onChange(onChange)
177
- }
178
- }
179
-
180
- transaction<T>(
181
- callback: TLSyncStorageTransactionCallback<R, T>,
182
- opts?: TLSyncStorageTransactionOptions
183
- ): TLSyncStorageTransactionResult<T, R> {
184
- const clockBefore = this.documentClock.get()
185
- const trackChanges = opts?.emitChanges === 'always'
186
- const txn = new InMemorySyncStorageTransaction<R>(this)
187
- let result: T
188
- let changes: TLSyncForwardDiff<R> | undefined
189
- try {
190
- result = transaction(() => {
191
- return callback(txn as any)
192
- }) as T
193
- if (trackChanges) {
194
- changes = txn.getChangesSince(clockBefore)?.diff
195
- }
196
- } catch (error) {
197
- console.error('Error in transaction', error)
198
- throw error
199
- } finally {
200
- txn.close()
201
- }
202
- if (
203
- typeof result === 'object' &&
204
- result &&
205
- 'then' in result &&
206
- typeof result.then === 'function'
207
- ) {
208
- const err = new Error('Transaction must return a value, not a promise')
209
- console.error(err)
210
- throw err
211
- }
212
-
213
- const clockAfter = this.documentClock.get()
214
- const didChange = clockAfter > clockBefore
215
- if (didChange) {
216
- this.notifier.notify({ id: opts?.id, documentClock: clockAfter })
217
- }
218
- // InMemorySyncStorage applies changes verbatim, so we only emit changes
219
- // when 'always' is specified (not for 'when-different')
220
- return { documentClock: clockAfter, didChange: clockAfter > clockBefore, result, changes }
221
- }
222
-
223
- getClock(): number {
224
- return this.documentClock.get()
225
- }
226
-
227
- /** @internal */
228
- pruneTombstones = throttle(
229
- () => {
230
- if (this.tombstones.size > MAX_TOMBSTONES) {
231
- // Convert to array and sort by clock ascending (oldest first)
232
- const tombstones = Array.from(this.tombstones.entries())
233
- .map(([id, clock]) => ({ id, clock }))
234
- .sort((a, b) => a.clock - b.clock)
235
-
236
- const result = computeTombstonePruning({
237
- tombstones,
238
- documentClock: this.documentClock.get(),
239
- })
240
- if (result) {
241
- this.tombstoneHistoryStartsAtClock.set(result.newTombstoneHistoryStartsAtClock)
242
- this.tombstones.deleteMany(result.idsToDelete)
243
- }
244
- }
245
- },
246
- 1000,
247
- // prevent this from running synchronously to avoid blocking requests
248
- { leading: false }
249
- )
250
-
251
- getSnapshot(): RoomSnapshot {
252
- return {
253
- tombstoneHistoryStartsAtClock: this.tombstoneHistoryStartsAtClock.get(),
254
- documentClock: this.documentClock.get(),
255
- documents: Array.from(this.documents.values()),
256
- tombstones: Object.fromEntries(this.tombstones.entries()),
257
- schema: this.schema.get(),
258
- }
259
- }
260
- }
261
-
262
- /**
263
- * Transaction implementation for InMemorySyncStorage.
264
- * Provides access to documents, tombstones, and metadata within a transaction.
265
- *
266
- * @internal
267
- */
268
- class InMemorySyncStorageTransaction<R extends UnknownRecord>
269
- implements TLSyncStorageTransaction<R>
270
- {
271
- private _clock
272
- private _closed = false
273
-
274
- constructor(private storage: InMemorySyncStorage<R>) {
275
- this._clock = this.storage.documentClock.get()
276
- }
277
-
278
- /** @internal */
279
- close() {
280
- this._closed = true
281
- }
282
-
283
- private assertNotClosed() {
284
- assert(!this._closed, 'Transaction has ended, iterator cannot be consumed')
285
- }
286
-
287
- getClock(): number {
288
- return this._clock
289
- }
290
-
291
- private didIncrementClock: boolean = false
292
- private getNextClock(): number {
293
- if (!this.didIncrementClock) {
294
- this.didIncrementClock = true
295
- this._clock = this.storage.documentClock.set(this.storage.documentClock.get() + 1)
296
- }
297
- return this._clock
298
- }
299
-
300
- get(id: string): R | undefined {
301
- this.assertNotClosed()
302
- return this.storage.documents.get(id)?.state
303
- }
304
-
305
- set(id: string, record: R): void {
306
- this.assertNotClosed()
307
- assert(id === record.id, `Record id mismatch: key does not match record.id`)
308
- const clock = this.getNextClock()
309
- // Automatically clear tombstone if it exists
310
- if (this.storage.tombstones.has(id)) {
311
- this.storage.tombstones.delete(id)
312
- }
313
- this.storage.documents.set(id, {
314
- state: devFreeze(record) as R,
315
- lastChangedClock: clock,
316
- })
317
- }
318
-
319
- delete(id: string): void {
320
- this.assertNotClosed()
321
- // Only create a tombstone if the record actually exists
322
- if (!this.storage.documents.has(id)) return
323
- const clock = this.getNextClock()
324
- this.storage.documents.delete(id)
325
- this.storage.tombstones.set(id, clock)
326
- this.storage.pruneTombstones()
327
- }
328
-
329
- *entries(): IterableIterator<[string, R]> {
330
- this.assertNotClosed()
331
- for (const [id, record] of this.storage.documents.entries()) {
332
- this.assertNotClosed()
333
- yield [id, record.state]
334
- }
335
- }
336
-
337
- *keys(): IterableIterator<string> {
338
- this.assertNotClosed()
339
- for (const key of this.storage.documents.keys()) {
340
- this.assertNotClosed()
341
- yield key
342
- }
343
- }
344
-
345
- *values(): IterableIterator<R> {
346
- this.assertNotClosed()
347
- for (const record of this.storage.documents.values()) {
348
- this.assertNotClosed()
349
- yield record.state
350
- }
351
- }
352
-
353
- getSchema(): SerializedSchema {
354
- this.assertNotClosed()
355
- return this.storage.schema.get()
356
- }
357
-
358
- setSchema(schema: SerializedSchema): void {
359
- this.assertNotClosed()
360
- this.storage.schema.set(schema)
361
- }
362
-
363
- getChangesSince(sinceClock: number): TLSyncStorageGetChangesSinceResult<R> | undefined {
364
- this.assertNotClosed()
365
- const clock = this.storage.documentClock.get()
366
- if (sinceClock === clock) return undefined
367
- if (sinceClock > clock) {
368
- // something went wrong, wipe the slate clean
369
- sinceClock = -1
370
- }
371
- const diff: TLSyncForwardDiff<R> = { puts: {}, deletes: [] }
372
- const wipeAll = sinceClock < this.storage.tombstoneHistoryStartsAtClock.get()
373
- for (const doc of this.storage.documents.values()) {
374
- if (wipeAll || doc.lastChangedClock > sinceClock) {
375
- // For historical changes, we don't have "from" state, so use added
376
- diff.puts[doc.state.id] = doc.state as R
377
- }
378
- }
379
- for (const [id, clock] of this.storage.tombstones.entries()) {
380
- if (clock > sinceClock) {
381
- // For tombstones, we don't have the removed record, use placeholder
382
- diff.deletes.push(id)
383
- }
384
- }
385
- return { diff, wipeAll }
386
- }
387
- }