@powersync/service-core 1.23.1 → 1.23.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.
Files changed (78) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/api/RouteAPI.d.ts +25 -11
  3. package/dist/replication/AbstractReplicator.d.ts +8 -0
  4. package/dist/replication/AbstractReplicator.js.map +1 -1
  5. package/dist/routes/RouterEngine.d.ts +1 -0
  6. package/dist/routes/RouterEngine.js +6 -1
  7. package/dist/routes/RouterEngine.js.map +1 -1
  8. package/dist/routes/configure-fastify.d.ts +52 -44
  9. package/dist/routes/configure-fastify.js +15 -2
  10. package/dist/routes/configure-fastify.js.map +1 -1
  11. package/dist/routes/endpoints/checkpointing.js +1 -3
  12. package/dist/routes/endpoints/checkpointing.js.map +1 -1
  13. package/dist/storage/BucketStorageBatch.d.ts +60 -11
  14. package/dist/storage/BucketStorageBatch.js.map +1 -1
  15. package/dist/storage/ChecksumCache.d.ts +11 -6
  16. package/dist/storage/ChecksumCache.js +5 -4
  17. package/dist/storage/ChecksumCache.js.map +1 -1
  18. package/dist/storage/SyncRulesBucketStorage.d.ts +35 -5
  19. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  20. package/dist/storage/WriteCheckpointAPI.d.ts +1 -1
  21. package/dist/sync/BucketChecksumState.d.ts +5 -2
  22. package/dist/sync/BucketChecksumState.js +36 -8
  23. package/dist/sync/BucketChecksumState.js.map +1 -1
  24. package/dist/sync/RequestTracker.d.ts +7 -0
  25. package/dist/sync/RequestTracker.js +12 -0
  26. package/dist/sync/RequestTracker.js.map +1 -1
  27. package/dist/sync/sync.js +118 -38
  28. package/dist/sync/sync.js.map +1 -1
  29. package/dist/system/ServiceContext.d.ts +2 -0
  30. package/dist/system/ServiceContext.js +2 -0
  31. package/dist/system/ServiceContext.js.map +1 -1
  32. package/dist/tracing/PerformanceTracer.d.ts +7 -1
  33. package/dist/tracing/PerformanceTracer.js +8 -0
  34. package/dist/tracing/PerformanceTracer.js.map +1 -1
  35. package/dist/util/checkpointing.d.ts +3 -8
  36. package/dist/util/checkpointing.js +1 -16
  37. package/dist/util/checkpointing.js.map +1 -1
  38. package/dist/util/config/compound-config-collector.js +10 -2
  39. package/dist/util/config/compound-config-collector.js.map +1 -1
  40. package/dist/util/config/defaults.d.ts +1 -0
  41. package/dist/util/config/defaults.js +1 -0
  42. package/dist/util/config/defaults.js.map +1 -1
  43. package/dist/util/config/types.d.ts +1 -0
  44. package/dist/util/util-index.d.ts +1 -0
  45. package/dist/util/util-index.js +1 -0
  46. package/dist/util/util-index.js.map +1 -1
  47. package/dist/util/utils.d.ts +1 -1
  48. package/dist/util/utils.js +1 -1
  49. package/dist/util/write-checkpoint-batcher.d.ts +19 -0
  50. package/dist/util/write-checkpoint-batcher.js +90 -0
  51. package/dist/util/write-checkpoint-batcher.js.map +1 -0
  52. package/package.json +5 -5
  53. package/src/api/RouteAPI.ts +25 -11
  54. package/src/replication/AbstractReplicator.ts +8 -0
  55. package/src/routes/RouterEngine.ts +7 -1
  56. package/src/routes/configure-fastify.ts +25 -2
  57. package/src/routes/endpoints/checkpointing.ts +1 -4
  58. package/src/storage/BucketStorageBatch.ts +61 -11
  59. package/src/storage/ChecksumCache.ts +27 -14
  60. package/src/storage/SyncRulesBucketStorage.ts +42 -5
  61. package/src/storage/WriteCheckpointAPI.ts +1 -1
  62. package/src/sync/BucketChecksumState.ts +65 -8
  63. package/src/sync/RequestTracker.ts +20 -0
  64. package/src/sync/sync.ts +148 -44
  65. package/src/system/ServiceContext.ts +7 -0
  66. package/src/tracing/PerformanceTracer.ts +18 -1
  67. package/src/util/checkpointing.ts +3 -22
  68. package/src/util/config/compound-config-collector.ts +13 -1
  69. package/src/util/config/defaults.ts +1 -0
  70. package/src/util/config/types.ts +1 -0
  71. package/src/util/util-index.ts +1 -0
  72. package/src/util/utils.ts +1 -1
  73. package/src/util/write-checkpoint-batcher.ts +122 -0
  74. package/test/src/checksum_cache.test.ts +22 -0
  75. package/test/src/config.test.ts +42 -0
  76. package/test/src/sync/BucketChecksumState.test.ts +41 -1
  77. package/test/src/util/checkpointing.test.ts +170 -0
  78. package/tsconfig.tsbuildinfo +1 -1
