@powersync/service-core 0.2.0 → 0.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.
@@ -0,0 +1,436 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { BucketChecksum, OpId } from '@/util/protocol-types.js';
3
+ import * as crypto from 'node:crypto';
4
+ import { addBucketChecksums } from '@/util/util-index.js';
5
+ import { ChecksumCache, FetchChecksums, FetchPartialBucketChecksum } from '@/storage/ChecksumCache.js';
6
+
7
+ /**
8
+ * Create a deterministic BucketChecksum based on the bucket name and checkpoint for testing purposes.
9
+ */
10
+ function testHash(bucket: string, checkpoint: OpId) {
11
+ const key = `${checkpoint}/${bucket}`;
12
+ const hash = crypto.createHash('sha256').update(key).digest().readInt32LE(0);
13
+ return hash;
14
+ }
15
+
16
+ function testPartialHash(request: FetchPartialBucketChecksum): BucketChecksum {
17
+ if (request.start) {
18
+ const a = testHash(request.bucket, request.start);
19
+ const b = testHash(request.bucket, request.end);
20
+ return addBucketChecksums(
21
+ {
22
+ bucket: request.bucket,
23
+ checksum: b,
24
+ count: Number(request.end)
25
+ },
26
+ {
27
+ // Subtract a
28
+ bucket: request.bucket,
29
+ checksum: -a,
30
+ count: -Number(request.start)
31
+ }
32
+ );
33
+ } else {
34
+ return {
35
+ bucket: request.bucket,
36
+ checksum: testHash(request.bucket, request.end),
37
+ count: Number(request.end)
38
+ };
39
+ }
40
+ }
41
+
42
+ const TEST_123 = {
43
+ bucket: 'test',
44
+ count: 123,
45
+ checksum: 1104081737
46
+ };
47
+
48
+ const TEST_1234 = {
49
+ bucket: 'test',
50
+ count: 1234,
51
+ checksum: -1593864957
52
+ };
53
+
54
+ const TEST2_123 = {
55
+ bucket: 'test2',
56
+ count: 123,
57
+ checksum: 1741377449
58
+ };
59
+
60
+ const TEST3_123 = {
61
+ bucket: 'test3',
62
+ count: 123,
63
+ checksum: -2085080402
64
+ };
65
+
66
+ function fetchTestChecksums(batch: FetchPartialBucketChecksum[]) {
67
+ return new Map(
68
+ batch.map((v) => {
69
+ return [v.bucket, testPartialHash(v)];
70
+ })
71
+ );
72
+ }
73
+
74
+ describe('checksum cache', function () {
75
+ const factory = (fetch: FetchChecksums) => {
76
+ return new ChecksumCache({ fetchChecksums: fetch });
77
+ };
78
+
79
+ it('should handle a sequential lookups (a)', async function () {
80
+ let lookups: FetchPartialBucketChecksum[][] = [];
81
+ const cache = factory(async (batch) => {
82
+ lookups.push(batch);
83
+ return fetchTestChecksums(batch);
84
+ });
85
+
86
+ expect(await cache.getChecksums('123', ['test'])).toEqual([TEST_123]);
87
+
88
+ expect(await cache.getChecksums('1234', ['test'])).toEqual([TEST_1234]);
89
+
90
+ expect(await cache.getChecksums('123', ['test2'])).toEqual([TEST2_123]);
91
+
92
+ expect(lookups).toEqual([
93
+ [{ bucket: 'test', end: '123' }],
94
+ // This should use the previous lookup
95
+ [{ bucket: 'test', start: '123', end: '1234' }],
96
+ [{ bucket: 'test2', end: '123' }]
97
+ ]);
98
+ });
99
+
100
+ it('should handle a sequential lookups (b)', async function () {
101
+ // Reverse order of the above
102
+ let lookups: FetchPartialBucketChecksum[][] = [];
103
+ const cache = factory(async (batch) => {
104
+ lookups.push(batch);
105
+ return fetchTestChecksums(batch);
106
+ });
107
+
108
+ expect(await cache.getChecksums('123', ['test2'])).toEqual([TEST2_123]);
109
+
110
+ expect(await cache.getChecksums('1234', ['test'])).toEqual([TEST_1234]);
111
+
112
+ expect(await cache.getChecksums('123', ['test'])).toEqual([TEST_123]);
113
+
114
+ expect(lookups).toEqual([
115
+ // With this order, there is no option for a partial lookup
116
+ [{ bucket: 'test2', end: '123' }],
117
+ [{ bucket: 'test', end: '1234' }],
118
+ [{ bucket: 'test', end: '123' }]
119
+ ]);
120
+ });
121
+
122
+ it('should handle a concurrent lookups (a)', async function () {
123
+ let lookups: FetchPartialBucketChecksum[][] = [];
124
+ const cache = factory(async (batch) => {
125
+ lookups.push(batch);
126
+ return fetchTestChecksums(batch);
127
+ });
128
+
129
+ const p1 = cache.getChecksums('123', ['test']);
130
+ const p2 = cache.getChecksums('1234', ['test']);
131
+ const p3 = cache.getChecksums('123', ['test2']);
132
+
133
+ expect(await p1).toEqual([TEST_123]);
134
+ expect(await p2).toEqual([TEST_1234]);
135
+ expect(await p3).toEqual([TEST2_123]);
136
+
137
+ // Concurrent requests, so we can't do a partial lookup for 123 -> 1234
138
+ expect(lookups).toEqual([
139
+ [{ bucket: 'test', end: '123' }],
140
+ [{ bucket: 'test', end: '1234' }],
141
+ [{ bucket: 'test2', end: '123' }]
142
+ ]);
143
+ });
144
+
145
+ it('should handle a concurrent lookups (b)', async function () {
146
+ let lookups: FetchPartialBucketChecksum[][] = [];
147
+ const cache = factory(async (batch) => {
148
+ lookups.push(batch);
149
+ return fetchTestChecksums(batch);
150
+ });
151
+
152
+ const p1 = cache.getChecksums('123', ['test']);
153
+ const p2 = cache.getChecksums('123', ['test']);
154
+
155
+ expect(await p1).toEqual([TEST_123]);
156
+
157
+ expect(await p2).toEqual([TEST_123]);
158
+
159
+ // The lookup should be deduplicated, even though it's in progress
160
+ expect(lookups).toEqual([[{ bucket: 'test', end: '123' }]]);
161
+ });
162
+
163
+ it('should handle serial + concurrent lookups', async function () {
164
+ let lookups: FetchPartialBucketChecksum[][] = [];
165
+ const cache = factory(async (batch) => {
166
+ lookups.push(batch);
167
+ return fetchTestChecksums(batch);
168
+ });
169
+
170
+ expect(await cache.getChecksums('123', ['test'])).toEqual([TEST_123]);
171
+
172
+ const p2 = cache.getChecksums('1234', ['test']);
173
+ const p3 = cache.getChecksums('1234', ['test']);
174
+
175
+ expect(await p2).toEqual([TEST_1234]);
176
+ expect(await p3).toEqual([TEST_1234]);
177
+
178
+ expect(lookups).toEqual([
179
+ [{ bucket: 'test', end: '123' }],
180
+ // This lookup is deduplicated
181
+ [{ bucket: 'test', start: '123', end: '1234' }]
182
+ ]);
183
+ });
184
+
185
+ it('should handle multiple buckets', async function () {
186
+ let lookups: FetchPartialBucketChecksum[][] = [];
187
+ const cache = factory(async (batch) => {
188
+ lookups.push(batch);
189
+ return fetchTestChecksums(batch);
190
+ });
191
+
192
+ expect(await cache.getChecksums('123', ['test', 'test2'])).toEqual([TEST_123, TEST2_123]);
193
+
194
+ expect(lookups).toEqual([
195
+ [
196
+ // Both lookups in the same request
197
+ { bucket: 'test', end: '123' },
198
+ { bucket: 'test2', end: '123' }
199
+ ]
200
+ ]);
201
+ });
202
+
203
+ it('should handle multiple buckets with partial caching (a)', async function () {
204
+ let lookups: FetchPartialBucketChecksum[][] = [];
205
+ const cache = factory(async (batch) => {
206
+ lookups.push(batch);
207
+ return fetchTestChecksums(batch);
208
+ });
209
+
210
+ expect(await cache.getChecksums('123', ['test'])).toEqual([TEST_123]);
211
+ expect(await cache.getChecksums('123', ['test', 'test2'])).toEqual([TEST_123, TEST2_123]);
212
+
213
+ expect(lookups).toEqual([
214
+ // Request 1
215
+ [{ bucket: 'test', end: '123' }],
216
+ // Request 2
217
+ [{ bucket: 'test2', end: '123' }]
218
+ ]);
219
+ });
220
+
221
+ it('should handle multiple buckets with partial caching (b)', async function () {
222
+ let lookups: FetchPartialBucketChecksum[][] = [];
223
+ const cache = factory(async (batch) => {
224
+ lookups.push(batch);
225
+ return fetchTestChecksums(batch);
226
+ });
227
+
228
+ const a = cache.getChecksums('123', ['test', 'test2']);
229
+ const b = cache.getChecksums('123', ['test2', 'test3']);
230
+
231
+ expect(await a).toEqual([TEST_123, TEST2_123]);
232
+ expect(await b).toEqual([TEST2_123, TEST3_123]);
233
+
234
+ expect(lookups).toEqual([
235
+ // Request A
236
+ [
237
+ { bucket: 'test', end: '123' },
238
+ { bucket: 'test2', end: '123' }
239
+ ],
240
+ // Request B (re-uses the checksum for test2 from request a)
241
+ [{ bucket: 'test3', end: '123' }]
242
+ ]);
243
+ });
244
+
245
+ it('should handle out-of-order requests', async function () {
246
+ let lookups: FetchPartialBucketChecksum[][] = [];
247
+ const cache = factory(async (batch) => {
248
+ lookups.push(batch);
249
+ return fetchTestChecksums(batch);
250
+ });
251
+
252
+ expect(await cache.getChecksums('123', ['test'])).toEqual([TEST_123]);
253
+
254
+ expect(await cache.getChecksums('125', ['test'])).toEqual([
255
+ {
256
+ bucket: 'test',
257
+ checksum: -1865121912,
258
+ count: 125
259
+ }
260
+ ]);
261
+
262
+ expect(await cache.getChecksums('124', ['test'])).toEqual([
263
+ {
264
+ bucket: 'test',
265
+ checksum: 1887460431,
266
+ count: 124
267
+ }
268
+ ]);
269
+ expect(lookups).toEqual([
270
+ [{ bucket: 'test', end: '123' }],
271
+ [{ bucket: 'test', start: '123', end: '125' }],
272
+ [{ bucket: 'test', start: '123', end: '124' }]
273
+ ]);
274
+ });
275
+
276
+ it('should handle errors', async function () {
277
+ let lookups: FetchPartialBucketChecksum[][] = [];
278
+ const TEST_ERROR = new Error('Simulated error');
279
+ const cache = factory(async (batch) => {
280
+ lookups.push(batch);
281
+ if (lookups.length == 1) {
282
+ throw new Error('Simulated error');
283
+ }
284
+ return fetchTestChecksums(batch);
285
+ });
286
+
287
+ const a = cache.getChecksums('123', ['test', 'test2']);
288
+ const b = cache.getChecksums('123', ['test2', 'test3']);
289
+
290
+ await expect(a).rejects.toEqual(TEST_ERROR);
291
+ await expect(b).rejects.toEqual(TEST_ERROR);
292
+
293
+ const a2 = cache.getChecksums('123', ['test', 'test2']);
294
+ const b2 = cache.getChecksums('123', ['test2', 'test3']);
295
+
296
+ expect(await a2).toEqual([TEST_123, TEST2_123]);
297
+ expect(await b2).toEqual([TEST2_123, TEST3_123]);
298
+
299
+ expect(lookups).toEqual([
300
+ // Request A (fails)
301
+ [
302
+ { bucket: 'test', end: '123' },
303
+ { bucket: 'test2', end: '123' }
304
+ ],
305
+ // Request B (re-uses the checksum for test2 from request a)
306
+ // Even thought the full request fails, this batch succeeds
307
+ [{ bucket: 'test3', end: '123' }],
308
+ // Retry request A
309
+ [
310
+ { bucket: 'test', end: '123' },
311
+ { bucket: 'test2', end: '123' }
312
+ ]
313
+ ]);
314
+ });
315
+
316
+ it('should handle missing checksums (a)', async function () {
317
+ let lookups: FetchPartialBucketChecksum[][] = [];
318
+ const cache = factory(async (batch) => {
319
+ lookups.push(batch);
320
+ return fetchTestChecksums(batch.filter((b) => b.bucket != 'test'));
321
+ });
322
+
323
+ expect(await cache.getChecksums('123', ['test'])).toEqual([{ bucket: 'test', checksum: 0, count: 0 }]);
324
+ expect(await cache.getChecksums('123', ['test', 'test2'])).toEqual([
325
+ { bucket: 'test', checksum: 0, count: 0 },
326
+ TEST2_123
327
+ ]);
328
+ });
329
+
330
+ it('should handle missing checksums (b)', async function () {
331
+ let lookups: FetchPartialBucketChecksum[][] = [];
332
+ const cache = factory(async (batch) => {
333
+ lookups.push(batch);
334
+ return fetchTestChecksums(batch.filter((b) => b.bucket != 'test' || b.end != '123'));
335
+ });
336
+
337
+ expect(await cache.getChecksums('123', ['test'])).toEqual([{ bucket: 'test', checksum: 0, count: 0 }]);
338
+ expect(await cache.getChecksums('1234', ['test'])).toEqual([
339
+ {
340
+ bucket: 'test',
341
+ checksum: 1597020602,
342
+ count: 1111
343
+ }
344
+ ]);
345
+
346
+ expect(lookups).toEqual([[{ bucket: 'test', end: '123' }], [{ bucket: 'test', start: '123', end: '1234' }]]);
347
+ });
348
+
349
+ it('should use maxSize', async function () {
350
+ let lookups: FetchPartialBucketChecksum[][] = [];
351
+ const cache = new ChecksumCache({
352
+ fetchChecksums: async (batch) => {
353
+ lookups.push(batch);
354
+ return fetchTestChecksums(batch);
355
+ },
356
+ maxSize: 2
357
+ });
358
+
359
+ expect(await cache.getChecksums('123', ['test'])).toEqual([TEST_123]);
360
+ expect(await cache.getChecksums('124', ['test'])).toEqual([
361
+ {
362
+ bucket: 'test',
363
+ checksum: 1887460431,
364
+ count: 124
365
+ }
366
+ ]);
367
+
368
+ expect(await cache.getChecksums('125', ['test'])).toEqual([
369
+ {
370
+ bucket: 'test',
371
+ checksum: -1865121912,
372
+ count: 125
373
+ }
374
+ ]);
375
+ expect(await cache.getChecksums('126', ['test'])).toEqual([
376
+ {
377
+ bucket: 'test',
378
+ checksum: -1720007310,
379
+ count: 126
380
+ }
381
+ ]);
382
+ expect(await cache.getChecksums('124', ['test'])).toEqual([
383
+ {
384
+ bucket: 'test',
385
+ checksum: 1887460431,
386
+ count: 124
387
+ }
388
+ ]);
389
+ expect(await cache.getChecksums('123', ['test'])).toEqual([TEST_123]);
390
+
391
+ expect(lookups).toEqual([
392
+ [{ bucket: 'test', end: '123' }],
393
+ [{ bucket: 'test', start: '123', end: '124' }],
394
+ [{ bucket: 'test', start: '124', end: '125' }],
395
+ [{ bucket: 'test', start: '125', end: '126' }],
396
+ [{ bucket: 'test', end: '124' }],
397
+ [{ bucket: 'test', end: '123' }]
398
+ ]);
399
+ });
400
+
401
+ it('should handle concurrent requests greater than cache size', async function () {
402
+ // This will not be cached efficiently, but we test that we don't get errors at least.
403
+ let lookups: FetchPartialBucketChecksum[][] = [];
404
+ const cache = new ChecksumCache({
405
+ fetchChecksums: async (batch) => {
406
+ lookups.push(batch);
407
+ return fetchTestChecksums(batch);
408
+ },
409
+ maxSize: 2
410
+ });
411
+
412
+ const p3 = cache.getChecksums('123', ['test3']);
413
+ const p4 = cache.getChecksums('123', ['test4']);
414
+ const p1 = cache.getChecksums('123', ['test']);
415
+ const p2 = cache.getChecksums('123', ['test2']);
416
+
417
+ expect(await p1).toEqual([TEST_123]);
418
+ expect(await p2).toEqual([TEST2_123]);
419
+ expect(await p3).toEqual([TEST3_123]);
420
+ expect(await p4).toEqual([
421
+ {
422
+ bucket: 'test4',
423
+ checksum: 1004797863,
424
+ count: 123
425
+ }
426
+ ]);
427
+
428
+ // The lookup should be deduplicated, even though it's in progress
429
+ expect(lookups).toEqual([
430
+ [{ bucket: 'test3', end: '123' }],
431
+ [{ bucket: 'test4', end: '123' }],
432
+ [{ bucket: 'test', end: '123' }],
433
+ [{ bucket: 'test2', end: '123' }]
434
+ ]);
435
+ });
436
+ });
@@ -252,7 +252,7 @@ bucket_definitions:
252
252
  { op: 'REMOVE', object_id: 'test1', checksum: c2 }
