@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.
- package/CHANGELOG.md +16 -0
- package/dist/api/RouteAPI.d.ts +25 -11
- package/dist/replication/AbstractReplicator.d.ts +8 -0
- package/dist/replication/AbstractReplicator.js.map +1 -1
- package/dist/routes/RouterEngine.d.ts +1 -0
- package/dist/routes/RouterEngine.js +6 -1
- package/dist/routes/RouterEngine.js.map +1 -1
- package/dist/routes/configure-fastify.d.ts +52 -44
- package/dist/routes/configure-fastify.js +15 -2
- package/dist/routes/configure-fastify.js.map +1 -1
- package/dist/routes/endpoints/checkpointing.js +1 -3
- package/dist/routes/endpoints/checkpointing.js.map +1 -1
- package/dist/storage/BucketStorageBatch.d.ts +60 -11
- package/dist/storage/BucketStorageBatch.js.map +1 -1
- package/dist/storage/ChecksumCache.d.ts +11 -6
- package/dist/storage/ChecksumCache.js +5 -4
- package/dist/storage/ChecksumCache.js.map +1 -1
- package/dist/storage/SyncRulesBucketStorage.d.ts +35 -5
- package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
- package/dist/storage/WriteCheckpointAPI.d.ts +1 -1
- package/dist/sync/BucketChecksumState.d.ts +5 -2
- package/dist/sync/BucketChecksumState.js +36 -8
- package/dist/sync/BucketChecksumState.js.map +1 -1
- package/dist/sync/RequestTracker.d.ts +7 -0
- package/dist/sync/RequestTracker.js +12 -0
- package/dist/sync/RequestTracker.js.map +1 -1
- package/dist/sync/sync.js +118 -38
- package/dist/sync/sync.js.map +1 -1
- package/dist/system/ServiceContext.d.ts +2 -0
- package/dist/system/ServiceContext.js +2 -0
- package/dist/system/ServiceContext.js.map +1 -1
- package/dist/tracing/PerformanceTracer.d.ts +7 -1
- package/dist/tracing/PerformanceTracer.js +8 -0
- package/dist/tracing/PerformanceTracer.js.map +1 -1
- package/dist/util/checkpointing.d.ts +3 -8
- package/dist/util/checkpointing.js +1 -16
- package/dist/util/checkpointing.js.map +1 -1
- package/dist/util/config/compound-config-collector.js +10 -2
- package/dist/util/config/compound-config-collector.js.map +1 -1
- package/dist/util/config/defaults.d.ts +1 -0
- package/dist/util/config/defaults.js +1 -0
- package/dist/util/config/defaults.js.map +1 -1
- package/dist/util/config/types.d.ts +1 -0
- package/dist/util/util-index.d.ts +1 -0
- package/dist/util/util-index.js +1 -0
- package/dist/util/util-index.js.map +1 -1
- package/dist/util/utils.d.ts +1 -1
- package/dist/util/utils.js +1 -1
- package/dist/util/write-checkpoint-batcher.d.ts +19 -0
- package/dist/util/write-checkpoint-batcher.js +90 -0
- package/dist/util/write-checkpoint-batcher.js.map +1 -0
- package/package.json +5 -5
- package/src/api/RouteAPI.ts +25 -11
- package/src/replication/AbstractReplicator.ts +8 -0
- package/src/routes/RouterEngine.ts +7 -1
- package/src/routes/configure-fastify.ts +25 -2
- package/src/routes/endpoints/checkpointing.ts +1 -4
- package/src/storage/BucketStorageBatch.ts +61 -11
- package/src/storage/ChecksumCache.ts +27 -14
- package/src/storage/SyncRulesBucketStorage.ts +42 -5
- package/src/storage/WriteCheckpointAPI.ts +1 -1
- package/src/sync/BucketChecksumState.ts +65 -8
- package/src/sync/RequestTracker.ts +20 -0
- package/src/sync/sync.ts +148 -44
- package/src/system/ServiceContext.ts +7 -0
- package/src/tracing/PerformanceTracer.ts +18 -1
- package/src/util/checkpointing.ts +3 -22
- package/src/util/config/compound-config-collector.ts +13 -1
- package/src/util/config/defaults.ts +1 -0
- package/src/util/config/types.ts +1 -0
- package/src/util/util-index.ts +1 -0
- package/src/util/utils.ts +1 -1
- package/src/util/write-checkpoint-batcher.ts +122 -0
- package/test/src/checksum_cache.test.ts +22 -0
- package/test/src/config.test.ts +42 -0
- package/test/src/sync/BucketChecksumState.test.ts +41 -1
- package/test/src/util/checkpointing.test.ts +170 -0
- 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
|
});
|
package/test/src/config.test.ts
CHANGED
|
@@ -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:
|
|
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
|
+
});
|