@tldraw/sync-core 4.2.0 → 4.2.2
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 +483 -58
- package/dist-cjs/index.js +13 -3
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js +55 -0
- package/dist-cjs/lib/DurableObjectSqliteSyncWrapper.js.map +7 -0
- package/dist-cjs/lib/InMemorySyncStorage.js +287 -0
- package/dist-cjs/lib/InMemorySyncStorage.js.map +7 -0
- package/dist-cjs/lib/MicrotaskNotifier.js +50 -0
- package/dist-cjs/lib/MicrotaskNotifier.js.map +7 -0
- package/dist-cjs/lib/NodeSqliteWrapper.js +48 -0
- package/dist-cjs/lib/NodeSqliteWrapper.js.map +7 -0
- package/dist-cjs/lib/RoomSession.js.map +1 -1
- package/dist-cjs/lib/SQLiteSyncStorage.js +428 -0
- package/dist-cjs/lib/SQLiteSyncStorage.js.map +7 -0
- 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/chunk.js +2 -2
- package/dist-cjs/lib/chunk.js.map +1 -1
- package/dist-cjs/lib/recordDiff.js +52 -0
- package/dist-cjs/lib/recordDiff.js.map +7 -0
- package/dist-esm/index.d.mts +483 -58
- package/dist-esm/index.mjs +20 -5
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs +35 -0
- package/dist-esm/lib/DurableObjectSqliteSyncWrapper.mjs.map +7 -0
- package/dist-esm/lib/InMemorySyncStorage.mjs +272 -0
- package/dist-esm/lib/InMemorySyncStorage.mjs.map +7 -0
- package/dist-esm/lib/MicrotaskNotifier.mjs +30 -0
- package/dist-esm/lib/MicrotaskNotifier.mjs.map +7 -0
- package/dist-esm/lib/NodeSqliteWrapper.mjs +28 -0
- package/dist-esm/lib/NodeSqliteWrapper.mjs.map +7 -0
- package/dist-esm/lib/RoomSession.mjs.map +1 -1
- package/dist-esm/lib/SQLiteSyncStorage.mjs +414 -0
- package/dist-esm/lib/SQLiteSyncStorage.mjs.map +7 -0
- 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/chunk.mjs +2 -2
- package/dist-esm/lib/chunk.mjs.map +1 -1
- package/dist-esm/lib/recordDiff.mjs +32 -0
- package/dist-esm/lib/recordDiff.mjs.map +7 -0
- package/package.json +12 -11
- package/src/index.ts +32 -3
- package/src/lib/ClientWebSocketAdapter.test.ts +3 -0
- package/src/lib/DurableObjectSqliteSyncWrapper.ts +95 -0
- package/src/lib/InMemorySyncStorage.ts +387 -0
- package/src/lib/MicrotaskNotifier.test.ts +429 -0
- package/src/lib/MicrotaskNotifier.ts +38 -0
- package/src/lib/NodeSqliteSyncWrapper.integration.test.ts +270 -0
- package/src/lib/NodeSqliteSyncWrapper.test.ts +272 -0
- package/src/lib/NodeSqliteWrapper.ts +99 -0
- package/src/lib/RoomSession.test.ts +1 -0
- package/src/lib/RoomSession.ts +2 -0
- package/src/lib/SQLiteSyncStorage.ts +627 -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/chunk.ts +2 -2
- package/src/lib/computeTombstonePruning.test.ts +352 -0
- package/src/lib/recordDiff.ts +73 -0
- package/src/test/FuzzEditor.ts +4 -5
- package/src/test/InMemorySyncStorage.test.ts +1684 -0
- package/src/test/SQLiteSyncStorage.test.ts +1378 -0
- package/src/test/TLSocketRoom.test.ts +255 -49
- package/src/test/TLSyncRoom.test.ts +1024 -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 +290 -8
- package/src/test/validation.test.ts +15 -10
- package/src/test/pruneTombstones.test.ts +0 -178
|
@@ -0,0 +1,1378 @@
|
|
|
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 { DatabaseSync } from 'node:sqlite'
|
|
10
|
+
import { vi } from 'vitest'
|
|
11
|
+
import { MAX_TOMBSTONES, TOMBSTONE_PRUNE_BUFFER_SIZE } from '../lib/InMemorySyncStorage'
|
|
12
|
+
import { NodeSqliteWrapper } from '../lib/NodeSqliteWrapper'
|
|
13
|
+
import { SQLiteSyncStorage } from '../lib/SQLiteSyncStorage'
|
|
14
|
+
import { RoomSnapshot } from '../lib/TLSyncRoom'
|
|
15
|
+
|
|
16
|
+
const tlSchema = createTLSchema()
|
|
17
|
+
|
|
18
|
+
const makeSnapshot = (records: TLRecord[], others: Partial<RoomSnapshot> = {}): RoomSnapshot => ({
|
|
19
|
+
documents: records.map((r) => ({ state: r, lastChangedClock: 0 })),
|
|
20
|
+
clock: 0,
|
|
21
|
+
documentClock: 0,
|
|
22
|
+
schema: tlSchema.serialize(),
|
|
23
|
+
...others,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// Helper to create legacy snapshots without documentClock field
|
|
27
|
+
const makeLegacySnapshot = (
|
|
28
|
+
records: TLRecord[],
|
|
29
|
+
others: Partial<Omit<RoomSnapshot, 'documentClock'>> = {}
|
|
30
|
+
): Omit<RoomSnapshot, 'documentClock'> & { schema: RoomSnapshot['schema'] } => ({
|
|
31
|
+
documents: records.map((r) => ({ state: r, lastChangedClock: 0 })),
|
|
32
|
+
clock: 0,
|
|
33
|
+
schema: tlSchema.serialize(),
|
|
34
|
+
...others,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const defaultRecords = [
|
|
38
|
+
DocumentRecordType.create({ id: TLDOCUMENT_ID }),
|
|
39
|
+
PageRecordType.create({
|
|
40
|
+
index: ZERO_INDEX_KEY,
|
|
41
|
+
name: 'Page 1',
|
|
42
|
+
id: PageRecordType.createId('page_1'),
|
|
43
|
+
}),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
function createWrapper(config?: { tablePrefix?: string }) {
|
|
47
|
+
const db = new DatabaseSync(':memory:')
|
|
48
|
+
return new NodeSqliteWrapper(db, config)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getStorage(snapshot: RoomSnapshot, wrapperConfig?: { tablePrefix?: string }) {
|
|
52
|
+
const sql = createWrapper(wrapperConfig)
|
|
53
|
+
return new SQLiteSyncStorage<TLRecord>({ sql, snapshot })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('SQLiteSyncStorage', () => {
|
|
57
|
+
describe('Static methods', () => {
|
|
58
|
+
describe('hasBeenInitialized', () => {
|
|
59
|
+
it('returns false for empty database', () => {
|
|
60
|
+
const sql = createWrapper()
|
|
61
|
+
expect(SQLiteSyncStorage.hasBeenInitialized(sql)).toBe(false)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('returns true after storage is initialized', () => {
|
|
65
|
+
const sql = createWrapper()
|
|
66
|
+
new SQLiteSyncStorage<TLRecord>({ sql, snapshot: makeSnapshot(defaultRecords) })
|
|
67
|
+
expect(SQLiteSyncStorage.hasBeenInitialized(sql)).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('respects table prefix', () => {
|
|
71
|
+
const sql = createWrapper({ tablePrefix: 'test_' })
|
|
72
|
+
expect(SQLiteSyncStorage.hasBeenInitialized(sql)).toBe(false)
|
|
73
|
+
new SQLiteSyncStorage<TLRecord>({ sql, snapshot: makeSnapshot(defaultRecords) })
|
|
74
|
+
expect(SQLiteSyncStorage.hasBeenInitialized(sql)).toBe(true)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('getDocumentClock', () => {
|
|
79
|
+
it('returns null for empty database', () => {
|
|
80
|
+
const sql = createWrapper()
|
|
81
|
+
expect(SQLiteSyncStorage.getDocumentClock(sql)).toBe(null)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('returns 0 for newly initialized storage with default snapshot', () => {
|
|
85
|
+
const sql = createWrapper()
|
|
86
|
+
new SQLiteSyncStorage<TLRecord>({ sql, snapshot: makeSnapshot(defaultRecords) })
|
|
87
|
+
expect(SQLiteSyncStorage.getDocumentClock(sql)).toBe(0)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('returns the documentClock value from snapshot', () => {
|
|
91
|
+
const sql = createWrapper()
|
|
92
|
+
const snapshot = makeSnapshot(defaultRecords)
|
|
93
|
+
snapshot.documentClock = 42
|
|
94
|
+
new SQLiteSyncStorage<TLRecord>({ sql, snapshot })
|
|
95
|
+
expect(SQLiteSyncStorage.getDocumentClock(sql)).toBe(42)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('returns updated clock after transactions', () => {
|
|
99
|
+
const sql = createWrapper()
|
|
100
|
+
const storage = new SQLiteSyncStorage<TLRecord>({
|
|
101
|
+
sql,
|
|
102
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
103
|
+
})
|
|
104
|
+
expect(SQLiteSyncStorage.getDocumentClock(sql)).toBe(0)
|
|
105
|
+
|
|
106
|
+
const newPage = PageRecordType.create({
|
|
107
|
+
id: PageRecordType.createId('test_page'),
|
|
108
|
+
name: 'Test Page',
|
|
109
|
+
index: 'a1' as IndexKey,
|
|
110
|
+
})
|
|
111
|
+
storage.transaction((txn) => {
|
|
112
|
+
txn.set(newPage.id, newPage)
|
|
113
|
+
})
|
|
114
|
+
expect(SQLiteSyncStorage.getDocumentClock(sql)).toBe(1)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('respects table prefix', () => {
|
|
118
|
+
const sql = createWrapper({ tablePrefix: 'test_' })
|
|
119
|
+
expect(SQLiteSyncStorage.getDocumentClock(sql)).toBe(null)
|
|
120
|
+
new SQLiteSyncStorage<TLRecord>({ sql, snapshot: makeSnapshot(defaultRecords) })
|
|
121
|
+
expect(SQLiteSyncStorage.getDocumentClock(sql)).toBe(0)
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('Constructor', () => {
|
|
127
|
+
it('initializes documents from snapshot', () => {
|
|
128
|
+
const storage = getStorage(makeSnapshot(defaultRecords))
|
|
129
|
+
|
|
130
|
+
const snapshot = storage.getSnapshot()
|
|
131
|
+
expect(snapshot.documents.length).toBe(2)
|
|
132
|
+
expect(snapshot.documents.find((d) => d.state.id === TLDOCUMENT_ID)).toBeDefined()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('initializes schema from snapshot', () => {
|
|
136
|
+
const snapshotIn = makeSnapshot(defaultRecords)
|
|
137
|
+
const storage = getStorage(snapshotIn)
|
|
138
|
+
|
|
139
|
+
const snapshot = storage.getSnapshot()
|
|
140
|
+
expect(snapshot.schema).toEqual(snapshotIn.schema)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('initializes documentClock from snapshot', () => {
|
|
144
|
+
const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 42 }))
|
|
145
|
+
|
|
146
|
+
expect(storage.getClock()).toBe(42)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('falls back to clock when documentClock is not present (legacy snapshot)', () => {
|
|
150
|
+
const storage = getStorage(makeLegacySnapshot(defaultRecords, { clock: 15 }) as RoomSnapshot)
|
|
151
|
+
|
|
152
|
+
expect(storage.getClock()).toBe(15)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('falls back to 0 when neither documentClock nor clock is present', () => {
|
|
156
|
+
const snapshot = {
|
|
157
|
+
documents: defaultRecords.map((r) => ({ state: r, lastChangedClock: 0 })),
|
|
158
|
+
schema: tlSchema.serialize(),
|
|
159
|
+
} as RoomSnapshot
|
|
160
|
+
|
|
161
|
+
const storage = getStorage(snapshot)
|
|
162
|
+
|
|
163
|
+
expect(storage.getClock()).toBe(0)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('initializes tombstones from snapshot', () => {
|
|
167
|
+
const storage = getStorage(
|
|
168
|
+
makeSnapshot(defaultRecords, {
|
|
169
|
+
tombstones: { 'shape:deleted1': 5, 'shape:deleted2': 10 },
|
|
170
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
171
|
+
documentClock: 15,
|
|
172
|
+
})
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
const snapshot = storage.getSnapshot()
|
|
176
|
+
expect(Object.keys(snapshot.tombstones!).length).toBe(2)
|
|
177
|
+
expect(snapshot.tombstones?.['shape:deleted1']).toBe(5)
|
|
178
|
+
expect(snapshot.tombstones?.['shape:deleted2']).toBe(10)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('sets tombstoneHistoryStartsAtClock from snapshot', () => {
|
|
182
|
+
const storage = getStorage(
|
|
183
|
+
makeSnapshot(defaultRecords, {
|
|
184
|
+
tombstoneHistoryStartsAtClock: 5,
|
|
185
|
+
documentClock: 10,
|
|
186
|
+
})
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
const snapshot = storage.getSnapshot()
|
|
190
|
+
expect(snapshot.tombstoneHistoryStartsAtClock).toBe(5)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('defaults tombstoneHistoryStartsAtClock to documentClock when not provided', () => {
|
|
194
|
+
const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 20 }))
|
|
195
|
+
|
|
196
|
+
const snapshot = storage.getSnapshot()
|
|
197
|
+
expect(snapshot.tombstoneHistoryStartsAtClock).toBe(20)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('handles empty documents array', () => {
|
|
201
|
+
const storage = getStorage(makeSnapshot([]))
|
|
202
|
+
|
|
203
|
+
const snapshot = storage.getSnapshot()
|
|
204
|
+
expect(snapshot.documents.length).toBe(0)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('works with table prefix', () => {
|
|
208
|
+
const storage = getStorage(makeSnapshot(defaultRecords), { tablePrefix: 'myapp_' })
|
|
209
|
+
|
|
210
|
+
const snapshot = storage.getSnapshot()
|
|
211
|
+
expect(snapshot.documents.length).toBe(2)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('reinitializes storage when snapshot provided to existing tables', () => {
|
|
215
|
+
const sql = createWrapper()
|
|
216
|
+
|
|
217
|
+
// First initialization
|
|
218
|
+
new SQLiteSyncStorage<TLRecord>({
|
|
219
|
+
sql,
|
|
220
|
+
snapshot: makeSnapshot(defaultRecords, { documentClock: 10 }),
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
// Second initialization with different data
|
|
224
|
+
const newRecords = [DocumentRecordType.create({ id: TLDOCUMENT_ID })]
|
|
225
|
+
const storage2 = new SQLiteSyncStorage<TLRecord>({
|
|
226
|
+
sql,
|
|
227
|
+
snapshot: makeSnapshot(newRecords, { documentClock: 20 }),
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
const snapshot = storage2.getSnapshot()
|
|
231
|
+
expect(snapshot.documents.length).toBe(1)
|
|
232
|
+
expect(snapshot.documentClock).toBe(20)
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
describe('Transaction', () => {
|
|
237
|
+
describe('get()', () => {
|
|
238
|
+
it('returns record by id', () => {
|
|
239
|
+
const storage = getStorage(makeSnapshot(defaultRecords))
|
|
240
|
+
|
|
241
|
+
storage.transaction((txn) => {
|
|
242
|
+
const doc = txn.get(TLDOCUMENT_ID)
|
|
243
|
+
expect(doc).toBeDefined()
|
|
244
|
+
expect(doc?.id).toBe(TLDOCUMENT_ID)
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('returns undefined for non-existent record', () => {
|
|
249
|
+
const storage = getStorage(makeSnapshot(defaultRecords))
|
|
250
|
+
|
|
251
|
+
storage.transaction((txn) => {
|
|
252
|
+
expect(txn.get('nonexistent')).toBeUndefined()
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
describe('set()', () => {
|
|
258
|
+
it('creates new records', () => {
|
|
259
|
+
const storage = getStorage(makeSnapshot(defaultRecords))
|
|
260
|
+
|
|
261
|
+
const newPage = PageRecordType.create({
|
|
262
|
+
id: PageRecordType.createId('new_page'),
|
|
263
|
+
name: 'New Page',
|
|
264
|
+
index: 'a2' as IndexKey,
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
storage.transaction((txn) => {
|
|
268
|
+
txn.set(newPage.id, newPage)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
const snapshot = storage.getSnapshot()
|
|
272
|
+
expect(snapshot.documents.length).toBe(3)
|
|
273
|
+
expect(snapshot.documents.find((d) => d.state.id === newPage.id)?.state).toEqual(newPage)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('updates existing records', () => {
|
|
277
|
+
const storage = getStorage(makeSnapshot(defaultRecords))
|
|
278
|
+
|
|
279
|
+
const pageId = PageRecordType.createId('page_1')
|
|
280
|
+
const updatedPage = PageRecordType.create({
|
|
281
|
+
id: pageId,
|
|
282
|
+
name: 'Updated Page',
|
|
283
|
+
index: ZERO_INDEX_KEY,
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
storage.transaction((txn) => {
|
|
287
|
+
txn.set(pageId, updatedPage)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
const snapshot = storage.getSnapshot()
|
|
291
|
+
expect(snapshot.documents.find((d) => d.state.id === pageId)?.state).toEqual(updatedPage)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('clears tombstone when re-creating a deleted record', () => {
|
|
295
|
+
const pageId = PageRecordType.createId('page_to_delete')
|
|
296
|
+
const page = PageRecordType.create({
|
|
297
|
+
id: pageId,
|
|
298
|
+
name: 'Page',
|
|
299
|
+
index: 'a2' as IndexKey,
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
const storage = getStorage(makeSnapshot([...defaultRecords, page]))
|
|
303
|
+
|
|
304
|
+
// Delete the page
|
|
305
|
+
storage.transaction((txn) => {
|
|
306
|
+
txn.delete(pageId)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
let snapshot = storage.getSnapshot()
|
|
310
|
+
expect(snapshot.tombstones?.[pageId]).toBeDefined()
|
|
311
|
+
expect(snapshot.documents.find((d) => d.state.id === pageId)).toBeUndefined()
|
|
312
|
+
|
|
313
|
+
// Re-create the page
|
|
314
|
+
storage.transaction((txn) => {
|
|
315
|
+
txn.set(pageId, page)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
snapshot = storage.getSnapshot()
|
|
319
|
+
expect(snapshot.tombstones?.[pageId]).toBeUndefined()
|
|
320
|
+
expect(snapshot.documents.find((d) => d.state.id === pageId)).toBeDefined()
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('sets lastChangedClock to the incremented clock', () => {
|
|
324
|
+
const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 5 }))
|
|
325
|
+
|
|
326
|
+
const newPage = PageRecordType.create({
|
|
327
|
+
id: PageRecordType.createId('new_page'),
|
|
328
|
+
name: 'New Page',
|
|
329
|
+
index: 'a2' as IndexKey,
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
storage.transaction((txn) => {
|
|
333
|
+
txn.set(newPage.id, newPage)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
const snapshot = storage.getSnapshot()
|
|
337
|
+
expect(snapshot.documents.find((d) => d.state.id === newPage.id)?.lastChangedClock).toBe(6)
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
describe('delete()', () => {
|
|
342
|
+
it('removes records', () => {
|
|
343
|
+
const storage = getStorage(makeSnapshot(defaultRecords))
|
|
344
|
+
|
|
345
|
+
const pageId = PageRecordType.createId('page_1')
|
|
346
|
+
|
|
347
|
+
storage.transaction((txn) => {
|
|
348
|
+
txn.delete(pageId)
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
const snapshot = storage.getSnapshot()
|
|
352
|
+
expect(snapshot.documents.find((d) => d.state.id === pageId)).toBeUndefined()
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('creates tombstones', () => {
|
|
356
|
+
const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 10 }))
|
|
357
|
+
|
|
358
|
+
const pageId = PageRecordType.createId('page_1')
|
|
359
|
+
|
|
360
|
+
storage.transaction((txn) => {
|
|
361
|
+
txn.delete(pageId)
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
const snapshot = storage.getSnapshot()
|
|
365
|
+
expect(snapshot.tombstones?.[pageId]).toBe(11) // clock incremented to 11
|
|
366
|
+
})
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
describe('getClock()', () => {
|
|
370
|
+
it('returns current clock at start of transaction', () => {
|
|
371
|
+
const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 42 }))
|
|
372
|
+
|
|
373
|
+
storage.transaction((txn) => {
|
|
374
|
+
expect(txn.getClock()).toBe(42)
|
|
375
|
+
})
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('returns incremented clock after a write', () => {
|
|
379
|
+
const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 42 }))
|
|
380
|
+
|
|
381
|
+
storage.transaction((txn) => {
|
|
382
|
+
expect(txn.getClock()).toBe(42)
|
|
383
|
+
const newPage = PageRecordType.create({
|
|
384
|
+
id: PageRecordType.createId('new'),
|
|
385
|
+
name: 'New',
|
|
386
|
+
index: 'a2' as IndexKey,
|
|
387
|
+
})
|
|
388
|
+
txn.set(newPage.id, newPage)
|
|
389
|
+
expect(txn.getClock()).toBe(43)
|
|
390
|
+
})
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('increments clock only once per transaction', () => {
|
|
394
|
+
const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 10 }))
|
|
395
|
+
|
|
396
|
+
storage.transaction((txn) => {
|
|
397
|
+
const page1 = PageRecordType.create({
|
|
398
|
+
id: PageRecordType.createId('p1'),
|
|
399
|
+
name: 'P1',
|
|
400
|
+
index: 'a2' as IndexKey,
|
|
401
|
+
})
|
|
402
|
+
const page2 = PageRecordType.create({
|
|
403
|
+
id: PageRecordType.createId('p2'),
|
|
404
|
+
name: 'P2',
|
|
405
|
+
index: 'a3' as IndexKey,
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
txn.set(page1.id, page1)
|
|
409
|
+
expect(txn.getClock()).toBe(11)
|
|
410
|
+
|
|
411
|
+
txn.set(page2.id, page2)
|
|
412
|
+
expect(txn.getClock()).toBe(11) // Still 11, not 12
|
|
413
|
+
|
|
414
|
+
txn.delete(PageRecordType.createId('page_1'))
|
|
415
|
+
expect(txn.getClock()).toBe(11) // Still 11
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
expect(storage.getClock()).toBe(11)
|
|
419
|
+
})
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
describe('entries()', () => {
|
|
423
|
+
it('iterates over all documents', () => {
|
|
424
|
+
const storage = getStorage(makeSnapshot(defaultRecords))
|
|
425
|
+
|
|
426
|
+
storage.transaction((txn) => {
|
|
427
|
+
const entries = Array.from(txn.entries())
|
|
428
|
+
expect(entries.length).toBe(2)
|
|
429
|
+
expect(entries.map(([id]) => id).sort()).toEqual(defaultRecords.map((r) => r.id).sort())
|
|
430
|
+
})
|
|
431
|
+
})
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
describe('keys()', () => {
|
|
435
|
+
it('iterates over all document ids', () => {
|
|
436
|
+
const storage = getStorage(makeSnapshot(defaultRecords))
|
|
437
|
+
|
|
438
|
+
storage.transaction((txn) => {
|
|
439
|
+
const keys = Array.from(txn.keys())
|
|
440
|
+
expect(keys.sort()).toEqual(defaultRecords.map((r) => r.id).sort())
|
|
441
|
+
})
|
|
442
|
+
})
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
describe('values()', () => {
|
|
446
|
+
it('iterates over all document states', () => {
|
|
447
|
+
const storage = getStorage(makeSnapshot(defaultRecords))
|
|
448
|
+
|
|
449
|
+
storage.transaction((txn) => {
|
|
450
|
+
const values = Array.from(txn.values())
|
|
451
|
+
expect(values.length).toBe(2)
|
|
452
|
+
expect(values.map((v) => v.id).sort()).toEqual(defaultRecords.map((r) => r.id).sort())
|
|
453
|
+
})
|
|
454
|
+
})
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
describe('iterator consumption after transaction ends', () => {
|
|
458
|
+
it('throws when entries() iterator is consumed after transaction ends', () => {
|
|
459
|
+
const storage = getStorage(makeSnapshot(defaultRecords))
|
|
460
|
+
|
|
461
|
+
let iterator: Iterator<[string, TLRecord]>
|
|
462
|
+
|
|
463
|
+
storage.transaction((txn) => {
|
|
464
|
+
iterator = txn.entries()[Symbol.iterator]()
|
|
465
|
+
// Consume one item inside the transaction - should work
|
|
466
|
+
iterator.next()
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
// Trying to consume more after transaction ends should throw
|
|
470
|
+
expect(() => iterator.next()).toThrow('Transaction has ended')
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
it('throws when keys() iterator is consumed after transaction ends', () => {
|
|
474
|
+
const storage = getStorage(makeSnapshot(defaultRecords))
|
|
475
|
+
|
|
476
|
+
let iterator: Iterator<string>
|
|
477
|
+
|
|
478
|
+
storage.transaction((txn) => {
|
|
479
|
+
iterator = txn.keys()[Symbol.iterator]()
|
|
480
|
+
iterator.next()
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
expect(() => iterator.next()).toThrow('Transaction has ended')
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('throws when values() iterator is consumed after transaction ends', () => {
|
|
487
|
+
const storage = getStorage(makeSnapshot(defaultRecords))
|
|
488
|
+
|
|
489
|
+
let iterator: Iterator<TLRecord>
|
|
490
|
+
|
|
491
|
+
storage.transaction((txn) => {
|
|
492
|
+
iterator = txn.values()[Symbol.iterator]()
|
|
493
|
+
iterator.next()
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
expect(() => iterator.next()).toThrow('Transaction has ended')
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
it('allows full consumption of iterator within transaction', () => {
|
|
500
|
+
const storage = getStorage(makeSnapshot(defaultRecords))
|
|
501
|
+
|
|
502
|
+
storage.transaction((txn) => {
|
|
503
|
+
// Should be able to fully consume all iterators
|
|
504
|
+
const entries = Array.from(txn.entries())
|
|
505
|
+
const keys = Array.from(txn.keys())
|
|
506
|
+
const values = Array.from(txn.values())
|
|
507
|
+
|
|
508
|
+
expect(entries.length).toBe(2)
|
|
509
|
+
expect(keys.length).toBe(2)
|
|
510
|
+
expect(values.length).toBe(2)
|
|
511
|
+
})
|
|
512
|
+
})
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
describe('getSchema() / setSchema()', () => {
|
|
516
|
+
it('gets the current schema', () => {
|
|
517
|
+
const snapshotIn = makeSnapshot(defaultRecords)
|
|
518
|
+
const storage = getStorage(snapshotIn)
|
|
519
|
+
|
|
520
|
+
storage.transaction((txn) => {
|
|
521
|
+
expect(txn.getSchema()).toEqual(snapshotIn.schema)
|
|
522
|
+
})
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
it('sets the schema', () => {
|
|
526
|
+
const storage = getStorage(makeSnapshot(defaultRecords))
|
|
527
|
+
|
|
528
|
+
const newSchema = { ...tlSchema.serialize(), schemaVersion: 99 as any }
|
|
529
|
+
|
|
530
|
+
storage.transaction((txn) => {
|
|
531
|
+
txn.setSchema(newSchema)
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
const snapshot = storage.getSnapshot()
|
|
535
|
+
expect(snapshot.schema?.schemaVersion).toBe(99)
|
|
536
|
+
})
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
describe('transaction result', () => {
|
|
540
|
+
it('returns result from callback', () => {
|
|
541
|
+
const storage = getStorage(makeSnapshot(defaultRecords))
|
|
542
|
+
|
|
543
|
+
const { result } = storage.transaction((txn) => {
|
|
544
|
+
return txn.get(TLDOCUMENT_ID)
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
expect(result?.id).toBe(TLDOCUMENT_ID)
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
it('returns didChange: false when no writes occur', () => {
|
|
551
|
+
const storage = getStorage(makeSnapshot(defaultRecords))
|
|
552
|
+
|
|
553
|
+
const { didChange, documentClock } = storage.transaction((txn) => {
|
|
554
|
+
txn.get(TLDOCUMENT_ID)
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
expect(didChange).toBe(false)
|
|
558
|
+
expect(documentClock).toBe(0)
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
it('returns didChange: true when writes occur', () => {
|
|
562
|
+
const storage = getStorage(makeSnapshot(defaultRecords))
|
|
563
|
+
|
|
564
|
+
const newPage = PageRecordType.create({
|
|
565
|
+
id: PageRecordType.createId('new'),
|
|
566
|
+
name: 'New',
|
|
567
|
+
index: 'a2' as IndexKey,
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
const { didChange, documentClock } = storage.transaction((txn) => {
|
|
571
|
+
txn.set(newPage.id, newPage)
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
expect(didChange).toBe(true)
|
|
575
|
+
expect(documentClock).toBe(1)
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
it('throws when callback returns a promise', () => {
|
|
579
|
+
const storage = getStorage(makeSnapshot(defaultRecords))
|
|
580
|
+
|
|
581
|
+
expect(() => {
|
|
582
|
+
storage.transaction(() => Promise.resolve() as any)
|
|
583
|
+
}).toThrow('Transaction must return a value, not a promise')
|
|
584
|
+
})
|
|
585
|
+
})
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
describe('getChangesSince', () => {
|
|
589
|
+
it('returns puts for records changed after sinceClock', () => {
|
|
590
|
+
const storage = getStorage(
|
|
591
|
+
makeSnapshot(defaultRecords, {
|
|
592
|
+
documents: [
|
|
593
|
+
{ state: defaultRecords[0], lastChangedClock: 5 },
|
|
594
|
+
{ state: defaultRecords[1], lastChangedClock: 10 },
|
|
595
|
+
],
|
|
596
|
+
documentClock: 15,
|
|
597
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
598
|
+
})
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
storage.transaction((txn) => {
|
|
602
|
+
const changes = txn.getChangesSince(7)!
|
|
603
|
+
const puts = Object.values(changes.diff.puts)
|
|
604
|
+
|
|
605
|
+
expect(puts.length).toBe(1)
|
|
606
|
+
expect((puts[0] as TLRecord).id).toBe(defaultRecords[1].id) // only record with clock 10 > 7
|
|
607
|
+
expect(changes.wipeAll).toBe(false)
|
|
608
|
+
})
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
it('returns all records when sinceClock is before all changes', () => {
|
|
612
|
+
const storage = getStorage(
|
|
613
|
+
makeSnapshot(defaultRecords, {
|
|
614
|
+
documents: [
|
|
615
|
+
{ state: defaultRecords[0], lastChangedClock: 5 },
|
|
616
|
+
{ state: defaultRecords[1], lastChangedClock: 10 },
|
|
617
|
+
],
|
|
618
|
+
documentClock: 15,
|
|
619
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
620
|
+
})
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
storage.transaction((txn) => {
|
|
624
|
+
const changes = txn.getChangesSince(0)!
|
|
625
|
+
const puts = Object.values(changes.diff.puts)
|
|
626
|
+
|
|
627
|
+
expect(puts.length).toBe(2)
|
|
628
|
+
})
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
it('returns deletes for tombstones after sinceClock', () => {
|
|
632
|
+
const storage = getStorage(
|
|
633
|
+
makeSnapshot(defaultRecords, {
|
|
634
|
+
tombstones: {
|
|
635
|
+
'shape:deleted1': 5,
|
|
636
|
+
'shape:deleted2': 12,
|
|
637
|
+
},
|
|
638
|
+
documentClock: 15,
|
|
639
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
640
|
+
})
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
storage.transaction((txn) => {
|
|
644
|
+
const changes = txn.getChangesSince(7)!
|
|
645
|
+
|
|
646
|
+
expect(changes.diff.deletes).toEqual(['shape:deleted2']) // only tombstone with clock 12 > 7
|
|
647
|
+
})
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
it('returns wipeAll: true when sinceClock < tombstoneHistoryStartsAtClock', () => {
|
|
651
|
+
const storage = getStorage(
|
|
652
|
+
makeSnapshot(defaultRecords, {
|
|
653
|
+
documentClock: 20,
|
|
654
|
+
tombstoneHistoryStartsAtClock: 10,
|
|
655
|
+
})
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
storage.transaction((txn) => {
|
|
659
|
+
const changes = txn.getChangesSince(5)! // 5 < 10
|
|
660
|
+
|
|
661
|
+
expect(changes.wipeAll).toBe(true)
|
|
662
|
+
// When wipeAll is true, all documents are returned
|
|
663
|
+
const puts = Object.values(changes.diff.puts)
|
|
664
|
+
expect(puts.length).toBe(2)
|
|
665
|
+
})
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
it('returns wipeAll: false when sinceClock >= tombstoneHistoryStartsAtClock', () => {
|
|
669
|
+
const storage = getStorage(
|
|
670
|
+
makeSnapshot(defaultRecords, {
|
|
671
|
+
documentClock: 20,
|
|
672
|
+
tombstoneHistoryStartsAtClock: 10,
|
|
673
|
+
})
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
storage.transaction((txn) => {
|
|
677
|
+
const changes = txn.getChangesSince(15)!
|
|
678
|
+
|
|
679
|
+
expect(changes.wipeAll).toBe(false)
|
|
680
|
+
})
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
it('returns undefined when no changes since clock', () => {
|
|
684
|
+
const storage = getStorage(
|
|
685
|
+
makeSnapshot(defaultRecords, {
|
|
686
|
+
documents: [
|
|
687
|
+
{ state: defaultRecords[0], lastChangedClock: 5 },
|
|
688
|
+
{ state: defaultRecords[1], lastChangedClock: 10 },
|
|
689
|
+
],
|
|
690
|
+
documentClock: 15,
|
|
691
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
692
|
+
})
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
storage.transaction((txn) => {
|
|
696
|
+
const changes = txn.getChangesSince(15)
|
|
697
|
+
expect(changes).toBeUndefined()
|
|
698
|
+
})
|
|
699
|
+
})
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
describe('onChange', () => {
|
|
703
|
+
it('accepts onChange callback in constructor', async () => {
|
|
704
|
+
const listener = vi.fn()
|
|
705
|
+
const sql = createWrapper()
|
|
706
|
+
const storage = new SQLiteSyncStorage<TLRecord>({
|
|
707
|
+
sql,
|
|
708
|
+
snapshot: makeSnapshot(defaultRecords),
|
|
709
|
+
onChange: listener,
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
await Promise.resolve()
|
|
713
|
+
|
|
714
|
+
const newPage = PageRecordType.create({
|
|
715
|
+
id: PageRecordType.createId('new'),
|
|
716
|
+
name: 'New',
|
|
717
|
+
index: 'a2' as IndexKey,
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
storage.transaction((txn) => {
|
|
721
|
+
txn.set(newPage.id, newPage)
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
await Promise.resolve()
|
|
725
|
+
|
|
726
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
727
|
+
expect(listener).toHaveBeenCalledWith(
|
|
728
|
+
expect.objectContaining({
|
|
729
|
+
documentClock: 1,
|
|
730
|
+
})
|
|
731
|
+
)
|
|
732
|
+
})
|
|
733
|
+
|
|
734
|
+
it('notifies listeners after changes', async () => {
|
|
735
|
+
const storage = getStorage(makeSnapshot(defaultRecords))
|
|
736
|
+
|
|
737
|
+
const listener = vi.fn()
|
|
738
|
+
storage.onChange(listener)
|
|
739
|
+
|
|
740
|
+
// Wait for listener registration (microtask)
|
|
741
|
+
await Promise.resolve()
|
|
742
|
+
|
|
743
|
+
const newPage = PageRecordType.create({
|
|
744
|
+
id: PageRecordType.createId('new'),
|
|
745
|
+
name: 'New',
|
|
746
|
+
index: 'a2' as IndexKey,
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
storage.transaction((txn) => {
|
|
750
|
+
txn.set(newPage.id, newPage)
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
// Wait for notification (microtask)
|
|
754
|
+
await Promise.resolve()
|
|
755
|
+
|
|
756
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
757
|
+
})
|
|
758
|
+
|
|
759
|
+
it('receives correct documentClock', async () => {
|
|
760
|
+
const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 10 }))
|
|
761
|
+
|
|
762
|
+
const listener = vi.fn()
|
|
763
|
+
storage.onChange(listener)
|
|
764
|
+
|
|
765
|
+
await Promise.resolve()
|
|
766
|
+
|
|
767
|
+
const newPage = PageRecordType.create({
|
|
768
|
+
id: PageRecordType.createId('new'),
|
|
769
|
+
name: 'New',
|
|
770
|
+
index: 'a2' as IndexKey,
|
|
771
|
+
})
|
|
772
|
+
|
|
773
|
+
storage.transaction((txn) => {
|
|
774
|
+
txn.set(newPage.id, newPage)
|
|
775
|
+
})
|
|
776
|
+
|
|
777
|
+
await Promise.resolve()
|
|
778
|
+
|
|
779
|
+
expect(listener).toHaveBeenCalledWith(
|
|
780
|
+
expect.objectContaining({
|
|
781
|
+
documentClock: 11,
|
|
782
|
+
})
|
|
783
|
+
)
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
it('receives transaction id when provided', async () => {
|
|
787
|
+
const storage = getStorage(makeSnapshot(defaultRecords))
|
|
788
|
+
|
|
789
|
+
const listener = vi.fn()
|
|
790
|
+
storage.onChange(listener)
|
|
791
|
+
|
|
792
|
+
await Promise.resolve()
|
|
793
|
+
|
|
794
|
+
const newPage = PageRecordType.create({
|
|
795
|
+
id: PageRecordType.createId('new'),
|
|
796
|
+
name: 'New',
|
|
797
|
+
index: 'a2' as IndexKey,
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
storage.transaction(
|
|
801
|
+
(txn) => {
|
|
802
|
+
txn.set(newPage.id, newPage)
|
|
803
|
+
},
|
|
804
|
+
{ id: 'my-transaction-id' }
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
await Promise.resolve()
|
|
808
|
+
|
|
809
|
+
expect(listener).toHaveBeenCalledWith(
|
|
810
|
+
expect.objectContaining({
|
|
811
|
+
id: 'my-transaction-id',
|
|
812
|
+
})
|
|
813
|
+
)
|
|
814
|
+
})
|
|
815
|
+
|
|
816
|
+
it('unsubscribe prevents future notifications', async () => {
|
|
817
|
+
const storage = getStorage(makeSnapshot(defaultRecords))
|
|
818
|
+
|
|
819
|
+
const listener = vi.fn()
|
|
820
|
+
const unsubscribe = storage.onChange(listener)
|
|
821
|
+
|
|
822
|
+
await Promise.resolve()
|
|
823
|
+
|
|
824
|
+
// Unsubscribe immediately
|
|
825
|
+
unsubscribe()
|
|
826
|
+
|
|
827
|
+
const newPage = PageRecordType.create({
|
|
828
|
+
id: PageRecordType.createId('new'),
|
|
829
|
+
name: 'New',
|
|
830
|
+
index: 'a2' as IndexKey,
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
storage.transaction((txn) => {
|
|
834
|
+
txn.set(newPage.id, newPage)
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
await Promise.resolve()
|
|
838
|
+
|
|
839
|
+
expect(listener).not.toHaveBeenCalled()
|
|
840
|
+
})
|
|
841
|
+
|
|
842
|
+
it('does not notify for read-only transactions', async () => {
|
|
843
|
+
const storage = getStorage(makeSnapshot(defaultRecords))
|
|
844
|
+
|
|
845
|
+
const listener = vi.fn()
|
|
846
|
+
storage.onChange(listener)
|
|
847
|
+
|
|
848
|
+
await Promise.resolve()
|
|
849
|
+
|
|
850
|
+
storage.transaction((txn) => {
|
|
851
|
+
txn.get(TLDOCUMENT_ID) // read only
|
|
852
|
+
})
|
|
853
|
+
|
|
854
|
+
await Promise.resolve()
|
|
855
|
+
|
|
856
|
+
expect(listener).not.toHaveBeenCalled()
|
|
857
|
+
})
|
|
858
|
+
})
|
|
859
|
+
|
|
860
|
+
describe('getSnapshot', () => {
|
|
861
|
+
it('returns correct snapshot structure', () => {
|
|
862
|
+
const storage = getStorage(
|
|
863
|
+
makeSnapshot(defaultRecords, {
|
|
864
|
+
documentClock: 15,
|
|
865
|
+
tombstoneHistoryStartsAtClock: 5,
|
|
866
|
+
tombstones: { 'shape:deleted': 10 },
|
|
867
|
+
})
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
const snapshot = storage.getSnapshot()
|
|
871
|
+
|
|
872
|
+
expect(snapshot.documentClock).toBe(15)
|
|
873
|
+
expect(snapshot.tombstoneHistoryStartsAtClock).toBe(5)
|
|
874
|
+
expect(snapshot.documents.length).toBe(2)
|
|
875
|
+
expect(snapshot.tombstones).toEqual({ 'shape:deleted': 10 })
|
|
876
|
+
expect(snapshot.schema).toEqual(tlSchema.serialize())
|
|
877
|
+
})
|
|
878
|
+
|
|
879
|
+
it('reflects changes from transactions', () => {
|
|
880
|
+
const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 0 }))
|
|
881
|
+
|
|
882
|
+
const newPage = PageRecordType.create({
|
|
883
|
+
id: PageRecordType.createId('new'),
|
|
884
|
+
name: 'New',
|
|
885
|
+
index: 'a2' as IndexKey,
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
storage.transaction((txn) => {
|
|
889
|
+
txn.set(newPage.id, newPage)
|
|
890
|
+
})
|
|
891
|
+
|
|
892
|
+
const snapshot = storage.getSnapshot()
|
|
893
|
+
|
|
894
|
+
expect(snapshot.documentClock).toBe(1)
|
|
895
|
+
expect(snapshot.documents.length).toBe(3)
|
|
896
|
+
expect(snapshot.documents.find((d) => d.state.id === newPage.id)).toBeDefined()
|
|
897
|
+
})
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
describe('Edge cases', () => {
|
|
901
|
+
describe('Transaction error handling', () => {
|
|
902
|
+
it('does not increment clock if transaction throws', () => {
|
|
903
|
+
const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 10 }))
|
|
904
|
+
|
|
905
|
+
expect(() => {
|
|
906
|
+
storage.transaction(() => {
|
|
907
|
+
throw new Error('Oops!')
|
|
908
|
+
})
|
|
909
|
+
}).toThrow('Oops!')
|
|
910
|
+
|
|
911
|
+
// Clock should not have changed
|
|
912
|
+
expect(storage.getClock()).toBe(10)
|
|
913
|
+
})
|
|
914
|
+
|
|
915
|
+
it('rolls back changes if transaction throws after a write', () => {
|
|
916
|
+
const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 10 }))
|
|
917
|
+
|
|
918
|
+
const newPage = PageRecordType.create({
|
|
919
|
+
id: PageRecordType.createId('new'),
|
|
920
|
+
name: 'New',
|
|
921
|
+
index: 'a2' as IndexKey,
|
|
922
|
+
})
|
|
923
|
+
|
|
924
|
+
expect(() => {
|
|
925
|
+
storage.transaction((txn) => {
|
|
926
|
+
txn.set(newPage.id, newPage)
|
|
927
|
+
throw new Error('Oops after write!')
|
|
928
|
+
})
|
|
929
|
+
}).toThrow('Oops after write!')
|
|
930
|
+
|
|
931
|
+
// Document should not have been added (rolled back)
|
|
932
|
+
const snapshot = storage.getSnapshot()
|
|
933
|
+
expect(snapshot.documents.find((d) => d.state.id === newPage.id)).toBeUndefined()
|
|
934
|
+
// Clock should not have changed
|
|
935
|
+
expect(storage.getClock()).toBe(10)
|
|
936
|
+
})
|
|
937
|
+
})
|
|
938
|
+
|
|
939
|
+
describe('Deleting non-existent records', () => {
|
|
940
|
+
it('does not create a tombstone for records that never existed', () => {
|
|
941
|
+
const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 5 }))
|
|
942
|
+
|
|
943
|
+
storage.transaction((txn) => {
|
|
944
|
+
txn.delete('nonexistent:record')
|
|
945
|
+
})
|
|
946
|
+
|
|
947
|
+
// No tombstone should be created for a record that never existed
|
|
948
|
+
const snapshot = storage.getSnapshot()
|
|
949
|
+
expect(snapshot.tombstones?.['nonexistent:record']).toBeUndefined()
|
|
950
|
+
// Clock should not be incremented since nothing changed
|
|
951
|
+
expect(storage.getClock()).toBe(5)
|
|
952
|
+
})
|
|
953
|
+
|
|
954
|
+
it('does not increment clock when deleting non-existent record', () => {
|
|
955
|
+
const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 10 }))
|
|
956
|
+
|
|
957
|
+
const { didChange, documentClock } = storage.transaction((txn) => {
|
|
958
|
+
txn.delete('nonexistent:record')
|
|
959
|
+
})
|
|
960
|
+
|
|
961
|
+
expect(didChange).toBe(false)
|
|
962
|
+
expect(documentClock).toBe(10)
|
|
963
|
+
})
|
|
964
|
+
})
|
|
965
|
+
|
|
966
|
+
describe('Set with mismatched ID', () => {
|
|
967
|
+
it('throws when key does not match record.id', () => {
|
|
968
|
+
const storage = getStorage(makeSnapshot(defaultRecords))
|
|
969
|
+
|
|
970
|
+
const page = PageRecordType.create({
|
|
971
|
+
id: PageRecordType.createId('actual_id'),
|
|
972
|
+
name: 'Test',
|
|
973
|
+
index: 'a2' as IndexKey,
|
|
974
|
+
})
|
|
975
|
+
|
|
976
|
+
// Attempting to store with a different key than the record's id should throw
|
|
977
|
+
expect(() => {
|
|
978
|
+
storage.transaction((txn) => {
|
|
979
|
+
txn.set('different:key', page)
|
|
980
|
+
})
|
|
981
|
+
}).toThrow('Record id mismatch: key does not match record.id')
|
|
982
|
+
})
|
|
983
|
+
|
|
984
|
+
it('succeeds when key matches record.id', () => {
|
|
985
|
+
const storage = getStorage(makeSnapshot(defaultRecords))
|
|
986
|
+
|
|
987
|
+
const page = PageRecordType.create({
|
|
988
|
+
id: PageRecordType.createId('my_page'),
|
|
989
|
+
name: 'Test',
|
|
990
|
+
index: 'a2' as IndexKey,
|
|
991
|
+
})
|
|
992
|
+
|
|
993
|
+
// Store with matching key
|
|
994
|
+
storage.transaction((txn) => {
|
|
995
|
+
txn.set(page.id, page)
|
|
996
|
+
})
|
|
997
|
+
|
|
998
|
+
const snapshot = storage.getSnapshot()
|
|
999
|
+
expect(snapshot.documents.find((d) => d.state.id === page.id)).toBeDefined()
|
|
1000
|
+
})
|
|
1001
|
+
})
|
|
1002
|
+
|
|
1003
|
+
describe('getChangesSince boundary conditions', () => {
|
|
1004
|
+
it('sinceClock exactly equal to tombstoneHistoryStartsAtClock is NOT wipeAll', () => {
|
|
1005
|
+
const storage = getStorage(
|
|
1006
|
+
makeSnapshot(defaultRecords, {
|
|
1007
|
+
documentClock: 20,
|
|
1008
|
+
tombstoneHistoryStartsAtClock: 10,
|
|
1009
|
+
})
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
storage.transaction((txn) => {
|
|
1013
|
+
// sinceClock === tombstoneHistoryStartsAtClock
|
|
1014
|
+
const changes = txn.getChangesSince(10)!
|
|
1015
|
+
expect(changes.wipeAll).toBe(false)
|
|
1016
|
+
})
|
|
1017
|
+
})
|
|
1018
|
+
|
|
1019
|
+
it('sinceClock one less than tombstoneHistoryStartsAtClock IS wipeAll', () => {
|
|
1020
|
+
const storage = getStorage(
|
|
1021
|
+
makeSnapshot(defaultRecords, {
|
|
1022
|
+
documentClock: 20,
|
|
1023
|
+
tombstoneHistoryStartsAtClock: 10,
|
|
1024
|
+
})
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
storage.transaction((txn) => {
|
|
1028
|
+
const changes = txn.getChangesSince(9)!
|
|
1029
|
+
expect(changes.wipeAll).toBe(true)
|
|
1030
|
+
})
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
it('handles negative sinceClock', () => {
|
|
1034
|
+
const storage = getStorage(
|
|
1035
|
+
makeSnapshot(defaultRecords, {
|
|
1036
|
+
documentClock: 10,
|
|
1037
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
1038
|
+
})
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
storage.transaction((txn) => {
|
|
1042
|
+
const changes = txn.getChangesSince(-1)!
|
|
1043
|
+
// -1 < 0, so wipeAll should be true
|
|
1044
|
+
expect(changes.wipeAll).toBe(true)
|
|
1045
|
+
// All documents should be returned
|
|
1046
|
+
expect(Object.values(changes.diff.puts).length).toBe(2)
|
|
1047
|
+
})
|
|
1048
|
+
})
|
|
1049
|
+
|
|
1050
|
+
it('returns undefined when sinceClock equals current documentClock', () => {
|
|
1051
|
+
const storage = getStorage(
|
|
1052
|
+
makeSnapshot(defaultRecords, {
|
|
1053
|
+
documents: [
|
|
1054
|
+
{ state: defaultRecords[0], lastChangedClock: 5 },
|
|
1055
|
+
{ state: defaultRecords[1], lastChangedClock: 10 },
|
|
1056
|
+
],
|
|
1057
|
+
documentClock: 10,
|
|
1058
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
1059
|
+
})
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
storage.transaction((txn) => {
|
|
1063
|
+
const changes = txn.getChangesSince(10)
|
|
1064
|
+
expect(changes).toBeUndefined()
|
|
1065
|
+
})
|
|
1066
|
+
})
|
|
1067
|
+
|
|
1068
|
+
it('returns all changes when sinceClock is greater than documentClock', () => {
|
|
1069
|
+
const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 10 }))
|
|
1070
|
+
|
|
1071
|
+
storage.transaction((txn) => {
|
|
1072
|
+
const changes = txn.getChangesSince(100)!
|
|
1073
|
+
expect(Object.values(changes.diff.puts).length).toBe(2)
|
|
1074
|
+
expect(changes.wipeAll).toBe(true)
|
|
1075
|
+
})
|
|
1076
|
+
})
|
|
1077
|
+
})
|
|
1078
|
+
|
|
1079
|
+
describe('Transaction result consistency', () => {
|
|
1080
|
+
it('didChange reflects whether clock was incremented', () => {
|
|
1081
|
+
const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 10 }))
|
|
1082
|
+
|
|
1083
|
+
// Read-only transaction
|
|
1084
|
+
const readResult = storage.transaction((txn) => {
|
|
1085
|
+
txn.get(TLDOCUMENT_ID)
|
|
1086
|
+
return 'read'
|
|
1087
|
+
})
|
|
1088
|
+
|
|
1089
|
+
expect(readResult.didChange).toBe(false)
|
|
1090
|
+
expect(readResult.documentClock).toBe(10)
|
|
1091
|
+
|
|
1092
|
+
// Write transaction
|
|
1093
|
+
const writeResult = storage.transaction((txn) => {
|
|
1094
|
+
txn.set(TLDOCUMENT_ID, defaultRecords[0])
|
|
1095
|
+
return 'write'
|
|
1096
|
+
})
|
|
1097
|
+
|
|
1098
|
+
expect(writeResult.didChange).toBe(true)
|
|
1099
|
+
expect(writeResult.documentClock).toBe(11)
|
|
1100
|
+
})
|
|
1101
|
+
|
|
1102
|
+
it('documentClock in result matches storage.getClock()', () => {
|
|
1103
|
+
const storage = getStorage(makeSnapshot(defaultRecords, { documentClock: 5 }))
|
|
1104
|
+
|
|
1105
|
+
const result = storage.transaction((txn) => {
|
|
1106
|
+
const page = PageRecordType.create({
|
|
1107
|
+
id: PageRecordType.createId('new'),
|
|
1108
|
+
name: 'New',
|
|
1109
|
+
index: 'a2' as IndexKey,
|
|
1110
|
+
})
|
|
1111
|
+
txn.set(page.id, page)
|
|
1112
|
+
})
|
|
1113
|
+
|
|
1114
|
+
expect(result.documentClock).toBe(storage.getClock())
|
|
1115
|
+
expect(result.documentClock).toBe(6)
|
|
1116
|
+
})
|
|
1117
|
+
})
|
|
1118
|
+
})
|
|
1119
|
+
|
|
1120
|
+
describe('pruneTombstones', () => {
|
|
1121
|
+
// Helper to create a snapshot with many tombstones
|
|
1122
|
+
function makeTombstoneMap(count: number, clockFn: (i: number) => number = (i) => i + 1) {
|
|
1123
|
+
const tombstones: Record<string, number> = {}
|
|
1124
|
+
for (let i = 0; i < count; i++) {
|
|
1125
|
+
tombstones[`doc${i}`] = clockFn(i)
|
|
1126
|
+
}
|
|
1127
|
+
return tombstones
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
it('does not prune when below MAX_TOMBSTONES', () => {
|
|
1131
|
+
const tombstoneCount = Math.floor(MAX_TOMBSTONES / 2)
|
|
1132
|
+
const storage = getStorage(
|
|
1133
|
+
makeSnapshot(defaultRecords, {
|
|
1134
|
+
documentClock: tombstoneCount + 1,
|
|
1135
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
1136
|
+
tombstones: makeTombstoneMap(tombstoneCount),
|
|
1137
|
+
})
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
const snapshotBefore = storage.getSnapshot()
|
|
1141
|
+
const initialCount = Object.keys(snapshotBefore.tombstones!).length
|
|
1142
|
+
const initialHistoryClock = snapshotBefore.tombstoneHistoryStartsAtClock
|
|
1143
|
+
|
|
1144
|
+
// Schedule the throttled function then force it to run
|
|
1145
|
+
storage.pruneTombstones()
|
|
1146
|
+
storage.pruneTombstones.flush()
|
|
1147
|
+
|
|
1148
|
+
const snapshotAfter = storage.getSnapshot()
|
|
1149
|
+
expect(Object.keys(snapshotAfter.tombstones!).length).toBe(initialCount)
|
|
1150
|
+
expect(snapshotAfter.tombstoneHistoryStartsAtClock).toBe(initialHistoryClock)
|
|
1151
|
+
})
|
|
1152
|
+
|
|
1153
|
+
it('prunes when exceeding MAX_TOMBSTONES', () => {
|
|
1154
|
+
const totalTombstones = MAX_TOMBSTONES + 500
|
|
1155
|
+
const storage = getStorage(
|
|
1156
|
+
makeSnapshot(defaultRecords, {
|
|
1157
|
+
documentClock: totalTombstones + 1,
|
|
1158
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
1159
|
+
tombstones: makeTombstoneMap(totalTombstones),
|
|
1160
|
+
})
|
|
1161
|
+
)
|
|
1162
|
+
|
|
1163
|
+
const snapshotBefore = storage.getSnapshot()
|
|
1164
|
+
expect(Object.keys(snapshotBefore.tombstones!).length).toBe(totalTombstones)
|
|
1165
|
+
|
|
1166
|
+
// Schedule the throttled function then force it to run
|
|
1167
|
+
storage.pruneTombstones()
|
|
1168
|
+
storage.pruneTombstones.flush()
|
|
1169
|
+
|
|
1170
|
+
const snapshotAfter = storage.getSnapshot()
|
|
1171
|
+
expect(Object.keys(snapshotAfter.tombstones!).length).toBeLessThan(totalTombstones)
|
|
1172
|
+
expect(Object.keys(snapshotAfter.tombstones!).length).toBeLessThanOrEqual(
|
|
1173
|
+
MAX_TOMBSTONES - TOMBSTONE_PRUNE_BUFFER_SIZE
|
|
1174
|
+
)
|
|
1175
|
+
expect(snapshotAfter.tombstoneHistoryStartsAtClock).toBeGreaterThan(0)
|
|
1176
|
+
})
|
|
1177
|
+
|
|
1178
|
+
it('updates tombstoneHistoryStartsAtClock correctly', () => {
|
|
1179
|
+
const totalTombstones = MAX_TOMBSTONES * 2
|
|
1180
|
+
const storage = getStorage(
|
|
1181
|
+
makeSnapshot(defaultRecords, {
|
|
1182
|
+
documentClock: totalTombstones + 1000,
|
|
1183
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
1184
|
+
tombstones: makeTombstoneMap(totalTombstones),
|
|
1185
|
+
})
|
|
1186
|
+
)
|
|
1187
|
+
|
|
1188
|
+
const snapshotBefore = storage.getSnapshot()
|
|
1189
|
+
const initialHistoryClock = snapshotBefore.tombstoneHistoryStartsAtClock
|
|
1190
|
+
|
|
1191
|
+
// Schedule the throttled function then force it to run
|
|
1192
|
+
storage.pruneTombstones()
|
|
1193
|
+
storage.pruneTombstones.flush()
|
|
1194
|
+
|
|
1195
|
+
const snapshotAfter = storage.getSnapshot()
|
|
1196
|
+
|
|
1197
|
+
// History clock should have advanced
|
|
1198
|
+
expect(snapshotAfter.tombstoneHistoryStartsAtClock).toBeGreaterThan(initialHistoryClock!)
|
|
1199
|
+
|
|
1200
|
+
// The algorithm deletes the OLDEST tombstones and keeps the NEWEST ones.
|
|
1201
|
+
// tombstoneHistoryStartsAtClock is set to the oldest REMAINING clock.
|
|
1202
|
+
// Remaining tombstones have clocks >= tombstoneHistoryStartsAtClock.
|
|
1203
|
+
const historyClock = snapshotAfter.tombstoneHistoryStartsAtClock
|
|
1204
|
+
for (const clock of Object.values(snapshotAfter.tombstones!)) {
|
|
1205
|
+
expect(clock).toBeGreaterThanOrEqual(historyClock!)
|
|
1206
|
+
}
|
|
1207
|
+
})
|
|
1208
|
+
|
|
1209
|
+
it('handles duplicate clock values across tombstones', () => {
|
|
1210
|
+
const totalTombstones = MAX_TOMBSTONES + 1
|
|
1211
|
+
const expectedCutoff = TOMBSTONE_PRUNE_BUFFER_SIZE + 1
|
|
1212
|
+
const overflow = 10
|
|
1213
|
+
const boundary = expectedCutoff + overflow
|
|
1214
|
+
const lowerClockVal = 1
|
|
1215
|
+
const upperClockVal = 2
|
|
1216
|
+
|
|
1217
|
+
const tombstones: Record<string, number> = {}
|
|
1218
|
+
for (let i = 0; i < totalTombstones; i++) {
|
|
1219
|
+
tombstones[`doc${i}`] = i < boundary ? lowerClockVal : upperClockVal
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const storage = getStorage(
|
|
1223
|
+
makeSnapshot(defaultRecords, {
|
|
1224
|
+
documentClock: 3,
|
|
1225
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
1226
|
+
tombstones,
|
|
1227
|
+
})
|
|
1228
|
+
)
|
|
1229
|
+
|
|
1230
|
+
// Schedule the throttled function then force it to run
|
|
1231
|
+
storage.pruneTombstones()
|
|
1232
|
+
storage.pruneTombstones.flush()
|
|
1233
|
+
|
|
1234
|
+
const snapshot = storage.getSnapshot()
|
|
1235
|
+
expect(Object.keys(snapshot.tombstones!).length).toEqual(
|
|
1236
|
+
MAX_TOMBSTONES - TOMBSTONE_PRUNE_BUFFER_SIZE - overflow
|
|
1237
|
+
)
|
|
1238
|
+
expect(Object.values(snapshot.tombstones!).every((clock) => clock === upperClockVal)).toBe(
|
|
1239
|
+
true
|
|
1240
|
+
)
|
|
1241
|
+
})
|
|
1242
|
+
|
|
1243
|
+
it('handles all tombstones with same clock value', () => {
|
|
1244
|
+
const totalTombstones = MAX_TOMBSTONES * 2
|
|
1245
|
+
const sameClock = 100
|
|
1246
|
+
|
|
1247
|
+
const storage = getStorage(
|
|
1248
|
+
makeSnapshot(defaultRecords, {
|
|
1249
|
+
documentClock: 1000,
|
|
1250
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
1251
|
+
tombstones: makeTombstoneMap(totalTombstones, () => sameClock),
|
|
1252
|
+
})
|
|
1253
|
+
)
|
|
1254
|
+
|
|
1255
|
+
// Schedule the throttled function then force it to run
|
|
1256
|
+
storage.pruneTombstones()
|
|
1257
|
+
storage.pruneTombstones.flush()
|
|
1258
|
+
|
|
1259
|
+
const snapshot = storage.getSnapshot()
|
|
1260
|
+
// When all have same clock, the cutoff extends to include all of them,
|
|
1261
|
+
// so all are pruned and history starts at documentClock
|
|
1262
|
+
expect(Object.keys(snapshot.tombstones!).length).toBe(0)
|
|
1263
|
+
expect(snapshot.tombstoneHistoryStartsAtClock).toBe(1000) // documentClock
|
|
1264
|
+
})
|
|
1265
|
+
|
|
1266
|
+
it('does not prune at exactly MAX_TOMBSTONES', () => {
|
|
1267
|
+
const storage = getStorage(
|
|
1268
|
+
makeSnapshot(defaultRecords, {
|
|
1269
|
+
documentClock: MAX_TOMBSTONES + 1,
|
|
1270
|
+
tombstoneHistoryStartsAtClock: 0,
|
|
1271
|
+
tombstones: makeTombstoneMap(MAX_TOMBSTONES),
|
|
1272
|
+
})
|
|
1273
|
+
)
|
|
1274
|
+
|
|
1275
|
+
// Schedule the throttled function then force it to run
|
|
1276
|
+
storage.pruneTombstones()
|
|
1277
|
+
storage.pruneTombstones.flush()
|
|
1278
|
+
|
|
1279
|
+
const snapshot = storage.getSnapshot()
|
|
1280
|
+
// Should not prune at exactly the threshold
|
|
1281
|
+
expect(Object.keys(snapshot.tombstones!).length).toBe(MAX_TOMBSTONES)
|
|
1282
|
+
})
|
|
1283
|
+
})
|
|
1284
|
+
|
|
1285
|
+
describe('Migration from TEXT to BLOB', () => {
|
|
1286
|
+
it('migrates existing TEXT data to BLOB format', () => {
|
|
1287
|
+
// Simulate a database created with the old schema (migration version 1, TEXT column)
|
|
1288
|
+
const db = new DatabaseSync(':memory:')
|
|
1289
|
+
|
|
1290
|
+
// Create old schema with TEXT column (migration version 1)
|
|
1291
|
+
db.exec(`
|
|
1292
|
+
CREATE TABLE documents (
|
|
1293
|
+
id TEXT PRIMARY KEY,
|
|
1294
|
+
state TEXT NOT NULL,
|
|
1295
|
+
lastChangedClock INTEGER NOT NULL
|
|
1296
|
+
);
|
|
1297
|
+
CREATE INDEX idx_documents_lastChangedClock ON documents(lastChangedClock);
|
|
1298
|
+
CREATE TABLE tombstones (
|
|
1299
|
+
id TEXT PRIMARY KEY,
|
|
1300
|
+
clock INTEGER NOT NULL
|
|
1301
|
+
);
|
|
1302
|
+
CREATE INDEX idx_tombstones_clock ON tombstones(clock);
|
|
1303
|
+
CREATE TABLE metadata (
|
|
1304
|
+
migrationVersion INTEGER NOT NULL,
|
|
1305
|
+
documentClock INTEGER NOT NULL,
|
|
1306
|
+
tombstoneHistoryStartsAtClock INTEGER NOT NULL,
|
|
1307
|
+
schema TEXT NOT NULL
|
|
1308
|
+
);
|
|
1309
|
+
INSERT INTO metadata (migrationVersion, documentClock, tombstoneHistoryStartsAtClock, schema)
|
|
1310
|
+
VALUES (1, 5, 0, '${JSON.stringify(tlSchema.serialize()).replace(/'/g, "''")}');
|
|
1311
|
+
`)
|
|
1312
|
+
|
|
1313
|
+
// Insert documents using the old TEXT format
|
|
1314
|
+
const doc1 = DocumentRecordType.create({ id: TLDOCUMENT_ID })
|
|
1315
|
+
const page1 = PageRecordType.create({
|
|
1316
|
+
id: PageRecordType.createId('migrated_page'),
|
|
1317
|
+
name: 'Migrated Page',
|
|
1318
|
+
index: ZERO_INDEX_KEY,
|
|
1319
|
+
})
|
|
1320
|
+
|
|
1321
|
+
const insertStmt = db.prepare(
|
|
1322
|
+
'INSERT INTO documents (id, state, lastChangedClock) VALUES (?, ?, ?)'
|
|
1323
|
+
)
|
|
1324
|
+
insertStmt.run(doc1.id, JSON.stringify(doc1), 1)
|
|
1325
|
+
insertStmt.run(page1.id, JSON.stringify(page1), 2)
|
|
1326
|
+
|
|
1327
|
+
// Add a tombstone
|
|
1328
|
+
db.exec("INSERT INTO tombstones (id, clock) VALUES ('shape:deleted', 3)")
|
|
1329
|
+
|
|
1330
|
+
// Now create SQLiteSyncStorage which should trigger the migration
|
|
1331
|
+
const sql = new NodeSqliteWrapper(db)
|
|
1332
|
+
const storage = new SQLiteSyncStorage<TLRecord>({ sql })
|
|
1333
|
+
|
|
1334
|
+
// Verify data is accessible after migration
|
|
1335
|
+
const snapshot = storage.getSnapshot()
|
|
1336
|
+
expect(snapshot.documents.length).toBe(2)
|
|
1337
|
+
expect(snapshot.documentClock).toBe(5)
|
|
1338
|
+
expect(snapshot.tombstones?.['shape:deleted']).toBe(3)
|
|
1339
|
+
|
|
1340
|
+
// Verify we can read specific records
|
|
1341
|
+
storage.transaction((txn) => {
|
|
1342
|
+
const doc = txn.get(TLDOCUMENT_ID)
|
|
1343
|
+
expect(doc).toBeDefined()
|
|
1344
|
+
expect(doc?.id).toBe(TLDOCUMENT_ID)
|
|
1345
|
+
|
|
1346
|
+
const page = txn.get(page1.id)
|
|
1347
|
+
expect(page).toBeDefined()
|
|
1348
|
+
expect((page as any)?.name).toBe('Migrated Page')
|
|
1349
|
+
})
|
|
1350
|
+
|
|
1351
|
+
// Verify we can still write new records
|
|
1352
|
+
const newPage = PageRecordType.create({
|
|
1353
|
+
id: PageRecordType.createId('new_after_migration'),
|
|
1354
|
+
name: 'New After Migration',
|
|
1355
|
+
index: 'a2' as IndexKey,
|
|
1356
|
+
})
|
|
1357
|
+
|
|
1358
|
+
storage.transaction((txn) => {
|
|
1359
|
+
txn.set(newPage.id, newPage)
|
|
1360
|
+
})
|
|
1361
|
+
|
|
1362
|
+
const snapshotAfter = storage.getSnapshot()
|
|
1363
|
+
expect(snapshotAfter.documents.length).toBe(3)
|
|
1364
|
+
expect(snapshotAfter.documents.find((d) => d.state.id === newPage.id)).toBeDefined()
|
|
1365
|
+
})
|
|
1366
|
+
|
|
1367
|
+
it('preserves migration version 2 for fresh databases', () => {
|
|
1368
|
+
const sql = createWrapper()
|
|
1369
|
+
new SQLiteSyncStorage<TLRecord>({ sql, snapshot: makeSnapshot(defaultRecords) })
|
|
1370
|
+
|
|
1371
|
+
// Check the migration version is 2
|
|
1372
|
+
const row = sql
|
|
1373
|
+
.prepare<{ migrationVersion: number }>('SELECT migrationVersion FROM metadata')
|
|
1374
|
+
.all()[0]
|
|
1375
|
+
expect(row?.migrationVersion).toBe(2)
|
|
1376
|
+
})
|
|
1377
|
+
})
|
|
1378
|
+
})
|