@tldraw/sync-core 5.2.0-next.e2b8d10bf10e → 5.2.0
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/DOCS.md +662 -0
- package/README.md +9 -1
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/ClientWebSocketAdapter.js +7 -1
- package/dist-cjs/lib/ClientWebSocketAdapter.js.map +2 -2
- package/dist-cjs/lib/TLSocketRoom.js +4 -1
- package/dist-cjs/lib/TLSocketRoom.js.map +2 -2
- package/dist-cjs/lib/TLSyncClient.js +2 -3
- package/dist-cjs/lib/TLSyncClient.js.map +2 -2
- package/dist-cjs/lib/TLSyncRoom.js +27 -3
- package/dist-cjs/lib/TLSyncRoom.js.map +2 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/ClientWebSocketAdapter.mjs +7 -1
- package/dist-esm/lib/ClientWebSocketAdapter.mjs.map +2 -2
- package/dist-esm/lib/TLSocketRoom.mjs +4 -1
- package/dist-esm/lib/TLSocketRoom.mjs.map +2 -2
- package/dist-esm/lib/TLSyncClient.mjs +2 -4
- package/dist-esm/lib/TLSyncClient.mjs.map +2 -2
- package/dist-esm/lib/TLSyncRoom.mjs +27 -3
- package/dist-esm/lib/TLSyncRoom.mjs.map +2 -2
- package/package.json +12 -8
- package/src/lib/ClientWebSocketAdapter.test.ts +502 -505
- package/src/lib/ClientWebSocketAdapter.ts +7 -1
- package/src/lib/DurableObjectSqliteSyncWrapper.test.ts +102 -0
- package/src/lib/InMemorySyncStorage.test.ts +294 -0
- package/src/lib/MicrotaskNotifier.test.ts +79 -254
- package/src/lib/{NodeSqliteSyncWrapper.test.ts → NodeSqliteWrapper.test.ts} +29 -25
- package/src/lib/SQLiteSyncStorage.test.ts +239 -0
- package/src/lib/ServerSocketAdapter.test.ts +60 -199
- package/src/lib/TLSocketRoom.ts +6 -1
- package/src/lib/TLSyncClient.test.ts +1127 -846
- package/src/lib/TLSyncClient.ts +4 -4
- package/src/lib/TLSyncRoom.ts +34 -4
- package/src/lib/TLSyncStorage.test.ts +225 -0
- package/src/lib/chunk.test.ts +372 -0
- package/src/lib/diff.test.ts +885 -0
- package/src/lib/interval.test.ts +43 -0
- package/src/lib/protocol.test.ts +43 -0
- package/src/lib/recordDiff.test.ts +159 -0
- package/src/test/TLSocketRoom.test.ts +1109 -894
- package/src/test/TLSyncRoom.test.ts +1754 -870
- package/src/test/storageContractSuite.ts +618 -0
- package/src/test/upgradeDowngrade.test.ts +140 -40
- package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +0 -270
- package/src/lib/RoomSession.test.ts +0 -101
- package/src/lib/computeTombstonePruning.test.ts +0 -352
- package/src/lib/server-types.test.ts +0 -44
- package/src/test/InMemorySyncStorage.test.ts +0 -1780
- package/src/test/SQLiteSyncStorage.test.ts +0 -1485
- package/src/test/chunk.test.ts +0 -385
- package/src/test/customMessages.test.ts +0 -36
- package/src/test/diff.test.ts +0 -784
- package/src/test/presenceMode.test.ts +0 -149
- package/src/test/validation.test.ts +0 -186
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
import { DocumentRecordType, PageRecordType, TLDOCUMENT_ID, TLRecord } from '@tldraw/tlschema'
|
|
2
|
+
import { createTLSchema } from '@tldraw/tlschema'
|
|
3
|
+
import { IndexKey, ZERO_INDEX_KEY } from '@tldraw/utils'
|
|
4
|
+
import { vi } from 'vitest'
|
|
5
|
+
import { MAX_TOMBSTONES, TOMBSTONE_PRUNE_BUFFER_SIZE } from '../lib/InMemorySyncStorage'
|
|
6
|
+
import { RoomSnapshot } from '../lib/TLSyncRoom'
|
|
7
|
+
import { TLSyncStorage, TLSyncStorageOnChangeCallbackProps } from '../lib/TLSyncStorage'
|
|
8
|
+
|
|
9
|
+
export const contractSchema = createTLSchema()
|
|
10
|
+
|
|
11
|
+
export function makeContractSnapshot(
|
|
12
|
+
records: TLRecord[],
|
|
13
|
+
others: Partial<RoomSnapshot> = {}
|
|
14
|
+
): RoomSnapshot {
|
|
15
|
+
return {
|
|
16
|
+
documents: records.map((r) => ({ state: r, lastChangedClock: 0 })),
|
|
17
|
+
clock: 0,
|
|
18
|
+
documentClock: 0,
|
|
19
|
+
schema: contractSchema.serialize(),
|
|
20
|
+
...others,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const contractRecords = [
|
|
25
|
+
DocumentRecordType.create({ id: TLDOCUMENT_ID }),
|
|
26
|
+
PageRecordType.create({
|
|
27
|
+
index: ZERO_INDEX_KEY,
|
|
28
|
+
name: 'Page 1',
|
|
29
|
+
id: PageRecordType.createId('page_1'),
|
|
30
|
+
}),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
export function makePage(id: string, name = id, index = 'a2') {
|
|
34
|
+
return PageRecordType.create({
|
|
35
|
+
id: PageRecordType.createId(id),
|
|
36
|
+
name,
|
|
37
|
+
index: index as IndexKey,
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface StorageContractFactory {
|
|
42
|
+
create(opts?: {
|
|
43
|
+
snapshot?: RoomSnapshot
|
|
44
|
+
onChange?(arg: TLSyncStorageOnChangeCallbackProps): void
|
|
45
|
+
}): TLSyncStorage<TLRecord>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The shared behavior suite for the storage contract (SPEC.md section 10, SS rules).
|
|
50
|
+
* Runs against both InMemorySyncStorage and SQLiteSyncStorage.
|
|
51
|
+
*/
|
|
52
|
+
export function registerStorageContractTests(factory: StorageContractFactory) {
|
|
53
|
+
const create = (snapshot?: RoomSnapshot) => factory.create(snapshot ? { snapshot } : undefined)
|
|
54
|
+
|
|
55
|
+
describe('storage contract', () => {
|
|
56
|
+
it('[SS1] seeds from DEFAULT_INITIAL_SNAPSHOT when constructed without a snapshot', () => {
|
|
57
|
+
const storage = factory.create()
|
|
58
|
+
const snapshot = storage.getSnapshot!()
|
|
59
|
+
expect(snapshot.documentClock).toBe(0)
|
|
60
|
+
expect(snapshot.documents.map((d) => d.state.typeName).sort()).toEqual(['document', 'page'])
|
|
61
|
+
expect(snapshot.documents.map((d) => d.lastChangedClock)).toEqual([0, 0])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('transactions', () => {
|
|
65
|
+
it('[SS2] returns the callback result along with clock metadata', () => {
|
|
66
|
+
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 7 }))
|
|
67
|
+
const res = storage.transaction((txn) => txn.get(TLDOCUMENT_ID))
|
|
68
|
+
expect(res).toEqual({
|
|
69
|
+
documentClock: 7,
|
|
70
|
+
didChange: false,
|
|
71
|
+
result: expect.objectContaining({ id: TLDOCUMENT_ID }),
|
|
72
|
+
changes: undefined,
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('[SS3] didChange is true exactly when the clock advanced', () => {
|
|
77
|
+
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 10 }))
|
|
78
|
+
|
|
79
|
+
const read = storage.transaction((txn) => void txn.get(TLDOCUMENT_ID))
|
|
80
|
+
expect(read.didChange).toBe(false)
|
|
81
|
+
expect(read.documentClock).toBe(10)
|
|
82
|
+
|
|
83
|
+
const write = storage.transaction((txn) =>
|
|
84
|
+
txn.set(contractRecords[0].id, contractRecords[0])
|
|
85
|
+
)
|
|
86
|
+
expect(write.didChange).toBe(true)
|
|
87
|
+
expect(write.documentClock).toBe(11)
|
|
88
|
+
expect(storage.getClock()).toBe(11)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('[SS4] a callback that returns a promise throws', () => {
|
|
92
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
93
|
+
const storage = create(makeContractSnapshot(contractRecords))
|
|
94
|
+
expect(() => storage.transaction(() => Promise.resolve() as any)).toThrow(
|
|
95
|
+
'Transaction must return a value, not a promise'
|
|
96
|
+
)
|
|
97
|
+
consoleSpy.mockRestore()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('[SS5] a throwing callback rolls back documents, tombstones, the schema, and the clock', () => {
|
|
101
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
102
|
+
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 10 }))
|
|
103
|
+
const newPage = makePage('new')
|
|
104
|
+
|
|
105
|
+
expect(() =>
|
|
106
|
+
storage.transaction((txn) => {
|
|
107
|
+
txn.set(newPage.id, newPage)
|
|
108
|
+
txn.delete(contractRecords[1].id)
|
|
109
|
+
txn.setSchema({ ...contractSchema.serialize(), schemaVersion: 99 as any })
|
|
110
|
+
throw new Error('Oops after write!')
|
|
111
|
+
})
|
|
112
|
+
).toThrow('Oops after write!')
|
|
113
|
+
|
|
114
|
+
const snapshot = storage.getSnapshot!()
|
|
115
|
+
expect(storage.getClock()).toBe(10)
|
|
116
|
+
expect(snapshot.documents.find((d) => d.state.id === newPage.id)).toBeUndefined()
|
|
117
|
+
expect(snapshot.documents.find((d) => d.state.id === contractRecords[1].id)).toBeDefined()
|
|
118
|
+
expect(snapshot.tombstones).toEqual({})
|
|
119
|
+
expect(snapshot.schema).toEqual(contractSchema.serialize())
|
|
120
|
+
consoleSpy.mockRestore()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('[SS5] a throwing callback does not notify onChange', async () => {
|
|
124
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
125
|
+
const listener = vi.fn()
|
|
126
|
+
const storage = factory.create({
|
|
127
|
+
snapshot: makeContractSnapshot(contractRecords),
|
|
128
|
+
onChange: listener,
|
|
129
|
+
})
|
|
130
|
+
await Promise.resolve()
|
|
131
|
+
|
|
132
|
+
expect(() =>
|
|
133
|
+
storage.transaction((txn) => {
|
|
134
|
+
txn.set(contractRecords[1].id, { ...contractRecords[1], name: 'changed' } as TLRecord)
|
|
135
|
+
throw new Error('rollback')
|
|
136
|
+
})
|
|
137
|
+
).toThrow('rollback')
|
|
138
|
+
|
|
139
|
+
await Promise.resolve()
|
|
140
|
+
await Promise.resolve()
|
|
141
|
+
expect(listener).not.toHaveBeenCalled()
|
|
142
|
+
consoleSpy.mockRestore()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('[SS8] txn.getClock returns the start clock, then the incremented clock after a write', () => {
|
|
146
|
+
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 42 }))
|
|
147
|
+
storage.transaction((txn) => {
|
|
148
|
+
expect(txn.getClock()).toBe(42)
|
|
149
|
+
const newPage = makePage('new')
|
|
150
|
+
txn.set(newPage.id, newPage)
|
|
151
|
+
expect(txn.getClock()).toBe(43)
|
|
152
|
+
})
|
|
153
|
+
expect(storage.getClock()).toBe(43)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('[SS9] the clock increments exactly once per writing transaction', () => {
|
|
157
|
+
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 10 }))
|
|
158
|
+
storage.transaction((txn) => {
|
|
159
|
+
const p1 = makePage('p1', 'P1', 'a2')
|
|
160
|
+
const p2 = makePage('p2', 'P2', 'a3')
|
|
161
|
+
txn.set(p1.id, p1)
|
|
162
|
+
expect(txn.getClock()).toBe(11)
|
|
163
|
+
txn.set(p2.id, p2)
|
|
164
|
+
expect(txn.getClock()).toBe(11)
|
|
165
|
+
txn.delete(contractRecords[1].id)
|
|
166
|
+
expect(txn.getClock()).toBe(11)
|
|
167
|
+
})
|
|
168
|
+
expect(storage.getClock()).toBe(11)
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
describe('reads and writes', () => {
|
|
173
|
+
it('[SS10] get returns the record, or undefined when absent', () => {
|
|
174
|
+
const storage = create(makeContractSnapshot(contractRecords))
|
|
175
|
+
storage.transaction((txn) => {
|
|
176
|
+
expect(txn.get(TLDOCUMENT_ID)).toEqual(contractRecords[0])
|
|
177
|
+
expect(txn.get('nonexistent')).toBeUndefined()
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('[SS11] set creates and updates records, stamping lastChangedClock', () => {
|
|
182
|
+
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 5 }))
|
|
183
|
+
const newPage = makePage('new_page', 'New Page')
|
|
184
|
+
storage.transaction((txn) => {
|
|
185
|
+
txn.set(newPage.id, newPage)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
const entry = storage.getSnapshot!().documents.find((d) => d.state.id === newPage.id)
|
|
189
|
+
expect(entry).toEqual({ state: newPage, lastChangedClock: 6 })
|
|
190
|
+
|
|
191
|
+
const updated = { ...newPage, name: 'Updated' } as TLRecord
|
|
192
|
+
storage.transaction((txn) => txn.set(newPage.id, updated))
|
|
193
|
+
expect(storage.getSnapshot!().documents.find((d) => d.state.id === newPage.id)).toEqual({
|
|
194
|
+
state: updated,
|
|
195
|
+
lastChangedClock: 7,
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('[SS11] set asserts that the key matches record.id', () => {
|
|
200
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
201
|
+
const storage = create(makeContractSnapshot(contractRecords))
|
|
202
|
+
const page = makePage('actual_id')
|
|
203
|
+
expect(() =>
|
|
204
|
+
storage.transaction((txn) => {
|
|
205
|
+
txn.set('different:key', page)
|
|
206
|
+
})
|
|
207
|
+
).toThrow('Record id mismatch')
|
|
208
|
+
consoleSpy.mockRestore()
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('[SS11] set clears the tombstone when re-creating a deleted record', () => {
|
|
212
|
+
const page = makePage('page_to_delete')
|
|
213
|
+
const storage = create(makeContractSnapshot([...contractRecords, page]))
|
|
214
|
+
|
|
215
|
+
storage.transaction((txn) => txn.delete(page.id))
|
|
216
|
+
expect(Object.keys(storage.getSnapshot!().tombstones!)).toEqual([page.id])
|
|
217
|
+
|
|
218
|
+
storage.transaction((txn) => txn.set(page.id, page))
|
|
219
|
+
expect(storage.getSnapshot!().tombstones).toEqual({})
|
|
220
|
+
expect(storage.getSnapshot!().documents.find((d) => d.state.id === page.id)).toBeDefined()
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('[SS12] delete removes the record and writes a tombstone at the new clock', () => {
|
|
224
|
+
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 10 }))
|
|
225
|
+
const pageId = contractRecords[1].id
|
|
226
|
+
storage.transaction((txn) => txn.delete(pageId))
|
|
227
|
+
|
|
228
|
+
const snapshot = storage.getSnapshot!()
|
|
229
|
+
expect(snapshot.documents.find((d) => d.state.id === pageId)).toBeUndefined()
|
|
230
|
+
expect(snapshot.tombstones).toEqual({ [pageId]: 11 })
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('[SS12] deleting an absent id is a complete no-op', () => {
|
|
234
|
+
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 10 }))
|
|
235
|
+
const { didChange, documentClock } = storage.transaction((txn) =>
|
|
236
|
+
txn.delete('nonexistent:record')
|
|
237
|
+
)
|
|
238
|
+
expect(didChange).toBe(false)
|
|
239
|
+
expect(documentClock).toBe(10)
|
|
240
|
+
expect(storage.getSnapshot!().tombstones).toEqual({})
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
describe('iteration', () => {
|
|
245
|
+
it('[SS13] entries/keys/values iterate the documents', () => {
|
|
246
|
+
const storage = create(makeContractSnapshot(contractRecords))
|
|
247
|
+
const ids = contractRecords.map((r) => r.id).sort()
|
|
248
|
+
storage.transaction((txn) => {
|
|
249
|
+
expect(
|
|
250
|
+
Array.from(txn.entries())
|
|
251
|
+
.map(([id]) => id)
|
|
252
|
+
.sort()
|
|
253
|
+
).toEqual(ids)
|
|
254
|
+
expect(Array.from(txn.keys()).sort()).toEqual(ids)
|
|
255
|
+
expect(
|
|
256
|
+
Array.from(txn.values())
|
|
257
|
+
.map((v) => v.id)
|
|
258
|
+
.sort()
|
|
259
|
+
).toEqual(ids)
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it.each(['entries', 'keys', 'values'] as const)(
|
|
264
|
+
'[SS13] consuming the %s iterator after the transaction ends throws',
|
|
265
|
+
(method) => {
|
|
266
|
+
const storage = create(makeContractSnapshot(contractRecords))
|
|
267
|
+
let iterator!: Iterator<unknown>
|
|
268
|
+
storage.transaction((txn) => {
|
|
269
|
+
iterator = txn[method]()[Symbol.iterator]()
|
|
270
|
+
iterator.next()
|
|
271
|
+
})
|
|
272
|
+
expect(() => iterator.next()).toThrow('Transaction has ended')
|
|
273
|
+
}
|
|
274
|
+
)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
describe('schema', () => {
|
|
278
|
+
it('[SS14] getSchema and setSchema read and write the persisted schema', () => {
|
|
279
|
+
const snapshot = makeContractSnapshot(contractRecords)
|
|
280
|
+
const storage = create(snapshot)
|
|
281
|
+
const newSchema = { ...contractSchema.serialize(), schemaVersion: 99 as any }
|
|
282
|
+
storage.transaction((txn) => {
|
|
283
|
+
expect(txn.getSchema()).toEqual(snapshot.schema)
|
|
284
|
+
txn.setSchema(newSchema)
|
|
285
|
+
expect(txn.getSchema()).toEqual(newSchema)
|
|
286
|
+
})
|
|
287
|
+
expect(storage.getSnapshot!().schema).toEqual(newSchema)
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
describe('getChangesSince', () => {
|
|
292
|
+
const seeded = () =>
|
|
293
|
+
create(
|
|
294
|
+
makeContractSnapshot(contractRecords, {
|
|
295
|
+
documents: [
|
|
296
|
+
{ state: contractRecords[0], lastChangedClock: 5 },
|
|
297
|
+
{ state: contractRecords[1], lastChangedClock: 10 },
|
|
298
|
+
],
|
|
299
|
+
tombstones: { 'shape:deleted1': 5, 'shape:deleted2': 12 },
|
|
300
|
+
documentClock: 15,
|
|
301
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
302
|
+
})
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
it('[SS15] returns undefined when sinceClock equals the current clock', () => {
|
|
306
|
+
seeded().transaction((txn) => {
|
|
307
|
+
expect(txn.getChangesSince(15)).toBeUndefined()
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('[SS15] returns puts for documents changed strictly after sinceClock', () => {
|
|
312
|
+
seeded().transaction((txn) => {
|
|
313
|
+
const changes = txn.getChangesSince(5)!
|
|
314
|
+
expect(changes.wipeAll).toBe(false)
|
|
315
|
+
expect(Object.keys(changes.diff.puts)).toEqual([contractRecords[1].id])
|
|
316
|
+
})
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('[SS15] returns deletes for tombstones strictly after sinceClock', () => {
|
|
320
|
+
seeded().transaction((txn) => {
|
|
321
|
+
const changes = txn.getChangesSince(5)!
|
|
322
|
+
expect(changes.diff.deletes).toEqual(['shape:deleted2'])
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('[SS15] a sinceClock in the future is treated as "everything changed"', () => {
|
|
327
|
+
seeded().transaction((txn) => {
|
|
328
|
+
const changes = txn.getChangesSince(100)!
|
|
329
|
+
expect(changes.wipeAll).toBe(true)
|
|
330
|
+
expect(Object.keys(changes.diff.puts).length).toBe(2)
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('[SS15] wipeAll is true when sinceClock predates tombstone history, with all documents and no deletes', () => {
|
|
335
|
+
const storage = create(
|
|
336
|
+
makeContractSnapshot(contractRecords, {
|
|
337
|
+
tombstones: { 'shape:deleted1': 12 },
|
|
338
|
+
documentClock: 20,
|
|
339
|
+
tombstoneHistoryStartsAtClock: 10,
|
|
340
|
+
})
|
|
341
|
+
)
|
|
342
|
+
storage.transaction((txn) => {
|
|
343
|
+
const changes = txn.getChangesSince(9)!
|
|
344
|
+
expect(changes.wipeAll).toBe(true)
|
|
345
|
+
expect(Object.keys(changes.diff.puts).length).toBe(2)
|
|
346
|
+
expect(changes.diff.deletes).toEqual([])
|
|
347
|
+
})
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('[SS15] wipeAll boundary: sinceClock equal to tombstoneHistoryStartsAtClock is incremental', () => {
|
|
351
|
+
const storage = create(
|
|
352
|
+
makeContractSnapshot(contractRecords, {
|
|
353
|
+
documentClock: 20,
|
|
354
|
+
tombstoneHistoryStartsAtClock: 10,
|
|
355
|
+
})
|
|
356
|
+
)
|
|
357
|
+
storage.transaction((txn) => {
|
|
358
|
+
expect(txn.getChangesSince(10)!.wipeAll).toBe(false)
|
|
359
|
+
expect(txn.getChangesSince(9)!.wipeAll).toBe(true)
|
|
360
|
+
})
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('[SS15] sees writes made earlier in the same transaction', () => {
|
|
364
|
+
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 5 }))
|
|
365
|
+
const newPage = makePage('new')
|
|
366
|
+
storage.transaction((txn) => {
|
|
367
|
+
txn.set(newPage.id, newPage)
|
|
368
|
+
txn.delete(contractRecords[1].id)
|
|
369
|
+
const changes = txn.getChangesSince(5)!
|
|
370
|
+
expect(Object.keys(changes.diff.puts)).toEqual([newPage.id])
|
|
371
|
+
expect(changes.diff.deletes).toEqual([contractRecords[1].id])
|
|
372
|
+
})
|
|
373
|
+
})
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
describe('emitChanges', () => {
|
|
377
|
+
it('[SS7] emitChanges: "always" returns the forward diff of the transaction', () => {
|
|
378
|
+
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 5 }))
|
|
379
|
+
const newPage = makePage('new')
|
|
380
|
+
const { changes } = storage.transaction(
|
|
381
|
+
(txn) => {
|
|
382
|
+
txn.set(newPage.id, newPage)
|
|
383
|
+
txn.delete(contractRecords[1].id)
|
|
384
|
+
},
|
|
385
|
+
{ emitChanges: 'always' }
|
|
386
|
+
)
|
|
387
|
+
expect(changes).toEqual({
|
|
388
|
+
puts: { [newPage.id]: newPage },
|
|
389
|
+
deletes: [contractRecords[1].id],
|
|
390
|
+
})
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('[SS7] emitChanges: "when-different" never emits (changes apply verbatim)', () => {
|
|
394
|
+
const storage = create(makeContractSnapshot(contractRecords))
|
|
395
|
+
const newPage = makePage('new')
|
|
396
|
+
const { changes } = storage.transaction(
|
|
397
|
+
(txn) => {
|
|
398
|
+
txn.set(newPage.id, newPage)
|
|
399
|
+
},
|
|
400
|
+
{ emitChanges: 'when-different' }
|
|
401
|
+
)
|
|
402
|
+
expect(changes).toBeUndefined()
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
it('[SS7] changes is undefined when emitChanges is not requested', () => {
|
|
406
|
+
const storage = create(makeContractSnapshot(contractRecords))
|
|
407
|
+
const newPage = makePage('new')
|
|
408
|
+
const { changes } = storage.transaction((txn) => {
|
|
409
|
+
txn.set(newPage.id, newPage)
|
|
410
|
+
})
|
|
411
|
+
expect(changes).toBeUndefined()
|
|
412
|
+
})
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
describe('onChange', () => {
|
|
416
|
+
it('[SS6] notifies listeners on a microtask with the new clock and transaction id', async () => {
|
|
417
|
+
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 10 }))
|
|
418
|
+
const listener = vi.fn()
|
|
419
|
+
storage.onChange(listener)
|
|
420
|
+
await Promise.resolve()
|
|
421
|
+
|
|
422
|
+
const newPage = makePage('new')
|
|
423
|
+
storage.transaction(
|
|
424
|
+
(txn) => {
|
|
425
|
+
txn.set(newPage.id, newPage)
|
|
426
|
+
},
|
|
427
|
+
{ id: 'my-transaction-id' }
|
|
428
|
+
)
|
|
429
|
+
expect(listener).not.toHaveBeenCalled() // deferred to a microtask
|
|
430
|
+
|
|
431
|
+
await Promise.resolve()
|
|
432
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
433
|
+
expect(listener).toHaveBeenCalledWith({ id: 'my-transaction-id', documentClock: 11 })
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
it('[SS6] the id is undefined when the transaction has none', async () => {
|
|
437
|
+
const storage = create(makeContractSnapshot(contractRecords))
|
|
438
|
+
const listener = vi.fn()
|
|
439
|
+
storage.onChange(listener)
|
|
440
|
+
await Promise.resolve()
|
|
441
|
+
|
|
442
|
+
const newPage = makePage('new')
|
|
443
|
+
storage.transaction((txn) => txn.set(newPage.id, newPage))
|
|
444
|
+
await Promise.resolve()
|
|
445
|
+
expect(listener).toHaveBeenCalledWith({ id: undefined, documentClock: 1 })
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('[SS6] read-only transactions do not notify', async () => {
|
|
449
|
+
const storage = create(makeContractSnapshot(contractRecords))
|
|
450
|
+
const listener = vi.fn()
|
|
451
|
+
storage.onChange(listener)
|
|
452
|
+
await Promise.resolve()
|
|
453
|
+
|
|
454
|
+
storage.transaction((txn) => void txn.get(TLDOCUMENT_ID))
|
|
455
|
+
await Promise.resolve()
|
|
456
|
+
expect(listener).not.toHaveBeenCalled()
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
it('[SS6] unsubscribing prevents future notifications', async () => {
|
|
460
|
+
const storage = create(makeContractSnapshot(contractRecords))
|
|
461
|
+
const listener = vi.fn()
|
|
462
|
+
const unsubscribe = storage.onChange(listener)
|
|
463
|
+
await Promise.resolve()
|
|
464
|
+
unsubscribe()
|
|
465
|
+
|
|
466
|
+
const newPage = makePage('new')
|
|
467
|
+
storage.transaction((txn) => txn.set(newPage.id, newPage))
|
|
468
|
+
await Promise.resolve()
|
|
469
|
+
expect(listener).not.toHaveBeenCalled()
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it('[SS6] a listener registered in the same callstack as a change does not receive it', async () => {
|
|
473
|
+
const storage = create(makeContractSnapshot(contractRecords))
|
|
474
|
+
const newPage = makePage('new')
|
|
475
|
+
storage.transaction((txn) => txn.set(newPage.id, newPage))
|
|
476
|
+
|
|
477
|
+
const listener = vi.fn()
|
|
478
|
+
storage.onChange(listener)
|
|
479
|
+
await Promise.resolve()
|
|
480
|
+
await Promise.resolve()
|
|
481
|
+
expect(listener).not.toHaveBeenCalled()
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
it('[SS6] an onChange callback passed to the constructor is registered the same way', async () => {
|
|
485
|
+
const listener = vi.fn()
|
|
486
|
+
const storage = factory.create({
|
|
487
|
+
snapshot: makeContractSnapshot(contractRecords),
|
|
488
|
+
onChange: listener,
|
|
489
|
+
})
|
|
490
|
+
await Promise.resolve()
|
|
491
|
+
|
|
492
|
+
const newPage = makePage('new')
|
|
493
|
+
storage.transaction((txn) => txn.set(newPage.id, newPage))
|
|
494
|
+
await Promise.resolve()
|
|
495
|
+
expect(listener).toHaveBeenCalledWith({ id: undefined, documentClock: 1 })
|
|
496
|
+
})
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
describe('getSnapshot', () => {
|
|
500
|
+
it('[SS16] returns the full room snapshot reflecting committed transactions', () => {
|
|
501
|
+
const storage = create(
|
|
502
|
+
makeContractSnapshot(contractRecords, {
|
|
503
|
+
documentClock: 15,
|
|
504
|
+
tombstoneHistoryStartsAtClock: 5,
|
|
505
|
+
tombstones: { 'shape:deleted': 10 },
|
|
506
|
+
})
|
|
507
|
+
)
|
|
508
|
+
const newPage = makePage('new')
|
|
509
|
+
storage.transaction((txn) => txn.set(newPage.id, newPage))
|
|
510
|
+
|
|
511
|
+
const snapshot = storage.getSnapshot!()
|
|
512
|
+
expect(snapshot.documentClock).toBe(16)
|
|
513
|
+
expect(snapshot.tombstoneHistoryStartsAtClock).toBe(5)
|
|
514
|
+
expect(snapshot.documents.length).toBe(3)
|
|
515
|
+
expect(snapshot.tombstones).toEqual({ 'shape:deleted': 10 })
|
|
516
|
+
expect(snapshot.schema).toEqual(contractSchema.serialize())
|
|
517
|
+
})
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
describe('snapshot fallbacks', () => {
|
|
521
|
+
it('[SS17] falls back to the legacy clock field when documentClock is missing', () => {
|
|
522
|
+
const snapshot = makeContractSnapshot(contractRecords) as any
|
|
523
|
+
delete snapshot.documentClock
|
|
524
|
+
snapshot.clock = 15
|
|
525
|
+
const storage = create(snapshot)
|
|
526
|
+
expect(storage.getClock()).toBe(15)
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
it('[SS17] falls back to 0 when neither documentClock nor clock is present', () => {
|
|
530
|
+
const snapshot = makeContractSnapshot(contractRecords) as any
|
|
531
|
+
delete snapshot.documentClock
|
|
532
|
+
delete snapshot.clock
|
|
533
|
+
const storage = create(snapshot)
|
|
534
|
+
expect(storage.getClock()).toBe(0)
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
it('[SS17] tombstoneHistoryStartsAtClock defaults to the document clock', () => {
|
|
538
|
+
const storage = create(makeContractSnapshot(contractRecords, { documentClock: 20 }))
|
|
539
|
+
expect(storage.getSnapshot!().tombstoneHistoryStartsAtClock).toBe(20)
|
|
540
|
+
})
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
describe('tombstone pruning', () => {
|
|
544
|
+
const makeTombstones = (count: number) => {
|
|
545
|
+
const tombstones: Record<string, number> = {}
|
|
546
|
+
for (let i = 0; i < count; i++) {
|
|
547
|
+
tombstones[`shape:doc${i}`] = i + 1
|
|
548
|
+
}
|
|
549
|
+
return tombstones
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const flushPrune = (storage: TLSyncStorage<TLRecord>) => {
|
|
553
|
+
const prune = (storage as any).pruneTombstones
|
|
554
|
+
prune()
|
|
555
|
+
prune.flush()
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
it('[SS18] does not prune at or below MAX_TOMBSTONES', () => {
|
|
559
|
+
const storage = create(
|
|
560
|
+
makeContractSnapshot(contractRecords, {
|
|
561
|
+
tombstones: makeTombstones(MAX_TOMBSTONES),
|
|
562
|
+
documentClock: MAX_TOMBSTONES + 1,
|
|
563
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
564
|
+
})
|
|
565
|
+
)
|
|
566
|
+
flushPrune(storage)
|
|
567
|
+
expect(Object.keys(storage.getSnapshot!().tombstones!).length).toBe(MAX_TOMBSTONES)
|
|
568
|
+
expect(storage.getSnapshot!().tombstoneHistoryStartsAtClock).toBe(0)
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it('[SS18] prunes the oldest tombstones past the buffer and advances the history clock', () => {
|
|
572
|
+
const total = MAX_TOMBSTONES + 500
|
|
573
|
+
const storage = create(
|
|
574
|
+
makeContractSnapshot(contractRecords, {
|
|
575
|
+
tombstones: makeTombstones(total),
|
|
576
|
+
documentClock: total + 1,
|
|
577
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
578
|
+
})
|
|
579
|
+
)
|
|
580
|
+
flushPrune(storage)
|
|
581
|
+
|
|
582
|
+
const snapshot = storage.getSnapshot!()
|
|
583
|
+
const remaining = Object.entries(snapshot.tombstones!)
|
|
584
|
+
expect(remaining.length).toBe(MAX_TOMBSTONES - TOMBSTONE_PRUNE_BUFFER_SIZE)
|
|
585
|
+
// the oldest were deleted; everything left is at or after the new history start
|
|
586
|
+
const historyClock = snapshot.tombstoneHistoryStartsAtClock!
|
|
587
|
+
expect(historyClock).toBeGreaterThan(0)
|
|
588
|
+
for (const [, clock] of remaining) {
|
|
589
|
+
expect(clock).toBeGreaterThanOrEqual(historyClock)
|
|
590
|
+
}
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
it('[SS18] never splits tombstones sharing a clock value', () => {
|
|
594
|
+
// 10 tombstones share the clock value at the cutoff boundary
|
|
595
|
+
const total = MAX_TOMBSTONES + 1
|
|
596
|
+
const expectedCutoff = TOMBSTONE_PRUNE_BUFFER_SIZE + 1
|
|
597
|
+
const overflow = 10
|
|
598
|
+
const boundary = expectedCutoff + overflow
|
|
599
|
+
const tombstones: Record<string, number> = {}
|
|
600
|
+
for (let i = 0; i < total; i++) {
|
|
601
|
+
tombstones[`shape:doc${i}`] = i < boundary ? 1 : 2
|
|
602
|
+
}
|
|
603
|
+
const storage = create(
|
|
604
|
+
makeContractSnapshot(contractRecords, {
|
|
605
|
+
tombstones,
|
|
606
|
+
documentClock: 3,
|
|
607
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
608
|
+
})
|
|
609
|
+
)
|
|
610
|
+
flushPrune(storage)
|
|
611
|
+
|
|
612
|
+
const remaining = Object.values(storage.getSnapshot!().tombstones!)
|
|
613
|
+
expect(remaining.length).toBe(MAX_TOMBSTONES - TOMBSTONE_PRUNE_BUFFER_SIZE - overflow)
|
|
614
|
+
expect(remaining.every((clock) => clock === 2)).toBe(true)
|
|
615
|
+
})
|
|
616
|
+
})
|
|
617
|
+
})
|
|
618
|
+
}
|