@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,1674 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createTLSchema,
|
|
3
|
+
DocumentRecordType,
|
|
4
|
+
PageRecordType,
|
|
5
|
+
TLDOCUMENT_ID,
|
|
6
|
+
TLRecord,
|
|
7
|
+
} from '@tldraw/tlschema'
|
|
8
|
+
import { IndexKey, ZERO_INDEX_KEY } from '@tldraw/utils'
|
|
9
|
+
import { vi } from 'vitest'
|
|
10
|
+
import {
|
|
11
|
+
InMemorySyncStorage,
|
|
12
|
+
MAX_TOMBSTONES,
|
|
13
|
+
TOMBSTONE_PRUNE_BUFFER_SIZE,
|
|
14
|
+
} from '../lib/InMemorySyncStorage'
|
|
15
|
+
import { RoomSnapshot } from '../lib/TLSyncRoom'
|
|
16
|
+
import { convertStoreSnapshotToRoomSnapshot, loadSnapshotIntoStorage } from '../lib/TLSyncStorage'
|
|
17
|
+
|
|
18
|
+
const tlSchema = createTLSchema()
|
|
19
|
+
|
|
20
|
+
const makeSnapshot = (records: TLRecord[], others: Partial<RoomSnapshot> = {}): RoomSnapshot => ({
|
|
21
|
+
documents: records.map((r) => ({ state: r, lastChangedClock: 0 })),
|
|
22
|
+
clock: 0,
|
|
23
|
+
documentClock: 0,
|
|
24
|
+
schema: tlSchema.serialize(),
|
|
25
|
+
...others,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
// Helper to create legacy snapshots without documentClock field
|
|
29
|
+
const makeLegacySnapshot = (
|
|
30
|
+
records: TLRecord[],
|
|
31
|
+
others: Partial<Omit<RoomSnapshot, 'documentClock'>> = {}
|
|
32
|
+
): Omit<RoomSnapshot, 'documentClock'> & { schema: RoomSnapshot['schema'] } => ({
|
|
33
|
+
documents: records.map((r) => ({ state: r, lastChangedClock: 0 })),
|
|
34
|
+
clock: 0,
|
|
35
|
+
schema: tlSchema.serialize(),
|
|
36
|
+
...others,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const defaultRecords = [
|
|
40
|
+
DocumentRecordType.create({ id: TLDOCUMENT_ID }),
|
|
41
|
+
PageRecordType.create({
|
|
42
|
+
index: ZERO_INDEX_KEY,
|
|
43
|
+
name: 'Page 1',
|
|
44
|
+
id: PageRecordType.createId('page_1'),
|
|
45
|
+
}),
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
describe('InMemorySyncStorage', () => {
|
|
49
|
+
describe('Constructor', () => {
|
|
50
|
+
it('initializes documents from snapshot', () => {
|
|
51
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot: makeSnapshot(defaultRecords) })
|
|
52
|
+
|
|
53
|
+
expect(storage.documents.size).toBe(2)
|
|
54
|
+
expect(storage.documents.get(TLDOCUMENT_ID)?.state.id).toBe(TLDOCUMENT_ID)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('initializes schema from snapshot', () => {
|
|
58
|
+
const snapshot = makeSnapshot(defaultRecords)
|
|
59
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot })
|
|
60
|
+
|
|
61
|
+
expect(storage.schema.get()).toEqual(snapshot.schema)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('initializes documentClock from snapshot', () => {
|
|
65
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
66
|
+
snapshot: makeSnapshot(defaultRecords, { documentClock: 42 }),
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
expect(storage.getClock()).toBe(42)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('falls back to clock when documentClock is not present (legacy snapshot)', () => {
|
|
73
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
74
|
+
snapshot: makeLegacySnapshot(defaultRecords, { clock: 15 }) as RoomSnapshot,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
expect(storage.getClock()).toBe(15)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('falls back to 0 when neither documentClock nor clock is present', () => {
|
|
81
|
+
const snapshot = {
|
|
82
|
+
documents: defaultRecords.map((r) => ({ state: r, lastChangedClock: 0 })),
|
|
83
|
+
schema: tlSchema.serialize(),
|
|
84
|
+
} as RoomSnapshot
|
|
85
|
+
|
|
86
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot })
|
|
87
|
+
|
|
88
|
+
expect(storage.getClock()).toBe(0)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('initializes tombstones from snapshot', () => {
|
|
92
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
93
|
+
snapshot: makeSnapshot(defaultRecords, {
|
|
94
|
+
tombstones: { 'shape:deleted1': 5, 'shape:deleted2': 10 },
|
|
95
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
96
|
+
documentClock: 15,
|
|
97
|
+
}),
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
expect(storage.tombstones.size).toBe(2)
|
|
101
|
+
expect(storage.tombstones.get('shape:deleted1')).toBe(5)
|
|
102
|
+
expect(storage.tombstones.get('shape:deleted2')).toBe(10)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('skips tombstones when tombstoneHistoryStartsAtClock equals documentClock', () => {
|
|
106
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
107
|
+
snapshot: makeSnapshot(defaultRecords, {
|
|
108
|
+
tombstones: { 'shape:deleted1': 5 },
|
|
109
|
+
tombstoneHistoryStartsAtClock: 15,
|
|
110
|
+
documentClock: 15,
|
|
111
|
+
}),
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// Tombstones should be skipped since history starts at current clock
|
|
115
|
+
expect(storage.tombstones.size).toBe(0)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('sets tombstoneHistoryStartsAtClock from snapshot', () => {
|
|
119
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
120
|
+
snapshot: makeSnapshot(defaultRecords, {
|
|
121
|
+
tombstoneHistoryStartsAtClock: 5,
|
|
122
|
+
documentClock: 10,
|
|
123
|
+
}),
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
expect(storage.tombstoneHistoryStartsAtClock.get()).toBe(5)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('defaults tombstoneHistoryStartsAtClock to documentClock when not provided', () => {
|
|
130
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
131
|
+
snapshot: makeSnapshot(defaultRecords, { documentClock: 20 }),
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
expect(storage.tombstoneHistoryStartsAtClock.get()).toBe(20)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('handles empty documents array', () => {
|
|
138
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
139
|
+
snapshot: makeSnapshot([]),
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
expect(storage.documents.size).toBe(0)
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('Transaction', () => {
|
|
147
|
+
describe('get()', () => {
|
|
148
|
+
it('returns record by id', () => {
|
|
149
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
150
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
storage.transaction((txn) => {
|
|
154
|
+
const doc = txn.get(TLDOCUMENT_ID)
|
|
155
|
+
expect(doc).toBeDefined()
|
|
156
|
+
expect(doc?.id).toBe(TLDOCUMENT_ID)
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('returns undefined for non-existent record', () => {
|
|
161
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
162
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
storage.transaction((txn) => {
|
|
166
|
+
expect(txn.get('nonexistent')).toBeUndefined()
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe('set()', () => {
|
|
172
|
+
it('creates new records', () => {
|
|
173
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
174
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const newPage = PageRecordType.create({
|
|
178
|
+
id: PageRecordType.createId('new_page'),
|
|
179
|
+
name: 'New Page',
|
|
180
|
+
index: 'a2' as IndexKey,
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
storage.transaction((txn) => {
|
|
184
|
+
txn.set(newPage.id, newPage)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
expect(storage.documents.size).toBe(3)
|
|
188
|
+
expect(storage.documents.get(newPage.id)?.state).toEqual(newPage)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('updates existing records', () => {
|
|
192
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
193
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
const pageId = PageRecordType.createId('page_1')
|
|
197
|
+
const updatedPage = PageRecordType.create({
|
|
198
|
+
id: pageId,
|
|
199
|
+
name: 'Updated Page',
|
|
200
|
+
index: ZERO_INDEX_KEY,
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
storage.transaction((txn) => {
|
|
204
|
+
txn.set(pageId, updatedPage)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
expect(storage.documents.get(pageId)?.state).toEqual(updatedPage)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('clears tombstone when re-creating a deleted record', () => {
|
|
211
|
+
const pageId = PageRecordType.createId('page_to_delete')
|
|
212
|
+
const page = PageRecordType.create({
|
|
213
|
+
id: pageId,
|
|
214
|
+
name: 'Page',
|
|
215
|
+
index: 'a2' as IndexKey,
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
219
|
+
snapshot: makeSnapshot([...defaultRecords, page]),
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// Delete the page
|
|
223
|
+
storage.transaction((txn) => {
|
|
224
|
+
txn.delete(pageId)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
expect(storage.tombstones.has(pageId)).toBe(true)
|
|
228
|
+
expect(storage.documents.has(pageId)).toBe(false)
|
|
229
|
+
|
|
230
|
+
// Re-create the page
|
|
231
|
+
storage.transaction((txn) => {
|
|
232
|
+
txn.set(pageId, page)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
expect(storage.tombstones.has(pageId)).toBe(false)
|
|
236
|
+
expect(storage.documents.has(pageId)).toBe(true)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('sets lastChangedClock to the incremented clock', () => {
|
|
240
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
241
|
+
snapshot: makeSnapshot(defaultRecords, { documentClock: 5 }),
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
const newPage = PageRecordType.create({
|
|
245
|
+
id: PageRecordType.createId('new_page'),
|
|
246
|
+
name: 'New Page',
|
|
247
|
+
index: 'a2' as IndexKey,
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
storage.transaction((txn) => {
|
|
251
|
+
txn.set(newPage.id, newPage)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
expect(storage.documents.get(newPage.id)?.lastChangedClock).toBe(6)
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
describe('delete()', () => {
|
|
259
|
+
it('removes records', () => {
|
|
260
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
261
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
const pageId = PageRecordType.createId('page_1')
|
|
265
|
+
|
|
266
|
+
storage.transaction((txn) => {
|
|
267
|
+
txn.delete(pageId)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
expect(storage.documents.has(pageId)).toBe(false)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('creates tombstones', () => {
|
|
274
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
275
|
+
snapshot: makeSnapshot(defaultRecords, { documentClock: 10 }),
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
const pageId = PageRecordType.createId('page_1')
|
|
279
|
+
|
|
280
|
+
storage.transaction((txn) => {
|
|
281
|
+
txn.delete(pageId)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
expect(storage.tombstones.has(pageId)).toBe(true)
|
|
285
|
+
expect(storage.tombstones.get(pageId)).toBe(11) // clock incremented to 11
|
|
286
|
+
})
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
describe('getClock()', () => {
|
|
290
|
+
it('returns current clock at start of transaction', () => {
|
|
291
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
292
|
+
snapshot: makeSnapshot(defaultRecords, { documentClock: 42 }),
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
storage.transaction((txn) => {
|
|
296
|
+
expect(txn.getClock()).toBe(42)
|
|
297
|
+
})
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('returns incremented clock after a write', () => {
|
|
301
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
302
|
+
snapshot: makeSnapshot(defaultRecords, { documentClock: 42 }),
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
storage.transaction((txn) => {
|
|
306
|
+
expect(txn.getClock()).toBe(42)
|
|
307
|
+
const newPage = PageRecordType.create({
|
|
308
|
+
id: PageRecordType.createId('new'),
|
|
309
|
+
name: 'New',
|
|
310
|
+
index: 'a2' as IndexKey,
|
|
311
|
+
})
|
|
312
|
+
txn.set(newPage.id, newPage)
|
|
313
|
+
expect(txn.getClock()).toBe(43)
|
|
314
|
+
})
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('increments clock only once per transaction', () => {
|
|
318
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
319
|
+
snapshot: makeSnapshot(defaultRecords, { documentClock: 10 }),
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
storage.transaction((txn) => {
|
|
323
|
+
const page1 = PageRecordType.create({
|
|
324
|
+
id: PageRecordType.createId('p1'),
|
|
325
|
+
name: 'P1',
|
|
326
|
+
index: 'a2' as IndexKey,
|
|
327
|
+
})
|
|
328
|
+
const page2 = PageRecordType.create({
|
|
329
|
+
id: PageRecordType.createId('p2'),
|
|
330
|
+
name: 'P2',
|
|
331
|
+
index: 'a3' as IndexKey,
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
txn.set(page1.id, page1)
|
|
335
|
+
expect(txn.getClock()).toBe(11)
|
|
336
|
+
|
|
337
|
+
txn.set(page2.id, page2)
|
|
338
|
+
expect(txn.getClock()).toBe(11) // Still 11, not 12
|
|
339
|
+
|
|
340
|
+
txn.delete(PageRecordType.createId('page_1'))
|
|
341
|
+
expect(txn.getClock()).toBe(11) // Still 11
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
expect(storage.getClock()).toBe(11)
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
describe('entries()', () => {
|
|
349
|
+
it('iterates over all documents', () => {
|
|
350
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
351
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
storage.transaction((txn) => {
|
|
355
|
+
const entries = Array.from(txn.entries())
|
|
356
|
+
expect(entries.length).toBe(2)
|
|
357
|
+
expect(entries.map(([id]) => id).sort()).toEqual(defaultRecords.map((r) => r.id).sort())
|
|
358
|
+
})
|
|
359
|
+
})
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
describe('keys()', () => {
|
|
363
|
+
it('iterates over all document ids', () => {
|
|
364
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
365
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
storage.transaction((txn) => {
|
|
369
|
+
const keys = Array.from(txn.keys())
|
|
370
|
+
expect(keys.sort()).toEqual(defaultRecords.map((r) => r.id).sort())
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
describe('values()', () => {
|
|
376
|
+
it('iterates over all document states', () => {
|
|
377
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
378
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
storage.transaction((txn) => {
|
|
382
|
+
const values = Array.from(txn.values())
|
|
383
|
+
expect(values.length).toBe(2)
|
|
384
|
+
expect(values.map((v) => v.id).sort()).toEqual(defaultRecords.map((r) => r.id).sort())
|
|
385
|
+
})
|
|
386
|
+
})
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
describe('iterator consumption after transaction ends', () => {
|
|
390
|
+
it('throws when entries() iterator is consumed after transaction ends', () => {
|
|
391
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
392
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
let iterator: Iterator<[string, TLRecord]>
|
|
396
|
+
|
|
397
|
+
storage.transaction((txn) => {
|
|
398
|
+
iterator = txn.entries()[Symbol.iterator]()
|
|
399
|
+
// Consume one item inside the transaction - should work
|
|
400
|
+
iterator.next()
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
// Trying to consume more after transaction ends should throw
|
|
404
|
+
expect(() => iterator.next()).toThrow('Transaction has ended')
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
it('throws when keys() iterator is consumed after transaction ends', () => {
|
|
408
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
409
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
let iterator: Iterator<string>
|
|
413
|
+
|
|
414
|
+
storage.transaction((txn) => {
|
|
415
|
+
iterator = txn.keys()[Symbol.iterator]()
|
|
416
|
+
iterator.next()
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
expect(() => iterator.next()).toThrow('Transaction has ended')
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('throws when values() iterator is consumed after transaction ends', () => {
|
|
423
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
424
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
let iterator: Iterator<TLRecord>
|
|
428
|
+
|
|
429
|
+
storage.transaction((txn) => {
|
|
430
|
+
iterator = txn.values()[Symbol.iterator]()
|
|
431
|
+
iterator.next()
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
expect(() => iterator.next()).toThrow('Transaction has ended')
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('allows full consumption of iterator within transaction', () => {
|
|
438
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
439
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
storage.transaction((txn) => {
|
|
443
|
+
// Should be able to fully consume all iterators
|
|
444
|
+
const entries = Array.from(txn.entries())
|
|
445
|
+
const keys = Array.from(txn.keys())
|
|
446
|
+
const values = Array.from(txn.values())
|
|
447
|
+
|
|
448
|
+
expect(entries.length).toBe(2)
|
|
449
|
+
expect(keys.length).toBe(2)
|
|
450
|
+
expect(values.length).toBe(2)
|
|
451
|
+
})
|
|
452
|
+
})
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
describe('getSchema() / setSchema()', () => {
|
|
456
|
+
it('gets the current schema', () => {
|
|
457
|
+
const snapshot = makeSnapshot(defaultRecords)
|
|
458
|
+
const storage = new InMemorySyncStorage<TLRecord>({ snapshot })
|
|
459
|
+
|
|
460
|
+
storage.transaction((txn) => {
|
|
461
|
+
expect(txn.getSchema()).toEqual(snapshot.schema)
|
|
462
|
+
})
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
it('sets the schema', () => {
|
|
466
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
467
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
const newSchema = { ...tlSchema.serialize(), schemaVersion: 99 as any }
|
|
471
|
+
|
|
472
|
+
storage.transaction((txn) => {
|
|
473
|
+
txn.setSchema(newSchema)
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
expect(storage.schema.get().schemaVersion).toBe(99)
|
|
477
|
+
})
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
describe('transaction result', () => {
|
|
481
|
+
it('returns result from callback', () => {
|
|
482
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
483
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
const { result } = storage.transaction((txn) => {
|
|
487
|
+
return txn.get(TLDOCUMENT_ID)
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
expect(result?.id).toBe(TLDOCUMENT_ID)
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
it('returns didChange: false when no writes occur', () => {
|
|
494
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
495
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
const { didChange, documentClock } = storage.transaction((txn) => {
|
|
499
|
+
txn.get(TLDOCUMENT_ID)
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
expect(didChange).toBe(false)
|
|
503
|
+
expect(documentClock).toBe(0)
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
it('returns didChange: true when writes occur', () => {
|
|
507
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
508
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
const newPage = PageRecordType.create({
|
|
512
|
+
id: PageRecordType.createId('new'),
|
|
513
|
+
name: 'New',
|
|
514
|
+
index: 'a2' as IndexKey,
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
const { didChange, documentClock } = storage.transaction((txn) => {
|
|
518
|
+
txn.set(newPage.id, newPage)
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
expect(didChange).toBe(true)
|
|
522
|
+
expect(documentClock).toBe(1)
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
it('throws when callback returns a promise', () => {
|
|
526
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
527
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
expect(() => {
|
|
531
|
+
storage.transaction(() => Promise.resolve() as any)
|
|
532
|
+
}).toThrow('Transaction must return a value, not a promise')
|
|
533
|
+
})
|
|
534
|
+
})
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
describe('getChangesSince', () => {
|
|
538
|
+
it('returns puts for records changed after sinceClock', () => {
|
|
539
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
540
|
+
snapshot: makeSnapshot(defaultRecords, {
|
|
541
|
+
documents: [
|
|
542
|
+
{ state: defaultRecords[0], lastChangedClock: 5 },
|
|
543
|
+
{ state: defaultRecords[1], lastChangedClock: 10 },
|
|
544
|
+
],
|
|
545
|
+
documentClock: 15,
|
|
546
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
547
|
+
}),
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
storage.transaction((txn) => {
|
|
551
|
+
const changes = txn.getChangesSince(7)!
|
|
552
|
+
const puts = Object.values(changes.diff.puts)
|
|
553
|
+
|
|
554
|
+
expect(puts.length).toBe(1)
|
|
555
|
+
expect((puts[0] as TLRecord).id).toBe(defaultRecords[1].id) // only record with clock 10 > 7
|
|
556
|
+
expect(changes.wipeAll).toBe(false)
|
|
557
|
+
})
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
it('returns all records when sinceClock is before all changes', () => {
|
|
561
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
562
|
+
snapshot: makeSnapshot(defaultRecords, {
|
|
563
|
+
documents: [
|
|
564
|
+
{ state: defaultRecords[0], lastChangedClock: 5 },
|
|
565
|
+
{ state: defaultRecords[1], lastChangedClock: 10 },
|
|
566
|
+
],
|
|
567
|
+
documentClock: 15,
|
|
568
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
569
|
+
}),
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
storage.transaction((txn) => {
|
|
573
|
+
const changes = txn.getChangesSince(0)!
|
|
574
|
+
const puts = Object.values(changes.diff.puts)
|
|
575
|
+
|
|
576
|
+
expect(puts.length).toBe(2)
|
|
577
|
+
})
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
it('returns deletes for tombstones after sinceClock', () => {
|
|
581
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
582
|
+
snapshot: makeSnapshot(defaultRecords, {
|
|
583
|
+
tombstones: {
|
|
584
|
+
'shape:deleted1': 5,
|
|
585
|
+
'shape:deleted2': 12,
|
|
586
|
+
},
|
|
587
|
+
documentClock: 15,
|
|
588
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
589
|
+
}),
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
storage.transaction((txn) => {
|
|
593
|
+
const changes = txn.getChangesSince(7)!
|
|
594
|
+
|
|
595
|
+
expect(changes.diff.deletes).toEqual(['shape:deleted2']) // only tombstone with clock 12 > 7
|
|
596
|
+
})
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
it('returns wipeAll: true when sinceClock < tombstoneHistoryStartsAtClock', () => {
|
|
600
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
601
|
+
snapshot: makeSnapshot(defaultRecords, {
|
|
602
|
+
documentClock: 20,
|
|
603
|
+
tombstoneHistoryStartsAtClock: 10,
|
|
604
|
+
}),
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
storage.transaction((txn) => {
|
|
608
|
+
const changes = txn.getChangesSince(5)! // 5 < 10
|
|
609
|
+
|
|
610
|
+
expect(changes.wipeAll).toBe(true)
|
|
611
|
+
// When wipeAll is true, all documents are returned
|
|
612
|
+
const puts = Object.values(changes.diff.puts)
|
|
613
|
+
expect(puts.length).toBe(2)
|
|
614
|
+
})
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
it('returns wipeAll: false when sinceClock >= tombstoneHistoryStartsAtClock', () => {
|
|
618
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
619
|
+
snapshot: makeSnapshot(defaultRecords, {
|
|
620
|
+
documentClock: 20,
|
|
621
|
+
tombstoneHistoryStartsAtClock: 10,
|
|
622
|
+
}),
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
storage.transaction((txn) => {
|
|
626
|
+
const changes = txn.getChangesSince(15)!
|
|
627
|
+
|
|
628
|
+
expect(changes.wipeAll).toBe(false)
|
|
629
|
+
})
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
it('returns undefined when no changes since clock', () => {
|
|
633
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
634
|
+
snapshot: makeSnapshot(defaultRecords, {
|
|
635
|
+
documents: [
|
|
636
|
+
{ state: defaultRecords[0], lastChangedClock: 5 },
|
|
637
|
+
{ state: defaultRecords[1], lastChangedClock: 10 },
|
|
638
|
+
],
|
|
639
|
+
documentClock: 15,
|
|
640
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
641
|
+
}),
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
storage.transaction((txn) => {
|
|
645
|
+
const changes = txn.getChangesSince(15)
|
|
646
|
+
expect(changes).toBeUndefined()
|
|
647
|
+
})
|
|
648
|
+
})
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
describe('onChange', () => {
|
|
652
|
+
it('notifies listeners after changes', async () => {
|
|
653
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
654
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
const listener = vi.fn()
|
|
658
|
+
storage.onChange(listener)
|
|
659
|
+
|
|
660
|
+
// Wait for listener registration (microtask)
|
|
661
|
+
await Promise.resolve()
|
|
662
|
+
|
|
663
|
+
const newPage = PageRecordType.create({
|
|
664
|
+
id: PageRecordType.createId('new'),
|
|
665
|
+
name: 'New',
|
|
666
|
+
index: 'a2' as IndexKey,
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
storage.transaction((txn) => {
|
|
670
|
+
txn.set(newPage.id, newPage)
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
// Wait for notification (microtask)
|
|
674
|
+
await Promise.resolve()
|
|
675
|
+
|
|
676
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
677
|
+
})
|
|
678
|
+
|
|
679
|
+
it('receives correct documentClock', async () => {
|
|
680
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
681
|
+
snapshot: makeSnapshot(defaultRecords, { documentClock: 10 }),
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
const listener = vi.fn()
|
|
685
|
+
storage.onChange(listener)
|
|
686
|
+
|
|
687
|
+
await Promise.resolve()
|
|
688
|
+
|
|
689
|
+
const newPage = PageRecordType.create({
|
|
690
|
+
id: PageRecordType.createId('new'),
|
|
691
|
+
name: 'New',
|
|
692
|
+
index: 'a2' as IndexKey,
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
storage.transaction((txn) => {
|
|
696
|
+
txn.set(newPage.id, newPage)
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
await Promise.resolve()
|
|
700
|
+
|
|
701
|
+
expect(listener).toHaveBeenCalledWith(
|
|
702
|
+
expect.objectContaining({
|
|
703
|
+
documentClock: 11,
|
|
704
|
+
})
|
|
705
|
+
)
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
it('receives transaction id when provided', async () => {
|
|
709
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
710
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
const listener = vi.fn()
|
|
714
|
+
storage.onChange(listener)
|
|
715
|
+
|
|
716
|
+
await Promise.resolve()
|
|
717
|
+
|
|
718
|
+
const newPage = PageRecordType.create({
|
|
719
|
+
id: PageRecordType.createId('new'),
|
|
720
|
+
name: 'New',
|
|
721
|
+
index: 'a2' as IndexKey,
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
storage.transaction(
|
|
725
|
+
(txn) => {
|
|
726
|
+
txn.set(newPage.id, newPage)
|
|
727
|
+
},
|
|
728
|
+
{ id: 'my-transaction-id' }
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
await Promise.resolve()
|
|
732
|
+
|
|
733
|
+
expect(listener).toHaveBeenCalledWith(
|
|
734
|
+
expect.objectContaining({
|
|
735
|
+
id: 'my-transaction-id',
|
|
736
|
+
})
|
|
737
|
+
)
|
|
738
|
+
})
|
|
739
|
+
|
|
740
|
+
it('unsubscribe prevents future notifications', async () => {
|
|
741
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
742
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
const listener = vi.fn()
|
|
746
|
+
const unsubscribe = storage.onChange(listener)
|
|
747
|
+
|
|
748
|
+
await Promise.resolve()
|
|
749
|
+
|
|
750
|
+
// Unsubscribe immediately
|
|
751
|
+
unsubscribe()
|
|
752
|
+
|
|
753
|
+
const newPage = PageRecordType.create({
|
|
754
|
+
id: PageRecordType.createId('new'),
|
|
755
|
+
name: 'New',
|
|
756
|
+
index: 'a2' as IndexKey,
|
|
757
|
+
})
|
|
758
|
+
|
|
759
|
+
storage.transaction((txn) => {
|
|
760
|
+
txn.set(newPage.id, newPage)
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
await Promise.resolve()
|
|
764
|
+
|
|
765
|
+
expect(listener).not.toHaveBeenCalled()
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
it('does not notify for read-only transactions', async () => {
|
|
769
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
770
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
771
|
+
})
|
|
772
|
+
|
|
773
|
+
const listener = vi.fn()
|
|
774
|
+
storage.onChange(listener)
|
|
775
|
+
|
|
776
|
+
await Promise.resolve()
|
|
777
|
+
|
|
778
|
+
storage.transaction((txn) => {
|
|
779
|
+
txn.get(TLDOCUMENT_ID) // read only
|
|
780
|
+
})
|
|
781
|
+
|
|
782
|
+
await Promise.resolve()
|
|
783
|
+
|
|
784
|
+
expect(listener).not.toHaveBeenCalled()
|
|
785
|
+
})
|
|
786
|
+
|
|
787
|
+
it('listener registered during same callstack does not receive preceding changes', async () => {
|
|
788
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
789
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
const newPage = PageRecordType.create({
|
|
793
|
+
id: PageRecordType.createId('new'),
|
|
794
|
+
name: 'New',
|
|
795
|
+
index: 'a2' as IndexKey,
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
// Make a change
|
|
799
|
+
storage.transaction((txn) => {
|
|
800
|
+
txn.set(newPage.id, newPage)
|
|
801
|
+
})
|
|
802
|
+
|
|
803
|
+
// Register listener in same callstack as the change
|
|
804
|
+
const listener = vi.fn()
|
|
805
|
+
storage.onChange(listener)
|
|
806
|
+
|
|
807
|
+
// Wait for all microtasks
|
|
808
|
+
await Promise.resolve()
|
|
809
|
+
// Listener should not have received the earlier change
|
|
810
|
+
expect(listener).not.toHaveBeenCalled()
|
|
811
|
+
|
|
812
|
+
// perform a read-only transaction
|
|
813
|
+
storage.transaction((txn) => {
|
|
814
|
+
txn.get(newPage.id)
|
|
815
|
+
})
|
|
816
|
+
await Promise.resolve()
|
|
817
|
+
|
|
818
|
+
// Listener should still not have received the earlier change
|
|
819
|
+
expect(listener).not.toHaveBeenCalled()
|
|
820
|
+
|
|
821
|
+
// perform a write transaction with id
|
|
822
|
+
storage.transaction(
|
|
823
|
+
(txn) => {
|
|
824
|
+
txn.set(newPage.id, newPage)
|
|
825
|
+
},
|
|
826
|
+
{ id: 'my-transaction-id' }
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
await Promise.resolve()
|
|
830
|
+
|
|
831
|
+
// Listener should have received the change
|
|
832
|
+
expect(listener).toHaveBeenCalledWith(
|
|
833
|
+
expect.objectContaining({
|
|
834
|
+
id: 'my-transaction-id',
|
|
835
|
+
})
|
|
836
|
+
)
|
|
837
|
+
})
|
|
838
|
+
|
|
839
|
+
it('unsubscribe before registration prevents registration', async () => {
|
|
840
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
841
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
const listener = vi.fn()
|
|
845
|
+
const unsubscribe = storage.onChange(listener)
|
|
846
|
+
|
|
847
|
+
const newPage = PageRecordType.create({
|
|
848
|
+
id: PageRecordType.createId('new'),
|
|
849
|
+
name: 'New',
|
|
850
|
+
index: 'a2' as IndexKey,
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
storage.transaction((txn) => {
|
|
854
|
+
txn.set(newPage.id, newPage)
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
// Unsubscribe before the microtask runs
|
|
858
|
+
unsubscribe()
|
|
859
|
+
|
|
860
|
+
await Promise.resolve()
|
|
861
|
+
|
|
862
|
+
expect(listener).not.toHaveBeenCalled()
|
|
863
|
+
|
|
864
|
+
storage.transaction((txn) => {
|
|
865
|
+
txn.set(newPage.id, { ...newPage, name: 'Updated' } as TLRecord)
|
|
866
|
+
})
|
|
867
|
+
|
|
868
|
+
await Promise.resolve()
|
|
869
|
+
|
|
870
|
+
expect(listener).not.toHaveBeenCalled()
|
|
871
|
+
})
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
describe('getSnapshot', () => {
|
|
875
|
+
it('returns correct snapshot structure', () => {
|
|
876
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
877
|
+
snapshot: makeSnapshot(defaultRecords, {
|
|
878
|
+
documentClock: 15,
|
|
879
|
+
tombstoneHistoryStartsAtClock: 5,
|
|
880
|
+
tombstones: { 'shape:deleted': 10 },
|
|
881
|
+
}),
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
const snapshot = storage.getSnapshot()
|
|
885
|
+
|
|
886
|
+
expect(snapshot.documentClock).toBe(15)
|
|
887
|
+
expect(snapshot.tombstoneHistoryStartsAtClock).toBe(5)
|
|
888
|
+
expect(snapshot.documents.length).toBe(2)
|
|
889
|
+
expect(snapshot.tombstones).toEqual({ 'shape:deleted': 10 })
|
|
890
|
+
expect(snapshot.schema).toEqual(tlSchema.serialize())
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
it('reflects changes from transactions', () => {
|
|
894
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
895
|
+
snapshot: makeSnapshot(defaultRecords, { documentClock: 0 }),
|
|
896
|
+
})
|
|
897
|
+
|
|
898
|
+
const newPage = PageRecordType.create({
|
|
899
|
+
id: PageRecordType.createId('new'),
|
|
900
|
+
name: 'New',
|
|
901
|
+
index: 'a2' as IndexKey,
|
|
902
|
+
})
|
|
903
|
+
|
|
904
|
+
storage.transaction((txn) => {
|
|
905
|
+
txn.set(newPage.id, newPage)
|
|
906
|
+
})
|
|
907
|
+
|
|
908
|
+
const snapshot = storage.getSnapshot()
|
|
909
|
+
|
|
910
|
+
expect(snapshot.documentClock).toBe(1)
|
|
911
|
+
expect(snapshot.documents.length).toBe(3)
|
|
912
|
+
expect(snapshot.documents.find((d) => d.state.id === newPage.id)).toBeDefined()
|
|
913
|
+
})
|
|
914
|
+
})
|
|
915
|
+
|
|
916
|
+
describe('pruneTombstones', () => {
|
|
917
|
+
it('does not prune when below MAX_TOMBSTONES', () => {
|
|
918
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
919
|
+
snapshot: makeSnapshot(defaultRecords, {
|
|
920
|
+
documentClock: 0,
|
|
921
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
922
|
+
}),
|
|
923
|
+
})
|
|
924
|
+
|
|
925
|
+
// Add tombstones below threshold
|
|
926
|
+
for (let i = 0; i < Math.floor(MAX_TOMBSTONES / 2); i++) {
|
|
927
|
+
storage.tombstones.set(`doc${i}`, i + 1)
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const initialSize = storage.tombstones.size
|
|
931
|
+
const initialHistoryClock = storage.tombstoneHistoryStartsAtClock.get()
|
|
932
|
+
|
|
933
|
+
// Schedule the throttled function then force it to run
|
|
934
|
+
// (leading: false means first call schedules, flush forces execution)
|
|
935
|
+
storage.pruneTombstones()
|
|
936
|
+
storage.pruneTombstones.flush()
|
|
937
|
+
|
|
938
|
+
expect(storage.tombstones.size).toBe(initialSize)
|
|
939
|
+
expect(storage.tombstoneHistoryStartsAtClock.get()).toBe(initialHistoryClock)
|
|
940
|
+
})
|
|
941
|
+
|
|
942
|
+
it('prunes when exceeding MAX_TOMBSTONES', () => {
|
|
943
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
944
|
+
snapshot: makeSnapshot(defaultRecords, {
|
|
945
|
+
documentClock: 0,
|
|
946
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
947
|
+
}),
|
|
948
|
+
})
|
|
949
|
+
|
|
950
|
+
// Add more than MAX_TOMBSTONES
|
|
951
|
+
const totalTombstones = MAX_TOMBSTONES + 500
|
|
952
|
+
for (let i = 0; i < totalTombstones; i++) {
|
|
953
|
+
storage.tombstones.set(`doc${i}`, i + 1)
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
expect(storage.tombstones.size).toBe(totalTombstones)
|
|
957
|
+
|
|
958
|
+
// Schedule the throttled function then force it to run
|
|
959
|
+
storage.pruneTombstones()
|
|
960
|
+
storage.pruneTombstones.flush()
|
|
961
|
+
|
|
962
|
+
expect(storage.tombstones.size).toBeLessThan(totalTombstones)
|
|
963
|
+
expect(storage.tombstones.size).toBeLessThanOrEqual(
|
|
964
|
+
MAX_TOMBSTONES - TOMBSTONE_PRUNE_BUFFER_SIZE
|
|
965
|
+
)
|
|
966
|
+
expect(storage.tombstoneHistoryStartsAtClock.get()).toBeGreaterThan(0)
|
|
967
|
+
})
|
|
968
|
+
|
|
969
|
+
it('updates tombstoneHistoryStartsAtClock correctly', () => {
|
|
970
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
971
|
+
snapshot: makeSnapshot(defaultRecords, {
|
|
972
|
+
documentClock: 10000,
|
|
973
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
974
|
+
}),
|
|
975
|
+
})
|
|
976
|
+
|
|
977
|
+
// Add more than MAX_TOMBSTONES with sequential clock values
|
|
978
|
+
const totalTombstones = MAX_TOMBSTONES * 2
|
|
979
|
+
for (let i = 0; i < totalTombstones; i++) {
|
|
980
|
+
storage.tombstones.set(`doc${i}`, i + 1)
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const initialHistoryClock = storage.tombstoneHistoryStartsAtClock.get()
|
|
984
|
+
|
|
985
|
+
// Schedule the throttled function then force it to run
|
|
986
|
+
storage.pruneTombstones()
|
|
987
|
+
storage.pruneTombstones.flush()
|
|
988
|
+
|
|
989
|
+
// History clock should have advanced
|
|
990
|
+
expect(storage.tombstoneHistoryStartsAtClock.get()).toBeGreaterThan(initialHistoryClock)
|
|
991
|
+
|
|
992
|
+
// The algorithm deletes the OLDEST tombstones and keeps the NEWEST ones.
|
|
993
|
+
// tombstoneHistoryStartsAtClock is set to the oldest REMAINING clock.
|
|
994
|
+
// Remaining tombstones have clocks >= tombstoneHistoryStartsAtClock.
|
|
995
|
+
const historyClock = storage.tombstoneHistoryStartsAtClock.get()
|
|
996
|
+
for (const [, clock] of storage.tombstones.entries()) {
|
|
997
|
+
expect(clock).toBeGreaterThanOrEqual(historyClock)
|
|
998
|
+
}
|
|
999
|
+
})
|
|
1000
|
+
|
|
1001
|
+
it('handles duplicate clock values across tombstones', () => {
|
|
1002
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1003
|
+
snapshot: makeSnapshot(defaultRecords, {
|
|
1004
|
+
documentClock: 3,
|
|
1005
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
1006
|
+
}),
|
|
1007
|
+
})
|
|
1008
|
+
|
|
1009
|
+
// Add tombstones with repeating clock values (10 per clock)
|
|
1010
|
+
const totalTombstones = MAX_TOMBSTONES + 1
|
|
1011
|
+
const expectedCutoff = TOMBSTONE_PRUNE_BUFFER_SIZE + 1
|
|
1012
|
+
const overflow = 10
|
|
1013
|
+
const boundary = expectedCutoff + overflow
|
|
1014
|
+
const lowerClockVal = 1
|
|
1015
|
+
const upperClockVal = 2
|
|
1016
|
+
for (let i = 0; i < totalTombstones; i++) {
|
|
1017
|
+
if (i < boundary) {
|
|
1018
|
+
storage.tombstones.set(`doc${i}`, lowerClockVal)
|
|
1019
|
+
} else {
|
|
1020
|
+
storage.tombstones.set(`doc${i}`, upperClockVal)
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Schedule the throttled function then force it to run
|
|
1025
|
+
storage.pruneTombstones()
|
|
1026
|
+
storage.pruneTombstones.flush()
|
|
1027
|
+
|
|
1028
|
+
expect(storage.tombstones.size).toEqual(
|
|
1029
|
+
MAX_TOMBSTONES - TOMBSTONE_PRUNE_BUFFER_SIZE - overflow
|
|
1030
|
+
)
|
|
1031
|
+
expect([...storage.tombstones.values()].every((clock) => clock === upperClockVal)).toBe(true)
|
|
1032
|
+
})
|
|
1033
|
+
|
|
1034
|
+
it('handles all tombstones with same clock value', () => {
|
|
1035
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1036
|
+
snapshot: makeSnapshot(defaultRecords, {
|
|
1037
|
+
documentClock: 1000,
|
|
1038
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
1039
|
+
}),
|
|
1040
|
+
})
|
|
1041
|
+
|
|
1042
|
+
// Add tombstones all with the same clock value
|
|
1043
|
+
const totalTombstones = MAX_TOMBSTONES * 2
|
|
1044
|
+
const sameClock = 100
|
|
1045
|
+
for (let i = 0; i < totalTombstones; i++) {
|
|
1046
|
+
storage.tombstones.set(`doc${i}`, sameClock)
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Schedule the throttled function then force it to run
|
|
1050
|
+
storage.pruneTombstones()
|
|
1051
|
+
storage.pruneTombstones.flush()
|
|
1052
|
+
|
|
1053
|
+
// When all have same clock, the cutoff extends to include all of them,
|
|
1054
|
+
// so all are pruned and history starts at documentClock
|
|
1055
|
+
expect(storage.tombstones.size).toBe(0)
|
|
1056
|
+
expect(storage.tombstoneHistoryStartsAtClock.get()).toBe(1000) // documentClock
|
|
1057
|
+
})
|
|
1058
|
+
|
|
1059
|
+
it('does not prune at exactly MAX_TOMBSTONES', () => {
|
|
1060
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1061
|
+
snapshot: makeSnapshot(defaultRecords, {
|
|
1062
|
+
documentClock: 0,
|
|
1063
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
1064
|
+
}),
|
|
1065
|
+
})
|
|
1066
|
+
|
|
1067
|
+
// Add exactly MAX_TOMBSTONES
|
|
1068
|
+
for (let i = 0; i < MAX_TOMBSTONES; i++) {
|
|
1069
|
+
storage.tombstones.set(`doc${i}`, i + 1)
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// Schedule the throttled function then force it to run
|
|
1073
|
+
storage.pruneTombstones()
|
|
1074
|
+
storage.pruneTombstones.flush()
|
|
1075
|
+
|
|
1076
|
+
// Should not prune at exactly the threshold
|
|
1077
|
+
expect(storage.tombstones.size).toBe(MAX_TOMBSTONES)
|
|
1078
|
+
})
|
|
1079
|
+
})
|
|
1080
|
+
|
|
1081
|
+
describe('loadSnapshotIntoStorage', () => {
|
|
1082
|
+
it('loads records from snapshot', () => {
|
|
1083
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1084
|
+
snapshot: makeSnapshot([]),
|
|
1085
|
+
})
|
|
1086
|
+
|
|
1087
|
+
const newSnapshot = makeSnapshot(defaultRecords)
|
|
1088
|
+
|
|
1089
|
+
storage.transaction((txn) => {
|
|
1090
|
+
loadSnapshotIntoStorage(txn, tlSchema, newSnapshot)
|
|
1091
|
+
})
|
|
1092
|
+
|
|
1093
|
+
expect(storage.documents.size).toBe(2)
|
|
1094
|
+
})
|
|
1095
|
+
|
|
1096
|
+
it('deletes records not in snapshot', () => {
|
|
1097
|
+
const extraPage = PageRecordType.create({
|
|
1098
|
+
id: PageRecordType.createId('extra'),
|
|
1099
|
+
name: 'Extra',
|
|
1100
|
+
index: 'a2' as IndexKey,
|
|
1101
|
+
})
|
|
1102
|
+
|
|
1103
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1104
|
+
snapshot: makeSnapshot([...defaultRecords, extraPage]),
|
|
1105
|
+
})
|
|
1106
|
+
|
|
1107
|
+
expect(storage.documents.size).toBe(3)
|
|
1108
|
+
|
|
1109
|
+
// Load a snapshot without the extra page
|
|
1110
|
+
const newSnapshot = makeSnapshot(defaultRecords)
|
|
1111
|
+
|
|
1112
|
+
storage.transaction((txn) => {
|
|
1113
|
+
loadSnapshotIntoStorage(txn, tlSchema, newSnapshot)
|
|
1114
|
+
})
|
|
1115
|
+
|
|
1116
|
+
expect(storage.documents.size).toBe(2)
|
|
1117
|
+
expect(storage.documents.has(extraPage.id)).toBe(false)
|
|
1118
|
+
expect(storage.tombstones.has(extraPage.id)).toBe(true)
|
|
1119
|
+
})
|
|
1120
|
+
|
|
1121
|
+
it('sets schema from snapshot', () => {
|
|
1122
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1123
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
1124
|
+
})
|
|
1125
|
+
|
|
1126
|
+
const newSnapshot = makeSnapshot(defaultRecords)
|
|
1127
|
+
|
|
1128
|
+
storage.transaction((txn) => {
|
|
1129
|
+
loadSnapshotIntoStorage(txn, tlSchema, newSnapshot)
|
|
1130
|
+
})
|
|
1131
|
+
|
|
1132
|
+
expect(storage.schema.get()).toEqual(newSnapshot.schema)
|
|
1133
|
+
})
|
|
1134
|
+
|
|
1135
|
+
it('does not update unchanged records', () => {
|
|
1136
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1137
|
+
snapshot: makeSnapshot(defaultRecords, { documentClock: 5 }),
|
|
1138
|
+
})
|
|
1139
|
+
|
|
1140
|
+
const initialClock = storage.getClock()
|
|
1141
|
+
|
|
1142
|
+
// Load the same snapshot
|
|
1143
|
+
const sameSnapshot = makeSnapshot(defaultRecords)
|
|
1144
|
+
|
|
1145
|
+
storage.transaction((txn) => {
|
|
1146
|
+
loadSnapshotIntoStorage(txn, tlSchema, sameSnapshot)
|
|
1147
|
+
})
|
|
1148
|
+
|
|
1149
|
+
// Clock should not have changed since records were equal
|
|
1150
|
+
expect(storage.getClock()).toBe(initialClock)
|
|
1151
|
+
})
|
|
1152
|
+
|
|
1153
|
+
it('throws when snapshot has no schema', () => {
|
|
1154
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1155
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
1156
|
+
})
|
|
1157
|
+
|
|
1158
|
+
const invalidSnapshot = {
|
|
1159
|
+
documents: [],
|
|
1160
|
+
clock: 0,
|
|
1161
|
+
} as any
|
|
1162
|
+
|
|
1163
|
+
expect(() => {
|
|
1164
|
+
storage.transaction((txn) => {
|
|
1165
|
+
loadSnapshotIntoStorage(txn, tlSchema, invalidSnapshot)
|
|
1166
|
+
})
|
|
1167
|
+
}).toThrow('Schema is required')
|
|
1168
|
+
})
|
|
1169
|
+
})
|
|
1170
|
+
|
|
1171
|
+
describe('convertStoreSnapshotToRoomSnapshot', () => {
|
|
1172
|
+
it('passes through RoomSnapshot unchanged', () => {
|
|
1173
|
+
const roomSnapshot = makeSnapshot(defaultRecords, { documentClock: 42 })
|
|
1174
|
+
|
|
1175
|
+
const result = convertStoreSnapshotToRoomSnapshot(roomSnapshot)
|
|
1176
|
+
|
|
1177
|
+
expect(result).toBe(roomSnapshot)
|
|
1178
|
+
})
|
|
1179
|
+
|
|
1180
|
+
it('converts TLStoreSnapshot to RoomSnapshot', () => {
|
|
1181
|
+
const storeSnapshot = {
|
|
1182
|
+
store: {
|
|
1183
|
+
[TLDOCUMENT_ID]: defaultRecords[0],
|
|
1184
|
+
[defaultRecords[1].id]: defaultRecords[1],
|
|
1185
|
+
},
|
|
1186
|
+
schema: tlSchema.serialize(),
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
const result = convertStoreSnapshotToRoomSnapshot(storeSnapshot)
|
|
1190
|
+
|
|
1191
|
+
expect(result.clock).toBe(0)
|
|
1192
|
+
expect(result.documentClock).toBe(0)
|
|
1193
|
+
expect(result.documents.length).toBe(2)
|
|
1194
|
+
expect(result.documents[0].lastChangedClock).toBe(0)
|
|
1195
|
+
expect(result.tombstones).toEqual({})
|
|
1196
|
+
expect(result.schema).toEqual(storeSnapshot.schema)
|
|
1197
|
+
})
|
|
1198
|
+
|
|
1199
|
+
it('sets all documents with lastChangedClock: 0', () => {
|
|
1200
|
+
const storeSnapshot = {
|
|
1201
|
+
store: {
|
|
1202
|
+
[TLDOCUMENT_ID]: defaultRecords[0],
|
|
1203
|
+
[defaultRecords[1].id]: defaultRecords[1],
|
|
1204
|
+
},
|
|
1205
|
+
schema: tlSchema.serialize(),
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
const result = convertStoreSnapshotToRoomSnapshot(storeSnapshot)
|
|
1209
|
+
|
|
1210
|
+
for (const doc of result.documents) {
|
|
1211
|
+
expect(doc.lastChangedClock).toBe(0)
|
|
1212
|
+
}
|
|
1213
|
+
})
|
|
1214
|
+
})
|
|
1215
|
+
|
|
1216
|
+
describe('Edge cases', () => {
|
|
1217
|
+
describe('Transaction error handling', () => {
|
|
1218
|
+
it('does not increment clock if transaction throws', () => {
|
|
1219
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1220
|
+
snapshot: makeSnapshot(defaultRecords, { documentClock: 10 }),
|
|
1221
|
+
})
|
|
1222
|
+
|
|
1223
|
+
expect(() => {
|
|
1224
|
+
storage.transaction(() => {
|
|
1225
|
+
throw new Error('Oops!')
|
|
1226
|
+
})
|
|
1227
|
+
}).toThrow('Oops!')
|
|
1228
|
+
|
|
1229
|
+
// Clock should not have changed
|
|
1230
|
+
expect(storage.getClock()).toBe(10)
|
|
1231
|
+
})
|
|
1232
|
+
|
|
1233
|
+
it('rolls back changes if transaction throws after a write', () => {
|
|
1234
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1235
|
+
snapshot: makeSnapshot(defaultRecords, { documentClock: 10 }),
|
|
1236
|
+
})
|
|
1237
|
+
|
|
1238
|
+
const newPage = PageRecordType.create({
|
|
1239
|
+
id: PageRecordType.createId('new'),
|
|
1240
|
+
name: 'New',
|
|
1241
|
+
index: 'a2' as IndexKey,
|
|
1242
|
+
})
|
|
1243
|
+
|
|
1244
|
+
expect(() => {
|
|
1245
|
+
storage.transaction((txn) => {
|
|
1246
|
+
txn.set(newPage.id, newPage)
|
|
1247
|
+
throw new Error('Oops after write!')
|
|
1248
|
+
})
|
|
1249
|
+
}).toThrow('Oops after write!')
|
|
1250
|
+
|
|
1251
|
+
// Document should not have been added
|
|
1252
|
+
expect(storage.documents.has(newPage.id)).toBe(false)
|
|
1253
|
+
// Clock should not have changed
|
|
1254
|
+
expect(storage.getClock()).toBe(10)
|
|
1255
|
+
})
|
|
1256
|
+
})
|
|
1257
|
+
|
|
1258
|
+
describe('Deleting non-existent records', () => {
|
|
1259
|
+
it('does not create a tombstone for records that never existed', () => {
|
|
1260
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1261
|
+
snapshot: makeSnapshot(defaultRecords, { documentClock: 5 }),
|
|
1262
|
+
})
|
|
1263
|
+
|
|
1264
|
+
storage.transaction((txn) => {
|
|
1265
|
+
txn.delete('nonexistent:record')
|
|
1266
|
+
})
|
|
1267
|
+
|
|
1268
|
+
// No tombstone should be created for a record that never existed
|
|
1269
|
+
expect(storage.tombstones.has('nonexistent:record')).toBe(false)
|
|
1270
|
+
// Clock should not be incremented since nothing changed
|
|
1271
|
+
expect(storage.getClock()).toBe(5)
|
|
1272
|
+
})
|
|
1273
|
+
|
|
1274
|
+
it('does not increment clock when deleting non-existent record', () => {
|
|
1275
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1276
|
+
snapshot: makeSnapshot(defaultRecords, { documentClock: 10 }),
|
|
1277
|
+
})
|
|
1278
|
+
|
|
1279
|
+
const { didChange, documentClock } = storage.transaction((txn) => {
|
|
1280
|
+
txn.delete('nonexistent:record')
|
|
1281
|
+
})
|
|
1282
|
+
|
|
1283
|
+
expect(didChange).toBe(false)
|
|
1284
|
+
expect(documentClock).toBe(10)
|
|
1285
|
+
})
|
|
1286
|
+
})
|
|
1287
|
+
|
|
1288
|
+
describe('Set with mismatched ID', () => {
|
|
1289
|
+
it('throws when key does not match record.id', () => {
|
|
1290
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1291
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
1292
|
+
})
|
|
1293
|
+
|
|
1294
|
+
const page = PageRecordType.create({
|
|
1295
|
+
id: PageRecordType.createId('actual_id'),
|
|
1296
|
+
name: 'Test',
|
|
1297
|
+
index: 'a2' as IndexKey,
|
|
1298
|
+
})
|
|
1299
|
+
|
|
1300
|
+
// Attempting to store with a different key than the record's id should throw
|
|
1301
|
+
expect(() => {
|
|
1302
|
+
storage.transaction((txn) => {
|
|
1303
|
+
txn.set('different:key', page)
|
|
1304
|
+
})
|
|
1305
|
+
}).toThrow('Record id mismatch: key does not match record.id')
|
|
1306
|
+
})
|
|
1307
|
+
|
|
1308
|
+
it('succeeds when key matches record.id', () => {
|
|
1309
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1310
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
1311
|
+
})
|
|
1312
|
+
|
|
1313
|
+
const page = PageRecordType.create({
|
|
1314
|
+
id: PageRecordType.createId('my_page'),
|
|
1315
|
+
name: 'Test',
|
|
1316
|
+
index: 'a2' as IndexKey,
|
|
1317
|
+
})
|
|
1318
|
+
|
|
1319
|
+
// Store with matching key
|
|
1320
|
+
storage.transaction((txn) => {
|
|
1321
|
+
txn.set(page.id, page)
|
|
1322
|
+
})
|
|
1323
|
+
|
|
1324
|
+
expect(storage.documents.has(page.id)).toBe(true)
|
|
1325
|
+
expect(storage.documents.get(page.id)?.state.id).toBe(page.id)
|
|
1326
|
+
})
|
|
1327
|
+
})
|
|
1328
|
+
|
|
1329
|
+
describe('getChangesSince boundary conditions', () => {
|
|
1330
|
+
it('sinceClock exactly equal to tombstoneHistoryStartsAtClock is NOT wipeAll', () => {
|
|
1331
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1332
|
+
snapshot: makeSnapshot(defaultRecords, {
|
|
1333
|
+
documentClock: 20,
|
|
1334
|
+
tombstoneHistoryStartsAtClock: 10,
|
|
1335
|
+
}),
|
|
1336
|
+
})
|
|
1337
|
+
|
|
1338
|
+
storage.transaction((txn) => {
|
|
1339
|
+
// sinceClock === tombstoneHistoryStartsAtClock
|
|
1340
|
+
const changes = txn.getChangesSince(10)!
|
|
1341
|
+
expect(changes.wipeAll).toBe(false)
|
|
1342
|
+
})
|
|
1343
|
+
})
|
|
1344
|
+
|
|
1345
|
+
it('sinceClock one less than tombstoneHistoryStartsAtClock IS wipeAll', () => {
|
|
1346
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1347
|
+
snapshot: makeSnapshot(defaultRecords, {
|
|
1348
|
+
documentClock: 20,
|
|
1349
|
+
tombstoneHistoryStartsAtClock: 10,
|
|
1350
|
+
}),
|
|
1351
|
+
})
|
|
1352
|
+
|
|
1353
|
+
storage.transaction((txn) => {
|
|
1354
|
+
const changes = txn.getChangesSince(9)!
|
|
1355
|
+
expect(changes.wipeAll).toBe(true)
|
|
1356
|
+
})
|
|
1357
|
+
})
|
|
1358
|
+
|
|
1359
|
+
it('handles negative sinceClock', () => {
|
|
1360
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1361
|
+
snapshot: makeSnapshot(defaultRecords, {
|
|
1362
|
+
documentClock: 10,
|
|
1363
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
1364
|
+
}),
|
|
1365
|
+
})
|
|
1366
|
+
|
|
1367
|
+
storage.transaction((txn) => {
|
|
1368
|
+
const changes = txn.getChangesSince(-1)!
|
|
1369
|
+
// -1 < 0, so wipeAll should be true
|
|
1370
|
+
expect(changes.wipeAll).toBe(true)
|
|
1371
|
+
// All documents should be returned
|
|
1372
|
+
expect(Object.values(changes.diff.puts).length).toBe(2)
|
|
1373
|
+
})
|
|
1374
|
+
})
|
|
1375
|
+
|
|
1376
|
+
it('returns undefined when sinceClock equals current documentClock', () => {
|
|
1377
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1378
|
+
snapshot: makeSnapshot(defaultRecords, {
|
|
1379
|
+
documents: [
|
|
1380
|
+
{ state: defaultRecords[0], lastChangedClock: 5 },
|
|
1381
|
+
{ state: defaultRecords[1], lastChangedClock: 10 },
|
|
1382
|
+
],
|
|
1383
|
+
documentClock: 10,
|
|
1384
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
1385
|
+
}),
|
|
1386
|
+
})
|
|
1387
|
+
|
|
1388
|
+
storage.transaction((txn) => {
|
|
1389
|
+
const changes = txn.getChangesSince(10)
|
|
1390
|
+
expect(changes).toBeUndefined()
|
|
1391
|
+
})
|
|
1392
|
+
})
|
|
1393
|
+
|
|
1394
|
+
it('returns all changes when sinceClock is greater than documentClock', () => {
|
|
1395
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1396
|
+
snapshot: makeSnapshot(defaultRecords, { documentClock: 10 }),
|
|
1397
|
+
})
|
|
1398
|
+
|
|
1399
|
+
storage.transaction((txn) => {
|
|
1400
|
+
const changes = txn.getChangesSince(100)!
|
|
1401
|
+
expect(Object.values(changes.diff.puts).length).toBe(2)
|
|
1402
|
+
expect(changes.wipeAll).toBe(true)
|
|
1403
|
+
})
|
|
1404
|
+
})
|
|
1405
|
+
})
|
|
1406
|
+
|
|
1407
|
+
describe('onChange callback edge cases', () => {
|
|
1408
|
+
it('handles unsubscribe called during callback', async () => {
|
|
1409
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1410
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
1411
|
+
})
|
|
1412
|
+
|
|
1413
|
+
let unsubscribe = () => {}
|
|
1414
|
+
const listener = vi.fn(() => {
|
|
1415
|
+
unsubscribe()
|
|
1416
|
+
})
|
|
1417
|
+
|
|
1418
|
+
unsubscribe = storage.onChange(listener)
|
|
1419
|
+
|
|
1420
|
+
await Promise.resolve()
|
|
1421
|
+
|
|
1422
|
+
const newPage = PageRecordType.create({
|
|
1423
|
+
id: PageRecordType.createId('new'),
|
|
1424
|
+
name: 'New',
|
|
1425
|
+
index: 'a2' as IndexKey,
|
|
1426
|
+
})
|
|
1427
|
+
|
|
1428
|
+
// First change
|
|
1429
|
+
storage.transaction((txn) => {
|
|
1430
|
+
txn.set(newPage.id, newPage)
|
|
1431
|
+
})
|
|
1432
|
+
|
|
1433
|
+
await Promise.resolve()
|
|
1434
|
+
|
|
1435
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
1436
|
+
|
|
1437
|
+
// Second change - listener should NOT be called
|
|
1438
|
+
storage.transaction((txn) => {
|
|
1439
|
+
txn.delete(newPage.id)
|
|
1440
|
+
})
|
|
1441
|
+
|
|
1442
|
+
await Promise.resolve()
|
|
1443
|
+
|
|
1444
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
1445
|
+
})
|
|
1446
|
+
})
|
|
1447
|
+
|
|
1448
|
+
describe('Snapshot consistency edge cases', () => {
|
|
1449
|
+
it('clamps tombstoneHistoryStartsAtClock to documentClock when greater', () => {
|
|
1450
|
+
// When tombstoneHistoryStartsAtClock > documentClock, it gets clamped
|
|
1451
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1452
|
+
snapshot: makeSnapshot(defaultRecords, {
|
|
1453
|
+
documentClock: 5,
|
|
1454
|
+
tombstoneHistoryStartsAtClock: 10, // Invalid: greater than documentClock
|
|
1455
|
+
}),
|
|
1456
|
+
})
|
|
1457
|
+
|
|
1458
|
+
// Should be clamped to documentClock
|
|
1459
|
+
expect(storage.tombstoneHistoryStartsAtClock.get()).toBe(5)
|
|
1460
|
+
expect(storage.getClock()).toBe(5)
|
|
1461
|
+
})
|
|
1462
|
+
|
|
1463
|
+
it('accepts tombstoneHistoryStartsAtClock equal to documentClock', () => {
|
|
1464
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1465
|
+
snapshot: makeSnapshot(defaultRecords, {
|
|
1466
|
+
documentClock: 10,
|
|
1467
|
+
tombstoneHistoryStartsAtClock: 10,
|
|
1468
|
+
}),
|
|
1469
|
+
})
|
|
1470
|
+
|
|
1471
|
+
expect(storage.getClock()).toBe(10)
|
|
1472
|
+
expect(storage.tombstoneHistoryStartsAtClock.get()).toBe(10)
|
|
1473
|
+
})
|
|
1474
|
+
|
|
1475
|
+
it('accepts tombstoneHistoryStartsAtClock less than documentClock', () => {
|
|
1476
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1477
|
+
snapshot: makeSnapshot(defaultRecords, {
|
|
1478
|
+
documentClock: 20,
|
|
1479
|
+
tombstoneHistoryStartsAtClock: 5,
|
|
1480
|
+
}),
|
|
1481
|
+
})
|
|
1482
|
+
|
|
1483
|
+
expect(storage.getClock()).toBe(20)
|
|
1484
|
+
expect(storage.tombstoneHistoryStartsAtClock.get()).toBe(5)
|
|
1485
|
+
})
|
|
1486
|
+
|
|
1487
|
+
it('handles duplicate document IDs in snapshot - last one wins', () => {
|
|
1488
|
+
const page1 = PageRecordType.create({
|
|
1489
|
+
id: PageRecordType.createId('dupe'),
|
|
1490
|
+
name: 'First',
|
|
1491
|
+
index: 'a1' as IndexKey,
|
|
1492
|
+
})
|
|
1493
|
+
const page2 = PageRecordType.create({
|
|
1494
|
+
id: PageRecordType.createId('dupe'),
|
|
1495
|
+
name: 'Second',
|
|
1496
|
+
index: 'a2' as IndexKey,
|
|
1497
|
+
})
|
|
1498
|
+
|
|
1499
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1500
|
+
snapshot: {
|
|
1501
|
+
documents: [
|
|
1502
|
+
{ state: page1, lastChangedClock: 1 },
|
|
1503
|
+
{ state: page2, lastChangedClock: 2 },
|
|
1504
|
+
],
|
|
1505
|
+
documentClock: 5,
|
|
1506
|
+
schema: tlSchema.serialize(),
|
|
1507
|
+
},
|
|
1508
|
+
})
|
|
1509
|
+
|
|
1510
|
+
// Last one wins due to Map behavior
|
|
1511
|
+
expect(storage.documents.get('page:dupe')?.state).toEqual(page2)
|
|
1512
|
+
expect(storage.documents.get('page:dupe')?.lastChangedClock).toBe(2)
|
|
1513
|
+
})
|
|
1514
|
+
})
|
|
1515
|
+
|
|
1516
|
+
describe('loadSnapshotIntoStorage edge cases', () => {
|
|
1517
|
+
it('deletes while iterating over keys', () => {
|
|
1518
|
+
const extraPage1 = PageRecordType.create({
|
|
1519
|
+
id: PageRecordType.createId('extra1'),
|
|
1520
|
+
name: 'Extra1',
|
|
1521
|
+
index: 'a2' as IndexKey,
|
|
1522
|
+
})
|
|
1523
|
+
const extraPage2 = PageRecordType.create({
|
|
1524
|
+
id: PageRecordType.createId('extra2'),
|
|
1525
|
+
name: 'Extra2',
|
|
1526
|
+
index: 'a3' as IndexKey,
|
|
1527
|
+
})
|
|
1528
|
+
|
|
1529
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1530
|
+
snapshot: makeSnapshot([...defaultRecords, extraPage1, extraPage2]),
|
|
1531
|
+
})
|
|
1532
|
+
|
|
1533
|
+
expect(storage.documents.size).toBe(4)
|
|
1534
|
+
|
|
1535
|
+
// Load a snapshot with only the default records
|
|
1536
|
+
const newSnapshot = makeSnapshot(defaultRecords)
|
|
1537
|
+
|
|
1538
|
+
storage.transaction((txn) => {
|
|
1539
|
+
loadSnapshotIntoStorage(txn, tlSchema, newSnapshot)
|
|
1540
|
+
})
|
|
1541
|
+
|
|
1542
|
+
// Both extra pages should be deleted
|
|
1543
|
+
expect(storage.documents.size).toBe(2)
|
|
1544
|
+
expect(storage.documents.has(extraPage1.id)).toBe(false)
|
|
1545
|
+
expect(storage.documents.has(extraPage2.id)).toBe(false)
|
|
1546
|
+
})
|
|
1547
|
+
})
|
|
1548
|
+
|
|
1549
|
+
describe('Record immutability', () => {
|
|
1550
|
+
it('freezes records stored via set()', () => {
|
|
1551
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1552
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
1553
|
+
})
|
|
1554
|
+
|
|
1555
|
+
const page = PageRecordType.create({
|
|
1556
|
+
id: PageRecordType.createId('mutable'),
|
|
1557
|
+
name: 'Original',
|
|
1558
|
+
index: 'a2' as IndexKey,
|
|
1559
|
+
})
|
|
1560
|
+
|
|
1561
|
+
storage.transaction((txn) => {
|
|
1562
|
+
txn.set(page.id, page)
|
|
1563
|
+
})
|
|
1564
|
+
|
|
1565
|
+
const stored = storage.documents.get(page.id)?.state as any
|
|
1566
|
+
|
|
1567
|
+
// Records should be frozen
|
|
1568
|
+
expect(Object.isFrozen(stored)).toBe(true)
|
|
1569
|
+
})
|
|
1570
|
+
|
|
1571
|
+
it('freezes records from constructor snapshot', () => {
|
|
1572
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1573
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
1574
|
+
})
|
|
1575
|
+
|
|
1576
|
+
const stored = storage.documents.get(TLDOCUMENT_ID)?.state as any
|
|
1577
|
+
|
|
1578
|
+
expect(Object.isFrozen(stored)).toBe(true)
|
|
1579
|
+
})
|
|
1580
|
+
})
|
|
1581
|
+
|
|
1582
|
+
describe('Clock overflow', () => {
|
|
1583
|
+
it('handles clock at MAX_SAFE_INTEGER', () => {
|
|
1584
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1585
|
+
snapshot: makeSnapshot(defaultRecords, {
|
|
1586
|
+
documentClock: Number.MAX_SAFE_INTEGER - 1,
|
|
1587
|
+
}),
|
|
1588
|
+
})
|
|
1589
|
+
|
|
1590
|
+
const newPage = PageRecordType.create({
|
|
1591
|
+
id: PageRecordType.createId('new'),
|
|
1592
|
+
name: 'New',
|
|
1593
|
+
index: 'a2' as IndexKey,
|
|
1594
|
+
})
|
|
1595
|
+
|
|
1596
|
+
storage.transaction((txn) => {
|
|
1597
|
+
txn.set(newPage.id, newPage)
|
|
1598
|
+
})
|
|
1599
|
+
|
|
1600
|
+
expect(storage.getClock()).toBe(Number.MAX_SAFE_INTEGER)
|
|
1601
|
+
|
|
1602
|
+
// What happens if we try to increment past MAX_SAFE_INTEGER?
|
|
1603
|
+
storage.transaction((txn) => {
|
|
1604
|
+
txn.set(newPage.id, { ...newPage, name: 'Updated' } as TLRecord)
|
|
1605
|
+
})
|
|
1606
|
+
|
|
1607
|
+
// Clock loses precision at MAX_SAFE_INTEGER + 1
|
|
1608
|
+
expect(storage.getClock()).toBe(Number.MAX_SAFE_INTEGER + 1)
|
|
1609
|
+
})
|
|
1610
|
+
})
|
|
1611
|
+
|
|
1612
|
+
describe('Empty and special IDs', () => {
|
|
1613
|
+
it('handles empty string as record ID', () => {
|
|
1614
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1615
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
1616
|
+
})
|
|
1617
|
+
|
|
1618
|
+
// Create a record with empty string id (unusual but possible)
|
|
1619
|
+
const weirdRecord = { ...defaultRecords[0], id: '' } as TLRecord
|
|
1620
|
+
|
|
1621
|
+
storage.transaction((txn) => {
|
|
1622
|
+
txn.set('', weirdRecord)
|
|
1623
|
+
})
|
|
1624
|
+
|
|
1625
|
+
expect(storage.documents.has('')).toBe(true)
|
|
1626
|
+
expect(storage.documents.get('')?.state.id).toBe('')
|
|
1627
|
+
})
|
|
1628
|
+
})
|
|
1629
|
+
|
|
1630
|
+
describe('Transaction result consistency', () => {
|
|
1631
|
+
it('didChange reflects whether clock was incremented', () => {
|
|
1632
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1633
|
+
snapshot: makeSnapshot(defaultRecords, { documentClock: 10 }),
|
|
1634
|
+
})
|
|
1635
|
+
|
|
1636
|
+
// Read-only transaction
|
|
1637
|
+
const readResult = storage.transaction((txn) => {
|
|
1638
|
+
txn.get(TLDOCUMENT_ID)
|
|
1639
|
+
return 'read'
|
|
1640
|
+
})
|
|
1641
|
+
|
|
1642
|
+
expect(readResult.didChange).toBe(false)
|
|
1643
|
+
expect(readResult.documentClock).toBe(10)
|
|
1644
|
+
|
|
1645
|
+
// Write transaction
|
|
1646
|
+
const writeResult = storage.transaction((txn) => {
|
|
1647
|
+
txn.set(TLDOCUMENT_ID, defaultRecords[0])
|
|
1648
|
+
return 'write'
|
|
1649
|
+
})
|
|
1650
|
+
|
|
1651
|
+
expect(writeResult.didChange).toBe(true)
|
|
1652
|
+
expect(writeResult.documentClock).toBe(11)
|
|
1653
|
+
})
|
|
1654
|
+
|
|
1655
|
+
it('documentClock in result matches storage.getClock()', () => {
|
|
1656
|
+
const storage = new InMemorySyncStorage<TLRecord>({
|
|
1657
|
+
snapshot: makeSnapshot(defaultRecords, { documentClock: 5 }),
|
|
1658
|
+
})
|
|
1659
|
+
|
|
1660
|
+
const result = storage.transaction((txn) => {
|
|
1661
|
+
const page = PageRecordType.create({
|
|
1662
|
+
id: PageRecordType.createId('new'),
|
|
1663
|
+
name: 'New',
|
|
1664
|
+
index: 'a2' as IndexKey,
|
|
1665
|
+
})
|
|
1666
|
+
txn.set(page.id, page)
|
|
1667
|
+
})
|
|
1668
|
+
|
|
1669
|
+
expect(result.documentClock).toBe(storage.getClock())
|
|
1670
|
+
expect(result.documentClock).toBe(6)
|
|
1671
|
+
})
|
|
1672
|
+
})
|
|
1673
|
+
})
|
|
1674
|
+
})
|