@@ -482,4 +482,26 @@ describe('checksum cache', function () {
482
482
  expect(await cache.getChecksums(123n, [{ bucket: 'test', source: DUMMY_SOURCE }])).toEqual([TEST_123]);
483
483
  expect(await cache.getChecksums(1234n, [{ bucket: 'test', source: DUMMY_SOURCE }])).toEqual([TEST_1234]);
484
484
  });
485
+
486
+ it('should refresh cached checksums after the configured TTL', async function () {
487
+ let lookups: FetchPartialBucketChecksum[][] = [];
488
+ const cache = new ChecksumCache({
489
+ fetchChecksums: async (batch) => {
490
+ lookups.push(batch);
491
+ return fetchTestChecksums(batch);
492
+ },
493
+ ttlMs: 1
494
+ });
495
+
496
+ expect(await cache.getChecksums(123n, [{ bucket: 'test', source: DUMMY_SOURCE }])).toEqual([TEST_123]);
497
+ expect(await cache.getChecksums(123n, [{ bucket: 'test', source: DUMMY_SOURCE }])).toEqual([TEST_123]);
498
+
499
+ await new Promise((resolve) => setTimeout(resolve, 1001)); // Needs to be >1s
500
+
501
+ expect(await cache.getChecksums(123n, [{ bucket: 'test', source: DUMMY_SOURCE }])).toEqual([TEST_123]);
502
+ expect(lookups.map(removeLookupSources)).toEqual([
503
+ [{ bucket: 'test', end: 123n }],
504
+ [{ bucket: 'test', end: 123n }]
505
+ ]);
506
+ });
485
507
  });
@@ -70,6 +70,48 @@ describe('Config', () => {
70
70
  expect(config.api_parameters.max_buckets_per_connection).toBe(1);
71
71
  });
72
72
 
