@powersync/service-core 0.0.0-dev-20250122110924 → 0.0.0-dev-20250227082606

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