@powersync/service-core 0.18.1 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/api/RouteAPI.d.ts +1 -1
  3. package/dist/api/diagnostics.js +107 -169
  4. package/dist/api/diagnostics.js.map +1 -1
  5. package/dist/entry/commands/compact-action.js +10 -73
  6. package/dist/entry/commands/compact-action.js.map +1 -1
  7. package/dist/modules/AbstractModule.d.ts +1 -1
  8. package/dist/replication/AbstractReplicator.js +8 -76
  9. package/dist/replication/AbstractReplicator.js.map +1 -1
  10. package/dist/routes/endpoints/checkpointing.js +3 -2
  11. package/dist/routes/endpoints/checkpointing.js.map +1 -1
  12. package/dist/routes/endpoints/socket-route.js +5 -5
  13. package/dist/routes/endpoints/socket-route.js.map +1 -1
  14. package/dist/routes/endpoints/sync-stream.js +5 -5
  15. package/dist/routes/endpoints/sync-stream.js.map +1 -1
  16. package/dist/runner/teardown.js +3 -65
  17. package/dist/runner/teardown.js.map +1 -1
  18. package/dist/storage/BucketStorage.d.ts +8 -441
  19. package/dist/storage/BucketStorage.js +9 -10
  20. package/dist/storage/BucketStorage.js.map +1 -1
  21. package/dist/storage/BucketStorageBatch.d.ts +130 -0
  22. package/dist/storage/BucketStorageBatch.js +10 -0
  23. package/dist/storage/BucketStorageBatch.js.map +1 -0
  24. package/dist/storage/BucketStorageFactory.d.ts +145 -0
  25. package/dist/storage/BucketStorageFactory.js +2 -0
  26. package/dist/storage/BucketStorageFactory.js.map +1 -0
  27. package/dist/storage/ChecksumCache.js.map +1 -1
  28. package/dist/storage/PersistedSyncRulesContent.d.ts +20 -0
  29. package/dist/storage/PersistedSyncRulesContent.js +2 -0
  30. package/dist/storage/PersistedSyncRulesContent.js.map +1 -0
  31. package/dist/storage/ReplicationEventPayload.d.ts +1 -1
  32. package/dist/storage/ReplicationLock.d.ts +4 -0
  33. package/dist/storage/ReplicationLock.js +2 -0
  34. package/dist/storage/ReplicationLock.js.map +1 -0
  35. package/dist/storage/SourceEntity.d.ts +6 -2
  36. package/dist/storage/SourceTable.d.ts +2 -2
  37. package/dist/storage/SourceTable.js.map +1 -1
  38. package/dist/storage/StorageEngine.d.ts +4 -4
  39. package/dist/storage/StorageEngine.js +2 -2
  40. package/dist/storage/StorageEngine.js.map +1 -1
  41. package/dist/storage/StorageProvider.d.ts +4 -1
  42. package/dist/storage/SyncRulesBucketStorage.d.ts +207 -0
  43. package/dist/storage/SyncRulesBucketStorage.js +7 -0
  44. package/dist/storage/SyncRulesBucketStorage.js.map +1 -0
  45. package/dist/storage/bson.d.ts +14 -3
  46. package/dist/storage/bson.js +18 -2
  47. package/dist/storage/bson.js.map +1 -1
  48. package/dist/storage/storage-index.d.ts +5 -0
  49. package/dist/storage/storage-index.js +5 -0
  50. package/dist/storage/storage-index.js.map +1 -1
  51. package/dist/sync/BucketChecksumState.d.ts +91 -0
  52. package/dist/sync/BucketChecksumState.js +313 -0
  53. package/dist/sync/BucketChecksumState.js.map +1 -0
  54. package/dist/sync/sync-index.d.ts +1 -0
  55. package/dist/sync/sync-index.js +1 -0
  56. package/dist/sync/sync-index.js.map +1 -1
  57. package/dist/sync/sync.d.ts +7 -3
  58. package/dist/sync/sync.js +139 -135
  59. package/dist/sync/sync.js.map +1 -1
  60. package/dist/sync/util.d.ts +9 -0
  61. package/dist/sync/util.js +44 -0
  62. package/dist/sync/util.js.map +1 -1
  63. package/dist/util/checkpointing.d.ts +1 -1
  64. package/dist/util/checkpointing.js +15 -78
  65. package/dist/util/checkpointing.js.map +1 -1
  66. package/dist/util/protocol-types.d.ts +13 -4
  67. package/package.json +5 -5
  68. package/src/api/RouteAPI.ts +1 -1
  69. package/src/api/diagnostics.ts +1 -1
  70. package/src/entry/commands/compact-action.ts +2 -3
  71. package/src/modules/AbstractModule.ts +1 -1
  72. package/src/replication/AbstractReplicator.ts +7 -12
  73. package/src/routes/endpoints/checkpointing.ts +3 -3
  74. package/src/routes/endpoints/socket-route.ts +7 -5
  75. package/src/routes/endpoints/sync-stream.ts +8 -5
  76. package/src/runner/teardown.ts +1 -1
  77. package/src/storage/BucketStorage.ts +8 -550
  78. package/src/storage/BucketStorageBatch.ts +158 -0
  79. package/src/storage/BucketStorageFactory.ts +166 -0
  80. package/src/storage/ChecksumCache.ts +1 -0
  81. package/src/storage/PersistedSyncRulesContent.ts +26 -0
  82. package/src/storage/ReplicationEventPayload.ts +1 -1
  83. package/src/storage/ReplicationLock.ts +5 -0
  84. package/src/storage/SourceEntity.ts +6 -2
  85. package/src/storage/SourceTable.ts +1 -1
  86. package/src/storage/StorageEngine.ts +4 -4
  87. package/src/storage/StorageProvider.ts +4 -1
  88. package/src/storage/SyncRulesBucketStorage.ts +265 -0
  89. package/src/storage/bson.ts +22 -4
  90. package/src/storage/storage-index.ts +5 -0
  91. package/src/sync/BucketChecksumState.ts +392 -0
  92. package/src/sync/sync-index.ts +1 -0
  93. package/src/sync/sync.ts +182 -157
  94. package/src/sync/util.ts +54 -0
  95. package/src/util/checkpointing.ts +4 -6
  96. package/src/util/protocol-types.ts +16 -4
  97. package/test/src/auth.test.ts +5 -5
  98. package/test/src/sync/BucketChecksumState.test.ts +565 -0
  99. package/test/src/sync/util.test.ts +34 -0
  100. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,565 @@