73
+ it('should resolve checksum cache TTL from API parameters', async () => {
74
+ const yamlConfig = /* yaml */ `
75
+ # PowerSync config
76
+ replication:
77
+ connections: []
78
+ storage:
79
+ type: mongodb
80
+ api:
81
+ parameters:
82
+ bucket_count_cache_ttl_minutes: 15
83
+ `;
84
+
85
+ const collector = new CompoundConfigCollector();
86
+
87
+ const config = await collector.collectConfig({
88
+ config_base64: Buffer.from(yamlConfig, 'utf-8').toString('base64')
89
+ });
90
+
91
+ expect(config.api_parameters.bucket_count_cache_ttl_minutes).toBe(15);
92
+ });
93
+
94
+ it('should reject fractional checksum cache TTL minutes', async () => {
95
+ const yamlConfig = /* yaml */ `
96
+ # PowerSync config
97
+ replication:
98
+ connections: []
99
+ storage:
100
+ type: mongodb
101
+ api:
102
+ parameters:
103
+ bucket_count_cache_ttl_minutes: 1.5
104
+ `;
105
+
106
+ const collector = new CompoundConfigCollector();
107
+
108
+ await expect(
109
+ collector.collectConfig({
110
+ config_base64: Buffer.from(yamlConfig, 'utf-8').toString('base64')
111
+ })
112
+ ).rejects.toThrow('api.parameters.bucket_count_cache_ttl_minutes must be a positive integer');
113
+ });
114
+
73
115
  it('should throw YAML validation error for invalid base64 config', {}, async () => {
74
116
  const yamlConfig = /* yaml */ `
75
117
  # PowerSync config
@@ -137,6 +137,7 @@ bucket_definitions:
137
137
  priority: 3
138
138
  }
139
139
  ]);
140
+ expect(line.bucketDataRequestHint).toEqual('bulk');
140
141
  // This is the bucket data to be fetched
141
142
  expect(bucketStarts(line.getFilteredBucketPositions())).toEqual(new Map([['1#global[]', 0n]]));
142
143
 
@@ -170,6 +171,45 @@ bucket_definitions:
170
171
  }
171
172
  });
172
173
  expect(bucketStarts(line2.getFilteredBucketPositions())).toEqual(new Map([['1#global[]', 1n]]));
174
+ expect(line2.bucketDataRequestHint).toEqual('incremental');
175
+ });
176
+
177
+ test('unfinished bulk bucket does not make checkpoint diff bulk', async () => {
178
+ const storage = new MockBucketChecksumStateStorage();
179
+ storage.updateTestChecksum({ bucket: '1#global[]', checksum: 1, count: 1 });
180
+
181
+ const state = new BucketChecksumState({
182
+ syncContext,
183
+ syncRequest,
184
+ tokenPayload,
185
+ syncRules: SYNC_RULES_GLOBAL,
186
+ bucketStorage: storage
187
+ });
188
+
189
+ const line = (await state.buildNextCheckpointLine({
190
+ base: storage.makeCheckpoint(1n),
191
+ writeCheckpoint: null,
192
+ update: CHECKPOINT_INVALIDATE_ALL
193
+ }))!;
194
+ line.advance();
195
+ line.updateBucketPosition({ bucket: '1#global[]', nextAfter: 1n, hasMore: true });
196
+
197
+ storage.updateTestChecksum({ bucket: '1#global[]', checksum: 2, count: 2 });
198
+
199
+ const line2 = (await state.buildNextCheckpointLine({
200
+ base: storage.makeCheckpoint(2n),
201
+ writeCheckpoint: null,
202
+ update: {
203
+ updatedDataBuckets: new Set(['1#global[]']),
204
+ invalidateDataBuckets: false,
205
+ updatedParameterLookups: new Set(),
206
+ invalidateParameterBuckets: false
207
+ }
208
+ }))!;
209
+ line2.advance();
210
+
211
+ expect(line2.bucketDataRequestHint).toEqual('incremental');
212
+ expect(bucketStarts(line2.getFilteredBucketPositions())).toEqual(new Map([['1#global[]', 1n]]));
173
213
  });
174
214
 
175
215
  test('global bucket with initial state', async () => {
@@ -1240,7 +1280,7 @@ class MockBucketChecksumStateStorage implements BucketChecksumStateStorage {
1240
1280
  this.filter?.({ invalidate: true });
1241
1281
  }
1242
1282
 
1243
- async getChecksums(_checkpoint: InternalOpId, buckets: { bucket: string }[]): Promise<ChecksumMap> {
1283
+ async getChecksums(_checkpoint: ReplicationCheckpoint, buckets: { bucket: string }[]): Promise<ChecksumMap> {
1244
1284
  return new Map<string, BucketChecksum>(
1245
1285
  buckets.map(({ bucket }) => {
1246
1286
  const checksum = this.state.get(bucket);
@@ -0,0 +1,170 @@
1
+ import { createWriteCheckpoint, WriteCheckpointBatcher } from '@/index.js';
2
+ import { describe, expect, test, vi } from 'vitest';
3
+
4
+ function deferred<T = void>() {
5
+ return Promise.withResolvers<T>();
6
+ }
7
+
8
+ async function waitForAsyncWork() {
9
+ await new Promise((resolve) => setTimeout(resolve, 0));
10
+ await new Promise((resolve) => setImmediate(resolve));
11
+ }
12
+
13
+ function createStorage() {
14
+ let nextCheckpoint = 1n;
15
+ const bucketStorage = {
16
+ createManagedWriteCheckpoints: vi.fn(async (checkpoints: { user_id: string }[]) => {
17
+ return new Map(checkpoints.map((checkpoint) => [checkpoint.user_id, nextCheckpoint++]));
18
+ })
19
+ };
20
+
21
+ const storage = {
22
+ getActiveSyncConfig: vi.fn(async () => ({ storage: bucketStorage }))
23
+ };
24
+
25
+ return { bucketStorage, storage };
26
+ }
27
+
28
+ function createBatcher(api: any, storage: any) {
29
+ return new WriteCheckpointBatcher(
30
+ () => api,
31
+ () => storage
32
+ );
33
+ }
34
+
35
+ describe('write checkpoint batching', () => {
36
+ test('coalesces same-turn requests and dispatches queued requests as capacity becomes available', async () => {
37
+ const gates = [deferred(), deferred()];
38
+ const { bucketStorage, storage } = createStorage();
39
+ const api = {
40
+ createReplicationHead: vi.fn(async (callback: (head: string) => Promise<unknown>) => {
41
+ const batch = api.createReplicationHead.mock.calls.length;
42
+ const result = await callback(`head-${batch}`);
43
+ await gates[batch - 1].promise;
44
+ return result;
45
+ })
46
+ };
47
+ const batcher = createBatcher(api, storage);
48
+
49
+ const firstBatch = [
50
+ createWriteCheckpoint({
51
+ userId: 'user-a',
52
+ clientId: 'client-1',
53
+ batcher
54
+ }),
55
+ createWriteCheckpoint({
56
+ userId: 'user-b',
57
+ clientId: undefined,
58
+ batcher
59
+ })
60
+ ];
61
+ await waitForAsyncWork();
62
+
63
+ expect(api.createReplicationHead).toHaveBeenCalledTimes(1);
64
+ expect(bucketStorage.createManagedWriteCheckpoints).toHaveBeenNthCalledWith(1, [
65
+ { user_id: 'user-a/client-1', heads: { '1': 'head-1' } },
66
+ { user_id: 'user-b', heads: { '1': 'head-1' } }
67
+ ]);
68
+
69
+ const secondBatch = [
70
+ createWriteCheckpoint({ userId: 'user-c', clientId: 'client-3', batcher }),
71
+ createWriteCheckpoint({ userId: 'user-d', clientId: undefined, batcher }),
72
+ createWriteCheckpoint({ userId: 'user-e', clientId: undefined, batcher })
73
+ ];
74
+
75
+ await waitForAsyncWork();
76
+
77
+ expect(api.createReplicationHead).toHaveBeenCalledTimes(2);
78
+ expect(bucketStorage.createManagedWriteCheckpoints).toHaveBeenNthCalledWith(2, [
79
+ { user_id: 'user-c/client-3', heads: { '1': 'head-2' } },
80
+ { user_id: 'user-d', heads: { '1': 'head-2' } },
81
+ { user_id: 'user-e', heads: { '1': 'head-2' } }
82
+ ]);
83
+
84
+ gates[0].resolve();
85
+ gates[1].resolve();
86
+ await expect(Promise.all([...firstBatch, ...secondBatch])).resolves.toEqual([
87
+ { writeCheckpoint: '1', replicationHead: 'head-1' },
88
+ { writeCheckpoint: '2', replicationHead: 'head-1' },
89
+ { writeCheckpoint: '3', replicationHead: 'head-2' },
90
+ { writeCheckpoint: '4', replicationHead: 'head-2' },
91
+ { writeCheckpoint: '5', replicationHead: 'head-2' }
92
+ ]);
93
+ });
94
+
95
+ test('allows three executing batches and queues later requests until one completes', async () => {
96
+ const gates = [deferred(), deferred(), deferred(), deferred()];
97
+ const { storage } = createStorage();
98
+ const api = {
99
+ createReplicationHead: vi.fn(async (callback: (head: string) => Promise<unknown>) => {
100
+ const batch = api.createReplicationHead.mock.calls.length;
101
+ const result = await callback(`head-${batch}`);
102
+ await gates[batch - 1].promise;
103
+ return result;
104
+ })
105
+ };
106
+ const batcher = createBatcher(api, storage);
107
+
108
+ const first = createWriteCheckpoint({
109
+ userId: 'user-a',
110
+ clientId: undefined,
111
+ batcher
112
+ });
113
+ await waitForAsyncWork();
114
+
115
+ const second = createWriteCheckpoint({
116
+ userId: 'user-b',
117
+ clientId: undefined,
118
+ batcher
119
+ });
120
+ await waitForAsyncWork();
121
+
122
+ const third = createWriteCheckpoint({
123
+ userId: 'user-c',
124
+ clientId: undefined,
125
+ batcher
126
+ });
127
+ await waitForAsyncWork();
128
+
129
+ expect(api.createReplicationHead).toHaveBeenCalledTimes(3);
130
+
131
+ const fourth = createWriteCheckpoint({
132
+ userId: 'user-d',
133
+ clientId: undefined,
134
+ batcher
135
+ });
136
+ await waitForAsyncWork();
137
+
138
+ expect(api.createReplicationHead).toHaveBeenCalledTimes(3);
139
+
140
+ gates[0].resolve();
141
+ await first;
142
+ await waitForAsyncWork();
143
+
144
+ expect(api.createReplicationHead).toHaveBeenCalledTimes(4);
145
+
146
+ gates[1].resolve();
147
+ gates[2].resolve();
148
+ gates[3].resolve();
149
+
150
+ await expect(Promise.all([second, third, fourth])).resolves.toEqual([
151
+ { writeCheckpoint: '2', replicationHead: 'head-2' },
152
+ { writeCheckpoint: '3', replicationHead: 'head-3' },
153
+ { writeCheckpoint: '4', replicationHead: 'head-4' }
154
+ ]);
155
+ });
156
+
157
+ test('passes batch errors through without retrying', async () => {
158
+ const error = new Error('source unavailable');
159
+ const { storage } = createStorage();
160
+ const api = {
161
+ createReplicationHead: vi.fn(async () => {
162
+ throw error;
163
+ })
164
+ };
165
+ const batcher = createBatcher(api, storage);
166
+
167
+ await expect(createWriteCheckpoint({ userId: 'user-a', clientId: undefined, batcher })).rejects.toBe(error);
168
+ expect(api.createReplicationHead).toHaveBeenCalledTimes(1);
169
+ });
170
+ });