253
253
  ]);
254
254
 
255
- const checksums = await storage.getChecksums(checkpoint, ['global[]']);
255
+ const checksums = [...(await storage.getChecksums(checkpoint, ['global[]'])).values()];
256
256
  expect(checksums).toEqual([
257
257
  {
258
258
  bucket: 'global[]',
@@ -599,7 +599,7 @@ bucket_definitions:
599
599
  { op: 'REMOVE', object_id: 'test1', checksum: c2 }
600
600
  ]);
601
601
 
602
- const checksums = await storage.getChecksums(checkpoint, ['global[]']);
602
+ const checksums = [...(await storage.getChecksums(checkpoint, ['global[]'])).values()];
603
603
  expect(checksums).toEqual([
604
604
  {
605
605
  bucket: 'global[]',
@@ -713,7 +713,7 @@ bucket_definitions:
713
713
  { op: 'REMOVE', object_id: 'test1', checksum: c2 }
714
714
  ]);
715
715
 
716
- const checksums = await storage.getChecksums(checkpoint, ['global[]']);
716
+ const checksums = [...(await storage.getChecksums(checkpoint, ['global[]'])).values()];
717
717
  expect(checksums).toEqual([
718
718
  {
719
719
  bucket: 'global[]',
@@ -50,7 +50,7 @@ function defineBatchTests(factory: StorageFactory) {
50
50
  const duration = Date.now() - start;
51
51
  const used = Math.round(process.memoryUsage().heapUsed / 1024 / 1024);
52
52
  const checksum = await context.storage!.getChecksums(checkpoint, ['global[]']);
53
- expect(checksum[0].count).toEqual(operation_count);
53
+ expect(checksum.get('global[]')!.count).toEqual(operation_count);
54
54
  const perSecond = Math.round((operation_count / duration) * 1000);
55
55
  console.log(`${operation_count} ops in ${duration}ms ${perSecond} ops/s. ${used}MB heap`);
56
56
  }),
@@ -101,7 +101,7 @@ function defineBatchTests(factory: StorageFactory) {
101
101
  const checkpoint = await context.getCheckpoint({ timeout: 100_000 });
102
102
  const duration = Date.now() - start;
103
103
  const checksum = await context.storage!.getChecksums(checkpoint, ['global[]']);
104
- expect(checksum[0].count).toEqual(operation_count);
104
+ expect(checksum.get('global[]')!.count).toEqual(operation_count);
105
105
  const perSecond = Math.round((operation_count / duration) * 1000);
106
106
  console.log(`${operation_count} ops in ${duration}ms ${perSecond} ops/s.`);
107
107
  printMemoryUsage();
@@ -157,7 +157,7 @@ function defineBatchTests(factory: StorageFactory) {
157
157
  const duration = Date.now() - start;
158
158
  const used = Math.round(process.memoryUsage().heapUsed / 1024 / 1024);
159
159
  const checksum = await context.storage!.getChecksums(checkpoint, ['global[]']);
160
- expect(checksum[0].count).toEqual(operationCount);
160
+ expect(checksum.get('global[]')!.count).toEqual(operationCount);
161
161
  const perSecond = Math.round((operationCount / duration) * 1000);
162
162
  // This number depends on the test machine, so we keep the test significantly
163
163
  // lower than expected numbers.
@@ -174,7 +174,7 @@ function defineBatchTests(factory: StorageFactory) {
174
174
  const truncateDuration = Date.now() - truncateStart;
175
175
 
176
176
  const checksum2 = await context.storage!.getChecksums(checkpoint2, ['global[]']);
177
- const truncateCount = checksum2[0].count - checksum[0].count;
177
+ const truncateCount = checksum2.get('global[]')!.count - checksum.get('global[]')!.count;
178
178
  expect(truncateCount).toEqual(numTransactions * perTransaction);
179
179
  const truncatePerSecond = Math.round((truncateCount / truncateDuration) * 1000);
180
180
  console.log(`Truncated ${truncateCount} ops in ${truncateDuration}ms ${truncatePerSecond} ops/s. ${used}MB heap`);
@@ -86,6 +86,12 @@ VALUES(6, 'epoch'::timestamp, 'epoch'::timestamptz);
86
86
 
87
87
  INSERT INTO test_data(id, timestamp, timestamptz)
88
88
  VALUES(7, 'infinity'::timestamp, 'infinity'::timestamptz);
89
+
90
+ INSERT INTO test_data(id, timestamptz)
91
+ VALUES(8, '0022-02-03 12:13:14+03'::timestamptz);
92
+
93
+ INSERT INTO test_data(id, timestamptz)
94
+ VALUES(9, '10022-02-03 12:13:14+03'::timestamptz);
89
95
  `);
90
96
  }
91
97
 
@@ -186,6 +192,18 @@ VALUES(10, ARRAY['null']::TEXT[]);
186
192
  timestamp: '9999-12-31 23:59:59',
187
193
  timestamptz: '9999-12-31 23:59:59Z'
188
194
  });
195
+
196
+ expect(transformed[7]).toMatchObject({
197
+ id: 8n,
198
+ timestamptz: '0022-02-03 09:13:14Z'
199
+ });
200
+
201
+ expect(transformed[8]).toMatchObject({
202
+ id: 9n,
203
+ // 10022-02-03 12:13:14+03 - out of range of both our date parsing logic, and sqlite's date functions
204
+ // We can consider just preserving the source string as an alternative if this causes issues.
205
+ timestamptz: null
206
+ });
189
207
  }
190
208
 
191
209
  function checkResultArrays(transformed: Record<string, any>[]) {