1
+ import {
2
+ BucketChecksum,
3
+ BucketChecksumState,
4
+ BucketChecksumStateStorage,
5
+ CHECKPOINT_INVALIDATE_ALL,
6
+ ChecksumMap,
7
+ OpId,
8
+ WatchFilterEvent
9
+ } from '@/index.js';
10
+ import { RequestParameters, SqliteJsonRow, SqliteJsonValue, SqlSyncRules } from '@powersync/service-sync-rules';
11
+ import { describe, expect, test } from 'vitest';
12
+
13
+ describe('BucketChecksumState', () => {
14
+ // Single global[] bucket.
15
+ // We don't care about data in these tests
16
+ const SYNC_RULES_GLOBAL = SqlSyncRules.fromYaml(
17
+ `
18
+ bucket_definitions:
19
+ global:
20
+ data: []
21
+ `,
22
+ { defaultSchema: 'public' }
23
+ );
24
+
25
+ // global[1] and global[2]
26
+ const SYNC_RULES_GLOBAL_TWO = SqlSyncRules.fromYaml(
27
+ `
28
+ bucket_definitions:
29
+ global:
30
+ parameters:
31
+ - select 1 as id
32
+ - select 2 as id
33
+ data: []
34
+ `,
35
+ { defaultSchema: 'public' }
36
+ );
37
+
38
+ // by_project[n]
39
+ const SYNC_RULES_DYNAMIC = SqlSyncRules.fromYaml(
40
+ `
41
+ bucket_definitions:
42
+ by_project:
43
+ parameters: select id from projects where user_id = request.user_id()
44
+ data: []
45
+ `,
46
+ { defaultSchema: 'public' }
47
+ );
48
+
49
+ test('global bucket with update', async () => {
50
+ const storage = new MockBucketChecksumStateStorage();
51
+ // Set intial state
52
+ storage.updateTestChecksum({ bucket: 'global[]', checksum: 1, count: 1 });
53
+
54
+ const state = new BucketChecksumState({
55
+ syncParams: new RequestParameters({ sub: '' }, {}),
56
+ syncRules: SYNC_RULES_GLOBAL,
57
+ bucketStorage: storage
58
+ });
59
+
60
+ const line = (await state.buildNextCheckpointLine({
61
+ base: { checkpoint: '1', lsn: '1' },
62
+ writeCheckpoint: null,
63
+ update: CHECKPOINT_INVALIDATE_ALL
64
+ }))!;
65
+ expect(line.checkpointLine).toEqual({
66
+ checkpoint: {
67
+ buckets: [{ bucket: 'global[]', checksum: 1, count: 1, priority: 3 }],
68
+ last_op_id: '1',
69
+ write_checkpoint: undefined
70
+ }
71
+ });
72
+ expect(line.bucketsToFetch).toEqual([
73
+ {
74
+ bucket: 'global[]',
75
+ priority: 3
76
+ }
77
+ ]);
78
+ // This is the bucket data to be fetched
79
+ expect(state.getFilteredBucketPositions(line.bucketsToFetch)).toEqual(new Map([['global[]', '0']]));
80
+
81
+ // This similuates the bucket data being sent
82
+ state.updateBucketPosition({ bucket: 'global[]', nextAfter: '1', hasMore: false });
83
+
84
+ // Update bucket storage state
85
+ storage.updateTestChecksum({ bucket: 'global[]', checksum: 2, count: 2 });
86
+
87
+ // Now we get a new line
88
+ const line2 = (await state.buildNextCheckpointLine({
89
+ base: { checkpoint: '2', lsn: '2' },
90
+ writeCheckpoint: null,
91
+ update: {
92
+ updatedDataBuckets: ['global[]'],
93
+ invalidateDataBuckets: false,
94
+ updatedParameterBucketDefinitions: [],
95
+ invalidateParameterBuckets: false
96
+ }
97
+ }))!;
98
+ expect(line2.checkpointLine).toEqual({
99
+ checkpoint_diff: {
100
+ removed_buckets: [],
101
+ updated_buckets: [{ bucket: 'global[]', checksum: 2, count: 2, priority: 3 }],
102
+ last_op_id: '2',
103
+ write_checkpoint: undefined
104
+ }
105
+ });
106
+ expect(state.getFilteredBucketPositions(line2.bucketsToFetch)).toEqual(new Map([['global[]', '1']]));
107
+ });
108
+
109
+ test('global bucket with initial state', async () => {
110
+ // This tests the client sending an initial state
111
+ // This does not affect the checkpoint, but does affect the data to be fetched
112
+ /// (getFilteredBucketStates)
113
+ const storage = new MockBucketChecksumStateStorage();
114
+ // Set intial state
115
+ storage.updateTestChecksum({ bucket: 'global[]', checksum: 1, count: 1 });
116
+
117
+ const state = new BucketChecksumState({
118
+ // Client sets the initial state here
119
+ initialBucketPositions: [{ name: 'global[]', after: '1' }],
120
+ syncParams: new RequestParameters({ sub: '' }, {}),
121
+ syncRules: SYNC_RULES_GLOBAL,
122
+ bucketStorage: storage
123
+ });
124
+
125
+ const line = (await state.buildNextCheckpointLine({
126
+ base: { checkpoint: '1', lsn: '1' },
127
+ writeCheckpoint: null,
128
+ update: CHECKPOINT_INVALIDATE_ALL
129
+ }))!;
130
+ expect(line.checkpointLine).toEqual({
131
+ checkpoint: {
132
+ buckets: [{ bucket: 'global[]', checksum: 1, count: 1, priority: 3 }],
133
+ last_op_id: '1',
134
+ write_checkpoint: undefined
135
+ }
136
+ });
137
+ expect(line.bucketsToFetch).toEqual([
138
+ {
139
+ bucket: 'global[]',
140
+ priority: 3
141
+ }
142
+ ]);
143
+ // This is the main difference between this and the previous test
144
+ expect(state.getFilteredBucketPositions(line.bucketsToFetch)).toEqual(new Map([['global[]', '1']]));
145
+ });
146
+
147
+ test('multiple static buckets', async () => {
148
+ const storage = new MockBucketChecksumStateStorage();
149
+ // Set intial state
150
+ storage.updateTestChecksum({ bucket: 'global[1]', checksum: 1, count: 1 });
151
+ storage.updateTestChecksum({ bucket: 'global[2]', checksum: 1, count: 1 });
152
+
153
+ const state = new BucketChecksumState({
154
+ syncParams: new RequestParameters({ sub: '' }, {}),
155
+ syncRules: SYNC_RULES_GLOBAL_TWO,
156
+ bucketStorage: storage
157
+ });
158
+
159
+ const line = (await state.buildNextCheckpointLine({
160
+ base: { checkpoint: '1', lsn: '1' },
161
+ writeCheckpoint: null,
162
+ update: CHECKPOINT_INVALIDATE_ALL
163
+ }))!;
164
+ expect(line.checkpointLine).toEqual({
165
+ checkpoint: {
166
+ buckets: [
167
+ { bucket: 'global[1]', checksum: 1, count: 1, priority: 3 },
168
+ { bucket: 'global[2]', checksum: 1, count: 1, priority: 3 }
169
+ ],
170
+ last_op_id: '1',
171
+ write_checkpoint: undefined
172
+ }
173
+ });
174
+ expect(line.bucketsToFetch).toEqual([
175
+ {
176
+ bucket: 'global[1]',
177
+ priority: 3
178
+ },
179
+ {
180
+ bucket: 'global[2]',
181
+ priority: 3
182
+ }
183
+ ]);
184
+
185
+ storage.updateTestChecksum({ bucket: 'global[1]', checksum: 2, count: 2 });
186
+ storage.updateTestChecksum({ bucket: 'global[2]', checksum: 2, count: 2 });
187
+
188
+ const line2 = (await state.buildNextCheckpointLine({
189
+ base: { checkpoint: '2', lsn: '2' },
190
+ writeCheckpoint: null,
191
+ update: {
192
+ ...CHECKPOINT_INVALIDATE_ALL,
193
+ updatedDataBuckets: ['global[1]', 'global[2]'],
194
+ invalidateDataBuckets: false
195
+ }
196
+ }))!;
197
+ expect(line2.checkpointLine).toEqual({
198
+ checkpoint_diff: {
199
+ removed_buckets: [],
200
+ updated_buckets: [
201
+ { bucket: 'global[1]', checksum: 2, count: 2, priority: 3 },
202
+ { bucket: 'global[2]', checksum: 2, count: 2, priority: 3 }
203
+ ],
204
+ last_op_id: '2',
205
+ write_checkpoint: undefined
206
+ }
207
+ });
208
+ });
209
+
210
+ test('removing a static bucket', async () => {
211
+ // This tests the client sending an initial state, with a bucket that we don't have.
212
+ // This makes effectively no difference to the output. By not including the bucket
213
+ // in the output, the client will remove the bucket.
214
+ const storage = new MockBucketChecksumStateStorage();
215
+
216
+ const state = new BucketChecksumState({
217
+ // Client sets the initial state here
218
+ initialBucketPositions: [{ name: 'something_here[]', after: '1' }],
219
+ syncParams: new RequestParameters({ sub: '' }, {}),
220
+ syncRules: SYNC_RULES_GLOBAL,
221
+ bucketStorage: storage
222
+ });
223
+
224
+ storage.updateTestChecksum({ bucket: 'global[]', checksum: 1, count: 1 });
225
+
226
+ const line = (await state.buildNextCheckpointLine({
227
+ base: { checkpoint: '1', lsn: '1' },
228
+ writeCheckpoint: null,
229
+ update: CHECKPOINT_INVALIDATE_ALL
230
+ }))!;
231
+ expect(line.checkpointLine).toEqual({
232
+ checkpoint: {
233
+ buckets: [{ bucket: 'global[]', checksum: 1, count: 1, priority: 3 }],
234
+ last_op_id: '1',
235
+ write_checkpoint: undefined
236
+ }
237
+ });
238
+ expect(line.bucketsToFetch).toEqual([
239
+ {
240
+ bucket: 'global[]',
241
+ priority: 3
242
+ }
243
+ ]);
244
+ expect(state.getFilteredBucketPositions(line.bucketsToFetch)).toEqual(new Map([['global[]', '0']]));
245
+ });
246
+
247
+ test('invalidating individual bucket', async () => {
248
+ // We manually control the filter events here.
249
+
250
+ const storage = new MockBucketChecksumStateStorage();
251
+ // Set initial state
252
+ storage.updateTestChecksum({ bucket: 'global[1]', checksum: 1, count: 1 });
253
+ storage.updateTestChecksum({ bucket: 'global[2]', checksum: 1, count: 1 });
254
+
255
+ const state = new BucketChecksumState({
256
+ syncParams: new RequestParameters({ sub: '' }, {}),
257
+ syncRules: SYNC_RULES_GLOBAL_TWO,
258
+ bucketStorage: storage
259
+ });
260
+
261
+ // We specifically do not set this here, so that we have manual control over the events.
262
+ // storage.filter = state.checkpointFilter;
263
+
264
+ await state.buildNextCheckpointLine({
265
+ base: { checkpoint: '1', lsn: '1' },
266
+ writeCheckpoint: null,
267
+ update: CHECKPOINT_INVALIDATE_ALL
268
+ });
269
+
270
+ state.updateBucketPosition({ bucket: 'global[1]', nextAfter: '1', hasMore: false });
271
+ state.updateBucketPosition({ bucket: 'global[2]', nextAfter: '1', hasMore: false });
272
+
273
+ storage.updateTestChecksum({ bucket: 'global[1]', checksum: 2, count: 2 });
274
+ storage.updateTestChecksum({ bucket: 'global[2]', checksum: 2, count: 2 });
275
+
276
+ const line2 = (await state.buildNextCheckpointLine({
277
+ base: { checkpoint: '2', lsn: '2' },
278
+ writeCheckpoint: null,
279
+ update: {
280
+ ...CHECKPOINT_INVALIDATE_ALL,
281
+ // Invalidate the state for global[1] - will only re-check the single bucket.
282
+ // This is essentially inconsistent state, but is the simplest way to test that
283
+ // the filter is working.
284
+ updatedDataBuckets: ['global[1]'],
285
+ invalidateDataBuckets: false
286
+ }
287
+ }))!;
288
+ expect(line2.checkpointLine).toEqual({
289
+ checkpoint_diff: {
290
+ removed_buckets: [],
291
+ updated_buckets: [
292
+ // This does not include global[2], since it was not invalidated.
293
+ { bucket: 'global[1]', checksum: 2, count: 2, priority: 3 }
294
+ ],
295
+ last_op_id: '2',
296
+ write_checkpoint: undefined
297
+ }
298
+ });
299
+ expect(line2.bucketsToFetch).toEqual([{ bucket: 'global[1]', priority: 3 }]);
300
+ });
301
+
302
+ test('invalidating all buckets', async () => {
303
+ // We manually control the filter events here.
304
+ const storage = new MockBucketChecksumStateStorage();
305
+
306
+ const state = new BucketChecksumState({
307
+ syncParams: new RequestParameters({ sub: '' }, {}),
308
+ syncRules: SYNC_RULES_GLOBAL_TWO,
309
+ bucketStorage: storage
310
+ });
311
+
312
+ // We specifically do not set this here, so that we have manual control over the events.
313
+ // storage.filter = state.checkpointFilter;
314
+
315
+ // Set initial state
316
+ storage.updateTestChecksum({ bucket: 'global[1]', checksum: 1, count: 1 });
317
+ storage.updateTestChecksum({ bucket: 'global[2]', checksum: 1, count: 1 });
318
+
319
+ await state.buildNextCheckpointLine({
320
+ base: { checkpoint: '1', lsn: '1' },
321
+ writeCheckpoint: null,
322
+ update: CHECKPOINT_INVALIDATE_ALL
323
+ });
324
+
325
+ storage.updateTestChecksum({ bucket: 'global[1]', checksum: 2, count: 2 });
326
+ storage.updateTestChecksum({ bucket: 'global[2]', checksum: 2, count: 2 });
327
+
328
+ const line2 = (await state.buildNextCheckpointLine({
329
+ base: { checkpoint: '2', lsn: '2' },
330
+ writeCheckpoint: null,
331
+ // Invalidate the state - will re-check all buckets
332
+ update: CHECKPOINT_INVALIDATE_ALL
333
+ }))!;
334
+ expect(line2.checkpointLine).toEqual({
335
+ checkpoint_diff: {
336
+ removed_buckets: [],
337
+ updated_buckets: [
338
+ { bucket: 'global[1]', checksum: 2, count: 2, priority: 3 },
339
+ { bucket: 'global[2]', checksum: 2, count: 2, priority: 3 }
340
+ ],
341
+ last_op_id: '2',
342
+ write_checkpoint: undefined
343
+ }
344
+ });
345
+ expect(line2.bucketsToFetch).toEqual([
346
+ { bucket: 'global[1]', priority: 3 },
347
+ { bucket: 'global[2]', priority: 3 }
348
+ ]);
349
+ });
350
+
351
+ test('interrupt and resume static buckets checkpoint', async () => {
352
+ const storage = new MockBucketChecksumStateStorage();
353
+ // Set intial state
354
+ storage.updateTestChecksum({ bucket: 'global[1]', checksum: 3, count: 3 });
355
+ storage.updateTestChecksum({ bucket: 'global[2]', checksum: 3, count: 3 });
356
+
357
+ const state = new BucketChecksumState({
358
+ syncParams: new RequestParameters({ sub: '' }, {}),
359
+ syncRules: SYNC_RULES_GLOBAL_TWO,
360
+ bucketStorage: storage
361
+ });
362
+
363
+ const line = (await state.buildNextCheckpointLine({
364
+ base: { checkpoint: '3', lsn: '3' },
365
+ writeCheckpoint: null,
366
+ update: CHECKPOINT_INVALIDATE_ALL
367
+ }))!;
368
+ expect(line.checkpointLine).toEqual({
369
+ checkpoint: {
370
+ buckets: [
371
+ { bucket: 'global[1]', checksum: 3, count: 3, priority: 3 },
372
+ { bucket: 'global[2]', checksum: 3, count: 3, priority: 3 }
373
+ ],
374
+ last_op_id: '3',
375
+ write_checkpoint: undefined
376
+ }
377
+ });
378
+ expect(line.bucketsToFetch).toEqual([
379
+ {
380
+ bucket: 'global[1]',
381
+ priority: 3
382
+ },
383
+ {
384
+ bucket: 'global[2]',
385
+ priority: 3
386
+ }
387
+ ]);
388
+
389
+ // This is the bucket data to be fetched
390
+ expect(state.getFilteredBucketPositions(line.bucketsToFetch)).toEqual(
391
+ new Map([
392
+ ['global[1]', '0'],
393
+ ['global[2]', '0']
394
+ ])
395
+ );
396
+
397
+ // No data changes here.
398
+ // We simulate partial data sent, before a checkpoint is interrupted.
399
+ state.updateBucketPosition({ bucket: 'global[1]', nextAfter: '3', hasMore: false });
400
+ state.updateBucketPosition({ bucket: 'global[2]', nextAfter: '1', hasMore: true });
401
+ storage.updateTestChecksum({ bucket: 'global[1]', checksum: 4, count: 4 });
402
+
403
+ const line2 = (await state.buildNextCheckpointLine({
404
+ base: { checkpoint: '4', lsn: '4' },
405
+ writeCheckpoint: null,
406
+ update: {
407
+ ...CHECKPOINT_INVALIDATE_ALL,
408
+ invalidateDataBuckets: false,
409
+ updatedDataBuckets: ['global[1]']
410
+ }
411
+ }))!;
412
+ expect(line2.checkpointLine).toEqual({
413
+ checkpoint_diff: {
414
+ removed_buckets: [],
415
+ updated_buckets: [
416
+ {
417
+ bucket: 'global[1]',
418
+ checksum: 4,
419
+ count: 4,
420
+ priority: 3
421
+ }
422
+ ],
423
+ last_op_id: '4',
424
+ write_checkpoint: undefined
425
+ }
426
+ });
427
+ // This should contain both buckets, even though only one changed.
428
+ expect(line2.bucketsToFetch).toEqual([
429
+ {
430
+ bucket: 'global[1]',
431
+ priority: 3
432
+ },
433
+ {
434
+ bucket: 'global[2]',
435
+ priority: 3
436
+ }
437
+ ]);
438
+
439
+ expect(state.getFilteredBucketPositions(line2.bucketsToFetch)).toEqual(
440
+ new Map([
441
+ ['global[1]', '3'],
442
+ ['global[2]', '1']
443
+ ])
444
+ );
445
+ });
446
+
447
+ test('dynamic buckets with updates', async () => {
448
+ const storage = new MockBucketChecksumStateStorage();
449
+ // Set intial state
450
+ storage.updateTestChecksum({ bucket: 'by_project[1]', checksum: 1, count: 1 });
451
+ storage.updateTestChecksum({ bucket: 'by_project[2]', checksum: 1, count: 1 });
452
+ storage.updateTestChecksum({ bucket: 'by_project[3]', checksum: 1, count: 1 });
453
+
454
+ const state = new BucketChecksumState({
455
+ syncParams: new RequestParameters({ sub: 'u1' }, {}),
456
+ syncRules: SYNC_RULES_DYNAMIC,
457
+ bucketStorage: storage
458
+ });
459
+
460
+ storage.getParameterSets = async (checkpoint: OpId, lookups: SqliteJsonValue[][]): Promise<SqliteJsonRow[]> => {
461
+ expect(checkpoint).toEqual('1');
462
+ expect(lookups).toEqual([['by_project', '1', 'u1']]);
463
+ return [{ id: 1 }, { id: 2 }];
464
+ };
465
+
466
+ const line = (await state.buildNextCheckpointLine({
467
+ base: { checkpoint: '1', lsn: '1' },
468
+ writeCheckpoint: null,
469
+ update: CHECKPOINT_INVALIDATE_ALL
470
+ }))!;
471
+ expect(line.checkpointLine).toEqual({
472
+ checkpoint: {
473
+ buckets: [
474
+ { bucket: 'by_project[1]', checksum: 1, count: 1, priority: 3 },
475
+ { bucket: 'by_project[2]', checksum: 1, count: 1, priority: 3 }
476
+ ],
477
+ last_op_id: '1',
478
+ write_checkpoint: undefined
479
+ }
480
+ });
481
+ expect(line.bucketsToFetch).toEqual([
482
+ {
483
+ bucket: 'by_project[1]',
484
+ priority: 3
485
+ },
486
+ {
487
+ bucket: 'by_project[2]',
488
+ priority: 3
489
+ }
490
+ ]);
491
+ // This is the bucket data to be fetched
492
+ expect(state.getFilteredBucketPositions(line.bucketsToFetch)).toEqual(
493
+ new Map([
494
+ ['by_project[1]', '0'],
495
+ ['by_project[2]', '0']
496
+ ])
497
+ );
498
+
499
+ state.updateBucketPosition({ bucket: 'by_project[1]', nextAfter: '1', hasMore: false });
500
+ state.updateBucketPosition({ bucket: 'by_project[2]', nextAfter: '1', hasMore: false });
501
+
502
+ storage.getParameterSets = async (checkpoint: OpId, lookups: SqliteJsonValue[][]): Promise<SqliteJsonRow[]> => {
503
+ expect(checkpoint).toEqual('2');
504
+ expect(lookups).toEqual([['by_project', '1', 'u1']]);
505
+ return [{ id: 1 }, { id: 2 }, { id: 3 }];
506
+ };
507
+
508
+ // Now we get a new line
509
+ const line2 = (await state.buildNextCheckpointLine({
510
+ base: { checkpoint: '2', lsn: '2' },
511
+ writeCheckpoint: null,
512
+ update: {
513
+ invalidateDataBuckets: false,
514
+ updatedDataBuckets: [],
515
+ updatedParameterBucketDefinitions: ['by_project'],
516
+ invalidateParameterBuckets: false
517
+ }
518
+ }))!;
519
+ expect(line2.checkpointLine).toEqual({
520
+ checkpoint_diff: {
521
+ removed_buckets: [],
522
+ updated_buckets: [{ bucket: 'by_project[3]', checksum: 1, count: 1, priority: 3 }],
523
+ last_op_id: '2',
524
+ write_checkpoint: undefined
525
+ }
526
+ });
527
+ expect(state.getFilteredBucketPositions(line2.bucketsToFetch)).toEqual(new Map([['by_project[3]', '0']]));
528
+ });
529
+ });
530
+
531
+ class MockBucketChecksumStateStorage implements BucketChecksumStateStorage {
532
+ private state: ChecksumMap = new Map();
533
+ public filter?: (event: WatchFilterEvent) => boolean;
534
+
535
+ constructor() {}
536
+
537
+ updateTestChecksum(checksum: BucketChecksum): void {
538
+ this.state.set(checksum.bucket, checksum);
539
+ this.filter?.({ changedDataBucket: checksum.bucket });
540
+ }
541
+
542
+ invalidate() {
543
+ this.filter?.({ invalidate: true });
544
+ }
545
+
546
+ async getChecksums(checkpoint: OpId, buckets: string[]): Promise<ChecksumMap> {
547
+ return new Map<string, BucketChecksum>(
548
+ buckets.map((bucket) => {
549
+ const checksum = this.state.get(bucket);
550
+ return [
551
+ bucket,
552
+ {
553
+ bucket: bucket,
554
+ checksum: checksum?.checksum ?? 0,
555
+ count: checksum?.count ?? 0
556
+ }
557
+ ];
558
+ })
559
+ );
560
+ }
561
+
562
+ async getParameterSets(checkpoint: OpId, lookups: SqliteJsonValue[][]): Promise<SqliteJsonRow[]> {
563
+ throw new Error('Method not implemented.');
564
+ }
565
+ }
@@ -0,0 +1,34 @@
1
+ import { acquireSemaphoreAbortable } from '@/index.js';
2
+ import { Semaphore, SemaphoreInterface } from 'async-mutex';
3
+ import { describe, expect, test, vi } from 'vitest';
4
+
5
+ describe('acquireSemaphoreAbortable', () => {
6
+ test('can acquire', async () => {
7
+ const semaphore = new Semaphore(1);
8
+ const controller = new AbortController();
9
+
10
+ expect(await acquireSemaphoreAbortable(semaphore, controller.signal)).not.toBe('aborted');
11
+ });
12
+
13
+ test('can cancel', async () => {
14
+ const semaphore = new Semaphore(1);
15
+ const controller = new AbortController();
16
+
17
+ const resolve = vi.fn();
18
+ const reject = vi.fn();
19
+
20
+ // First invocation: Lock the semaphore
21
+ const result = await acquireSemaphoreAbortable(semaphore, controller.signal);
22
+ expect(result).not.toBe('aborted');
23
+ const [count, release] = result as [number, SemaphoreInterface.Releaser];
24
+
25
+ acquireSemaphoreAbortable(semaphore, controller.signal).then(resolve, reject);
26
+ controller.abort();
27
+ await Promise.resolve();
28
+ expect(reject).not.toHaveBeenCalled();
29
+ expect(resolve).toHaveBeenCalledWith('aborted');
30
+
31
+ // Releasing the semaphore should not invoke resolve again
32
+ release();
33
+ });
34
+ });