@tldraw/sync-core 4.3.0-canary.d8da2a99f394 → 4.3.0-canary.e1766dd4eab3
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.
- package/dist-cjs/index.d.ts +239 -57
- package/dist-cjs/index.js +7 -3
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/InMemorySyncStorage.js +289 -0
- package/dist-cjs/lib/InMemorySyncStorage.js.map +7 -0
- package/dist-cjs/lib/RoomSession.js.map +1 -1
- package/dist-cjs/lib/TLSocketRoom.js +117 -69
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +7 -0
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +357 -688
- package/dist-cjs/lib/TLSyncRoom.js.map +3 -3
- package/dist-cjs/lib/TLSyncStorage.js +76 -0
- package/dist-cjs/lib/TLSyncStorage.js.map +7 -0
- package/dist-cjs/lib/recordDiff.js +52 -0
- package/dist-cjs/lib/recordDiff.js.map +7 -0
- package/dist-esm/index.d.mts +239 -57
- package/dist-esm/index.mjs +12 -5
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/InMemorySyncStorage.mjs +274 -0
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +7 -0
- package/dist-esm/lib/RoomSession.mjs.map +1 -1
- package/dist-esm/lib/TLSocketRoom.mjs +121 -70
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +7 -0
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +370 -702
- package/dist-esm/lib/TLSyncRoom.mjs.map +3 -3
- package/dist-esm/lib/TLSyncStorage.mjs +56 -0
- package/dist-esm/lib/TLSyncStorage.mjs.map +7 -0
- package/dist-esm/lib/recordDiff.mjs +32 -0
- package/dist-esm/lib/recordDiff.mjs.map +7 -0
- package/package.json +6 -6
- package/src/index.ts +21 -3
- package/src/lib/InMemorySyncStorage.ts +357 -0
- package/src/lib/RoomSession.test.ts +1 -0
- package/src/lib/RoomSession.ts +2 -0
- package/src/lib/TLSocketRoom.ts +228 -114
- package/src/lib/TLSyncClient.ts +12 -0
- package/src/lib/TLSyncRoom.ts +473 -913
- package/src/lib/TLSyncStorage.ts +216 -0
- package/src/lib/recordDiff.ts +73 -0
- package/src/test/FuzzEditor.ts +4 -5
- package/src/test/InMemorySyncStorage.test.ts +1674 -0
- package/src/test/TLSocketRoom.test.ts +255 -49
- package/src/test/TLSyncRoom.test.ts +1022 -534
- package/src/test/TestServer.ts +12 -1
- package/src/test/customMessages.test.ts +1 -1
- package/src/test/presenceMode.test.ts +6 -6
- package/src/test/syncFuzz.test.ts +2 -4
- package/src/test/upgradeDowngrade.test.ts +282 -8
- package/src/test/validation.test.ts +10 -10
- package/src/test/pruneTombstones.test.ts +0 -178
|
@@ -0,0 +1,357 @@
|
|
|
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 { RoomSnapshot } from './TLSyncRoom'
|
|
12
|
+
import {
|
|
13
|
+
TLSyncForwardDiff,
|
|
14
|
+
TLSyncStorage,
|
|
15
|
+
TLSyncStorageGetChangesSinceResult,
|
|
16
|
+
TLSyncStorageOnChangeCallbackProps,
|
|
17
|
+
TLSyncStorageTransaction,
|
|
18
|
+
TLSyncStorageTransactionCallback,
|
|
19
|
+
TLSyncStorageTransactionOptions,
|
|
20
|
+
TLSyncStorageTransactionResult,
|
|
21
|
+
} from './TLSyncStorage'
|
|
22
|
+
|
|
23
|
+
/** @internal */
|
|
24
|
+
export const TOMBSTONE_PRUNE_BUFFER_SIZE = 1000
|
|
25
|
+
/** @internal */
|
|
26
|
+
export const MAX_TOMBSTONES = 5000
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Default initial snapshot for a new room.
|
|
30
|
+
* @public
|
|
31
|
+
*/
|
|
32
|
+
export const DEFAULT_INITIAL_SNAPSHOT = {
|
|
33
|
+
documentClock: 0,
|
|
34
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
35
|
+
schema: createTLSchema().serialize(),
|
|
36
|
+
documents: [
|
|
37
|
+
{
|
|
38
|
+
state: DocumentRecordType.create({ id: TLDOCUMENT_ID }),
|
|
39
|
+
lastChangedClock: 0,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
state: PageRecordType.create({
|
|
43
|
+
id: 'page:page' as TLPageId,
|
|
44
|
+
name: 'Page 1',
|
|
45
|
+
index: 'a1' as IndexKey,
|
|
46
|
+
}),
|
|
47
|
+
lastChangedClock: 0,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* In-memory implementation of TLSyncStorage using AtomMap for documents and tombstones,
|
|
54
|
+
* and atoms for clock values. This is the default storage implementation used by TLSyncRoom.
|
|
55
|
+
*
|
|
56
|
+
* @public
|
|
57
|
+
*/
|
|
58
|
+
export class InMemorySyncStorage<R extends UnknownRecord> implements TLSyncStorage<R> {
|
|
59
|
+
/** @internal */
|
|
60
|
+
documents: AtomMap<string, { state: R; lastChangedClock: number }>
|
|
61
|
+
/** @internal */
|
|
62
|
+
tombstones: AtomMap<string, number>
|
|
63
|
+
/** @internal */
|
|
64
|
+
schema: Atom<SerializedSchema>
|
|
65
|
+
/** @internal */
|
|
66
|
+
documentClock: Atom<number>
|
|
67
|
+
/** @internal */
|
|
68
|
+
tombstoneHistoryStartsAtClock: Atom<number>
|
|
69
|
+
|
|
70
|
+
private listeners = new Set<(arg: TLSyncStorageOnChangeCallbackProps) => unknown>()
|
|
71
|
+
onChange(callback: (arg: TLSyncStorageOnChangeCallbackProps) => unknown): () => void {
|
|
72
|
+
let didDelete = false
|
|
73
|
+
// we put the callback registration in a microtask because the callback is invoked
|
|
74
|
+
// in a microtask, and so this makes sure the callback is invoked after all the updates
|
|
75
|
+
// that happened in the current callstack before this onChange registration have been processed.
|
|
76
|
+
queueMicrotask(() => {
|
|
77
|
+
if (didDelete) return
|
|
78
|
+
this.listeners.add(callback)
|
|
79
|
+
})
|
|
80
|
+
return () => {
|
|
81
|
+
if (didDelete) return
|
|
82
|
+
didDelete = true
|
|
83
|
+
this.listeners.delete(callback)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
constructor({
|
|
88
|
+
snapshot = DEFAULT_INITIAL_SNAPSHOT,
|
|
89
|
+
onChange,
|
|
90
|
+
}: {
|
|
91
|
+
snapshot?: RoomSnapshot
|
|
92
|
+
onChange?(arg: TLSyncStorageOnChangeCallbackProps): unknown
|
|
93
|
+
} = {}) {
|
|
94
|
+
const maxClockValue = Math.max(
|
|
95
|
+
0,
|
|
96
|
+
...Object.values(snapshot.tombstones ?? {}),
|
|
97
|
+
...Object.values(snapshot.documents.map((d) => d.lastChangedClock))
|
|
98
|
+
)
|
|
99
|
+
this.documents = new AtomMap(
|
|
100
|
+
'room documents',
|
|
101
|
+
snapshot.documents.map((d) => [
|
|
102
|
+
d.state.id,
|
|
103
|
+
{ state: devFreeze(d.state) as R, lastChangedClock: d.lastChangedClock },
|
|
104
|
+
])
|
|
105
|
+
)
|
|
106
|
+
const documentClock = Math.max(maxClockValue, snapshot.documentClock ?? snapshot.clock ?? 0)
|
|
107
|
+
|
|
108
|
+
this.documentClock = atom('document clock', documentClock)
|
|
109
|
+
// math.min to make sure the tombstone history starts at or before the document clock
|
|
110
|
+
const tombstoneHistoryStartsAtClock = Math.min(
|
|
111
|
+
snapshot.tombstoneHistoryStartsAtClock ?? documentClock,
|
|
112
|
+
documentClock
|
|
113
|
+
)
|
|
114
|
+
this.tombstoneHistoryStartsAtClock = atom(
|
|
115
|
+
'tombstone history starts at clock',
|
|
116
|
+
tombstoneHistoryStartsAtClock
|
|
117
|
+
)
|
|
118
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
119
|
+
this.schema = atom('schema', snapshot.schema ?? createTLSchema().serializeEarliestVersion())
|
|
120
|
+
this.tombstones = new AtomMap(
|
|
121
|
+
'room tombstones',
|
|
122
|
+
// If the tombstone history starts now (or we didn't have the
|
|
123
|
+
// tombstoneHistoryStartsAtClock) then there are no tombstones
|
|
124
|
+
tombstoneHistoryStartsAtClock === documentClock
|
|
125
|
+
? []
|
|
126
|
+
: objectMapEntries(snapshot.tombstones ?? {})
|
|
127
|
+
)
|
|
128
|
+
if (onChange) {
|
|
129
|
+
this.onChange(onChange)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
transaction<T>(
|
|
134
|
+
callback: TLSyncStorageTransactionCallback<R, T>,
|
|
135
|
+
opts?: TLSyncStorageTransactionOptions
|
|
136
|
+
): TLSyncStorageTransactionResult<T, R> {
|
|
137
|
+
const clockBefore = this.documentClock.get()
|
|
138
|
+
const trackChanges = opts?.emitChanges === 'always'
|
|
139
|
+
const txn = new InMemorySyncStorageTransaction<R>(this)
|
|
140
|
+
let result: T
|
|
141
|
+
let changes: TLSyncForwardDiff<R> | undefined
|
|
142
|
+
try {
|
|
143
|
+
result = transaction(() => {
|
|
144
|
+
return callback(txn as any)
|
|
145
|
+
}) as T
|
|
146
|
+
if (trackChanges) {
|
|
147
|
+
changes = txn.getChangesSince(clockBefore)?.diff
|
|
148
|
+
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.error('Error in transaction', error)
|
|
151
|
+
throw error
|
|
152
|
+
} finally {
|
|
153
|
+
txn.close()
|
|
154
|
+
}
|
|
155
|
+
if (
|
|
156
|
+
typeof result === 'object' &&
|
|
157
|
+
result &&
|
|
158
|
+
'then' in result &&
|
|
159
|
+
typeof result.then === 'function'
|
|
160
|
+
) {
|
|
161
|
+
const err = new Error('Transaction must return a value, not a promise')
|
|
162
|
+
console.error(err)
|
|
163
|
+
throw err
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const clockAfter = this.documentClock.get()
|
|
167
|
+
const didChange = clockAfter > clockBefore
|
|
168
|
+
if (didChange) {
|
|
169
|
+
// todo: batch these updates
|
|
170
|
+
queueMicrotask(() => {
|
|
171
|
+
const props: TLSyncStorageOnChangeCallbackProps = {
|
|
172
|
+
id: opts?.id,
|
|
173
|
+
documentClock: clockAfter,
|
|
174
|
+
}
|
|
175
|
+
for (const listener of this.listeners) {
|
|
176
|
+
try {
|
|
177
|
+
listener(props)
|
|
178
|
+
} catch (error) {
|
|
179
|
+
console.error('Error in onChange callback', error)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
// InMemorySyncStorage applies changes verbatim, so we only emit changes
|
|
185
|
+
// when 'always' is specified (not for 'when-different')
|
|
186
|
+
return { documentClock: clockAfter, didChange: clockAfter > clockBefore, result, changes }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
getClock(): number {
|
|
190
|
+
return this.documentClock.get()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** @internal */
|
|
194
|
+
pruneTombstones = throttle(
|
|
195
|
+
() => {
|
|
196
|
+
if (this.tombstones.size > MAX_TOMBSTONES) {
|
|
197
|
+
const tombstones = Array.from(this.tombstones)
|
|
198
|
+
// sort entries in ascending order by clock (oldest first)
|
|
199
|
+
tombstones.sort((a, b) => a[1] - b[1])
|
|
200
|
+
// determine how many to delete, avoiding partial history for a clock value
|
|
201
|
+
let cutoff = TOMBSTONE_PRUNE_BUFFER_SIZE + this.tombstones.size - MAX_TOMBSTONES
|
|
202
|
+
while (cutoff < tombstones.length && tombstones[cutoff - 1][1] === tombstones[cutoff][1]) {
|
|
203
|
+
cutoff++
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Set history start to the oldest remaining tombstone's clock
|
|
207
|
+
// (or documentClock if we're deleting everything)
|
|
208
|
+
const oldestRemaining = tombstones[cutoff]
|
|
209
|
+
this.tombstoneHistoryStartsAtClock.set(oldestRemaining?.[1] ?? this.documentClock.get())
|
|
210
|
+
|
|
211
|
+
// Delete the oldest tombstones (first cutoff entries)
|
|
212
|
+
const toDelete = tombstones.slice(0, cutoff)
|
|
213
|
+
this.tombstones.deleteMany(toDelete.map(([id]) => id))
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
1000,
|
|
217
|
+
// prevent this from running synchronously to avoid blocking requests
|
|
218
|
+
{ leading: false }
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
getSnapshot(): RoomSnapshot {
|
|
222
|
+
return {
|
|
223
|
+
tombstoneHistoryStartsAtClock: this.tombstoneHistoryStartsAtClock.get(),
|
|
224
|
+
documentClock: this.documentClock.get(),
|
|
225
|
+
documents: Array.from(this.documents.values()),
|
|
226
|
+
tombstones: Object.fromEntries(this.tombstones.entries()),
|
|
227
|
+
schema: this.schema.get(),
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Transaction implementation for InMemorySyncStorage.
|
|
234
|
+
* Provides access to documents, tombstones, and metadata within a transaction.
|
|
235
|
+
*
|
|
236
|
+
* @internal
|
|
237
|
+
*/
|
|
238
|
+
class InMemorySyncStorageTransaction<R extends UnknownRecord>
|
|
239
|
+
implements TLSyncStorageTransaction<R>
|
|
240
|
+
{
|
|
241
|
+
private _clock
|
|
242
|
+
private _closed = false
|
|
243
|
+
|
|
244
|
+
constructor(private storage: InMemorySyncStorage<R>) {
|
|
245
|
+
this._clock = this.storage.documentClock.get()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** @internal */
|
|
249
|
+
close() {
|
|
250
|
+
this._closed = true
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private assertNotClosed() {
|
|
254
|
+
assert(!this._closed, 'Transaction has ended, iterator cannot be consumed')
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
getClock(): number {
|
|
258
|
+
return this._clock
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private didIncrementClock: boolean = false
|
|
262
|
+
private getNextClock(): number {
|
|
263
|
+
if (!this.didIncrementClock) {
|
|
264
|
+
this.didIncrementClock = true
|
|
265
|
+
this._clock = this.storage.documentClock.set(this.storage.documentClock.get() + 1)
|
|
266
|
+
}
|
|
267
|
+
return this._clock
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
get(id: string): R | undefined {
|
|
271
|
+
this.assertNotClosed()
|
|
272
|
+
return this.storage.documents.get(id)?.state
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
set(id: string, record: R): void {
|
|
276
|
+
this.assertNotClosed()
|
|
277
|
+
assert(id === record.id, `Record id mismatch: key does not match record.id`)
|
|
278
|
+
const clock = this.getNextClock()
|
|
279
|
+
// Automatically clear tombstone if it exists
|
|
280
|
+
if (this.storage.tombstones.has(id)) {
|
|
281
|
+
this.storage.tombstones.delete(id)
|
|
282
|
+
}
|
|
283
|
+
this.storage.documents.set(id, {
|
|
284
|
+
state: devFreeze(record) as R,
|
|
285
|
+
lastChangedClock: clock,
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
delete(id: string): void {
|
|
290
|
+
this.assertNotClosed()
|
|
291
|
+
// Only create a tombstone if the record actually exists
|
|
292
|
+
if (!this.storage.documents.has(id)) return
|
|
293
|
+
const clock = this.getNextClock()
|
|
294
|
+
this.storage.documents.delete(id)
|
|
295
|
+
this.storage.tombstones.set(id, clock)
|
|
296
|
+
this.storage.pruneTombstones()
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
*entries(): IterableIterator<[string, R]> {
|
|
300
|
+
this.assertNotClosed()
|
|
301
|
+
for (const [id, record] of this.storage.documents.entries()) {
|
|
302
|
+
this.assertNotClosed()
|
|
303
|
+
yield [id, record.state]
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
*keys(): IterableIterator<string> {
|
|
308
|
+
this.assertNotClosed()
|
|
309
|
+
for (const key of this.storage.documents.keys()) {
|
|
310
|
+
this.assertNotClosed()
|
|
311
|
+
yield key
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
*values(): IterableIterator<R> {
|
|
316
|
+
this.assertNotClosed()
|
|
317
|
+
for (const record of this.storage.documents.values()) {
|
|
318
|
+
this.assertNotClosed()
|
|
319
|
+
yield record.state
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
getSchema(): SerializedSchema {
|
|
324
|
+
this.assertNotClosed()
|
|
325
|
+
return this.storage.schema.get()
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
setSchema(schema: SerializedSchema): void {
|
|
329
|
+
this.assertNotClosed()
|
|
330
|
+
this.storage.schema.set(schema)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
getChangesSince(sinceClock: number): TLSyncStorageGetChangesSinceResult<R> | undefined {
|
|
334
|
+
this.assertNotClosed()
|
|
335
|
+
const clock = this.storage.documentClock.get()
|
|
336
|
+
if (sinceClock === clock) return undefined
|
|
337
|
+
if (sinceClock > clock) {
|
|
338
|
+
// something went wrong, wipe the slate clean
|
|
339
|
+
sinceClock = -1
|
|
340
|
+
}
|
|
341
|
+
const diff: TLSyncForwardDiff<R> = { puts: {}, deletes: [] }
|
|
342
|
+
const wipeAll = sinceClock < this.storage.tombstoneHistoryStartsAtClock.get()
|
|
343
|
+
for (const doc of this.storage.documents.values()) {
|
|
344
|
+
if (wipeAll || doc.lastChangedClock > sinceClock) {
|
|
345
|
+
// For historical changes, we don't have "from" state, so use added
|
|
346
|
+
diff.puts[doc.state.id] = doc.state as R
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
for (const [id, clock] of this.storage.tombstones.entries()) {
|
|
350
|
+
if (clock > sinceClock) {
|
|
351
|
+
// For tombstones, we don't have the removed record, use placeholder
|
|
352
|
+
diff.deletes.push(id)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return { diff, wipeAll }
|
|
356
|
+
}
|
|
357
|
+
}
|
|
@@ -68,6 +68,7 @@ describe('RoomSession state transitions', () => {
|
|
|
68
68
|
isReadonly: initialSession.isReadonly,
|
|
69
69
|
requiresLegacyRejection: initialSession.requiresLegacyRejection,
|
|
70
70
|
serializedSchema: mockSerializedSchema,
|
|
71
|
+
requiresDownMigrations: false,
|
|
71
72
|
supportsStringAppend: true,
|
|
72
73
|
lastInteractionTime: Date.now(),
|
|
73
74
|
debounceTimer: null,
|
package/src/lib/RoomSession.ts
CHANGED
|
@@ -132,6 +132,8 @@ export type RoomSession<R extends UnknownRecord, Meta> =
|
|
|
132
132
|
state: typeof RoomSessionState.Connected
|
|
133
133
|
/** Serialized schema information for this connected session */
|
|
134
134
|
serializedSchema: SerializedSchema
|
|
135
|
+
/** Whether this session requires down migrations */
|
|
136
|
+
requiresDownMigrations: boolean
|
|
135
137
|
/** Timestamp of the last interaction or message from this session */
|
|
136
138
|
lastInteractionTime: number
|
|
137
139
|
/** Timer for debouncing operations, if active */
|