@powersync/service-core 1.23.2 → 1.24.0

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 +39 -0
  2. package/dist/api/RouteAPI.d.ts +17 -3
  3. package/dist/entry/commands/compact-action.js +4 -0
  4. package/dist/entry/commands/compact-action.js.map +1 -1
  5. package/dist/replication/AbstractReplicator.d.ts +9 -4
  6. package/dist/replication/AbstractReplicator.js +35 -11
  7. package/dist/replication/AbstractReplicator.js.map +1 -1
  8. package/dist/routes/configure-fastify.d.ts +31 -0
  9. package/dist/routes/endpoints/checkpointing.d.ts +62 -0
  10. package/dist/routes/endpoints/checkpointing.js +63 -3
  11. package/dist/routes/endpoints/checkpointing.js.map +1 -1
  12. package/dist/routes/endpoints/socket-route.js +2 -1
  13. package/dist/routes/endpoints/socket-route.js.map +1 -1
  14. package/dist/storage/BucketStorageBatch.d.ts +4 -1
  15. package/dist/storage/BucketStorageBatch.js.map +1 -1
  16. package/dist/storage/BucketStorageFactory.d.ts +2 -2
  17. package/dist/storage/CheckpointChecksumInvalidatedError.d.ts +12 -0
  18. package/dist/storage/CheckpointChecksumInvalidatedError.js +17 -0
  19. package/dist/storage/CheckpointChecksumInvalidatedError.js.map +1 -0
  20. package/dist/storage/SyncRulesBucketStorage.d.ts +26 -1
  21. package/dist/storage/SyncRulesBucketStorage.js +3 -0
  22. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  23. package/dist/storage/WriteCheckpointAPI.d.ts +60 -3
  24. package/dist/storage/WriteCheckpointAPI.js +33 -0
  25. package/dist/storage/WriteCheckpointAPI.js.map +1 -1
  26. package/dist/storage/implementation/BucketDefinitionMapping.d.ts +4 -4
  27. package/dist/storage/implementation/BucketDefinitionMapping.js.map +1 -1
  28. package/dist/storage/storage-index.d.ts +1 -0
  29. package/dist/storage/storage-index.js +1 -0
  30. package/dist/storage/storage-index.js.map +1 -1
  31. package/dist/sync/BucketChecksumState.d.ts +7 -0
  32. package/dist/sync/BucketChecksumState.js +33 -13
  33. package/dist/sync/BucketChecksumState.js.map +1 -1
  34. package/dist/sync/sync.js +34 -6
  35. package/dist/sync/sync.js.map +1 -1
  36. package/dist/sync/util.d.ts +5 -0
  37. package/dist/sync/util.js +9 -0
  38. package/dist/sync/util.js.map +1 -1
  39. package/dist/util/checkpointing.d.ts +1 -0
  40. package/dist/util/checkpointing.js +1 -1
  41. package/dist/util/checkpointing.js.map +1 -1
  42. package/dist/util/config/compound-config-collector.js +9 -1
  43. package/dist/util/config/compound-config-collector.js.map +1 -1
  44. package/dist/util/config/defaults.d.ts +1 -0
  45. package/dist/util/config/defaults.js +1 -0
  46. package/dist/util/config/defaults.js.map +1 -1
  47. package/dist/util/config/types.d.ts +1 -0
  48. package/dist/util/write-checkpoint-batcher.d.ts +1 -1
  49. package/dist/util/write-checkpoint-batcher.js +18 -8
  50. package/dist/util/write-checkpoint-batcher.js.map +1 -1
  51. package/package.json +5 -5
  52. package/src/api/RouteAPI.ts +18 -3
  53. package/src/entry/commands/compact-action.ts +6 -0
  54. package/src/replication/AbstractReplicator.ts +42 -12
  55. package/src/routes/endpoints/checkpointing.ts +77 -3
  56. package/src/routes/endpoints/socket-route.ts +2 -1
  57. package/src/storage/BucketStorageBatch.ts +4 -1
  58. package/src/storage/BucketStorageFactory.ts +2 -2
  59. package/src/storage/CheckpointChecksumInvalidatedError.ts +17 -0
  60. package/src/storage/SyncRulesBucketStorage.ts +32 -1
  61. package/src/storage/WriteCheckpointAPI.ts +108 -3
  62. package/src/storage/implementation/BucketDefinitionMapping.ts +4 -4
  63. package/src/storage/storage-index.ts +1 -0
  64. package/src/sync/BucketChecksumState.ts +38 -13
  65. package/src/sync/sync.ts +33 -7
  66. package/src/sync/util.ts +12 -0
  67. package/src/util/checkpointing.ts +2 -1
  68. package/src/util/config/compound-config-collector.ts +12 -0
  69. package/src/util/config/defaults.ts +1 -0
  70. package/src/util/config/types.ts +1 -0
  71. package/src/util/write-checkpoint-batcher.ts +30 -10
  72. package/test/src/AbstractReplicator.test.ts +147 -0
  73. package/test/src/config.test.ts +60 -0
  74. package/test/src/routes/checkpointing.test.ts +54 -0
  75. package/test/src/sync/BucketChecksumState.test.ts +57 -11
  76. package/test/src/sync/util.test.ts +13 -1
  77. package/test/src/util/checkpointing.test.ts +99 -20
  78. package/tsconfig.tsbuildinfo +1 -1
@@ -1,4 +1,4 @@
1
- import { createWriteCheckpoint, WriteCheckpointBatcher } from '@/index.js';
1
+ import { createWriteCheckpoint, storage, WriteCheckpointBatcher } from '@/index.js';
2
2
  import { describe, expect, test, vi } from 'vitest';
3
3
 
4
4
  function deferred<T = void>() {
@@ -10,11 +10,14 @@ async function waitForAsyncWork() {
10
10
  await new Promise((resolve) => setImmediate(resolve));
11
11
  }
12
12
 
13
- function createStorage() {
13
+ function createStorage(options?: { shouldAdvance?: (checkpoint: { user_id: string }) => boolean }) {
14
14
  let nextCheckpoint = 1n;
15
15
  const bucketStorage = {
16
16
  createManagedWriteCheckpoints: vi.fn(async (checkpoints: { user_id: string }[]) => {
17
- return new Map(checkpoints.map((checkpoint) => [checkpoint.user_id, nextCheckpoint++]));
17
+ return {
18
+ writeCheckpoints: new Map(checkpoints.map((checkpoint) => [checkpoint.user_id, nextCheckpoint++])),
19
+ shouldAdvance: checkpoints.some((checkpoint) => options?.shouldAdvance?.(checkpoint) ?? true)
20
+ };
18
21
  })
19
22
  };
20
23
 
@@ -25,6 +28,31 @@ function createStorage() {
25
28
  return { bucketStorage, storage };
26
29
  }
27
30
 
31
+ /**
32
+ * Mock RouteAPI.createReplicationHead. The callback persists the write-checkpoint
33
+ * mapping and returns { response, shouldAdvance }; the source marker (tracked by
34
+ * `advanceMarker`) is only forced when shouldAdvance is true. An optional gate per
35
+ * batch keeps the call in flight so concurrency can be exercised.
36
+ */
37
+ function createApi(options?: { gates?: PromiseWithResolvers<void>[] }) {
38
+ const advanceMarker = vi.fn(async (_head: string) => undefined);
39
+ const createReplicationHead = vi.fn(
40
+ async (callback: (head: string) => Promise<{ response: unknown; shouldAdvance: boolean }>) => {
41
+ const batch = createReplicationHead.mock.calls.length;
42
+ const head = `head-${batch}`;
43
+ const { response, shouldAdvance } = await callback(head);
44
+ if (shouldAdvance) {
45
+ await advanceMarker(head);
46
+ }
47
+ if (options?.gates) {
48
+ await options.gates[batch - 1].promise;
49
+ }
50
+ return response;
51
+ }
52
+ );
53
+ return { createReplicationHead, advanceMarker };
54
+ }
55
+
28
56
  function createBatcher(api: any, storage: any) {
29
57
  return new WriteCheckpointBatcher(
30
58
  () => api,
@@ -33,17 +61,25 @@ function createBatcher(api: any, storage: any) {
33
61
  }
34
62
 
35
63
  describe('write checkpoint batching', () => {
64
+ test('deduplicates managed checkpoints by greatest supplied request id per user', () => {
65
+ expect(
66
+ storage.uniqueManagedWriteCheckpoints([
67
+ { user_id: 'user-a', heads: { '1': 'generated' } },
68
+ { user_id: 'user-a', heads: { '1': 'stale' }, checkpoint_request_id: 41n },
69
+ { user_id: 'user-a', heads: { '1': 'winner' }, checkpoint_request_id: 43n },
70
+ { user_id: 'user-a', heads: { '1': 'lower' }, checkpoint_request_id: 42n },
71
+ { user_id: 'user-b', heads: { '1': 'generated-b' } }
72
+ ])
73
+ ).toEqual([
74
+ { user_id: 'user-a', heads: { '1': 'winner' }, checkpoint_request_id: 43n },
75
+ { user_id: 'user-b', heads: { '1': 'generated-b' } }
76
+ ]);
77
+ });
78
+
36
79
  test('coalesces same-turn requests and dispatches queued requests as capacity becomes available', async () => {
37
80
  const gates = [deferred(), deferred()];
38
81
  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
- };
82
+ const api = createApi({ gates });
47
83
  const batcher = createBatcher(api, storage);
48
84
 
49
85
  const firstBatch = [
@@ -92,17 +128,58 @@ describe('write checkpoint batching', () => {
92
128
  ]);
93
129
  });
94
130
 
131
+ test('passes supplied checkpoint request ids into the storage batch', async () => {
132
+ const { bucketStorage, storage } = createStorage();
133
+ const api = createApi();
134
+ const batcher = createBatcher(api, storage);
135
+
136
+ await expect(
137
+ createWriteCheckpoint({
138
+ userId: 'user-a',
139
+ clientId: 'client-1',
140
+ checkpointRequestId: 42n,
141
+ batcher
142
+ })
143
+ ).resolves.toEqual({ writeCheckpoint: '1', replicationHead: 'head-1' });
144
+
145
+ expect(bucketStorage.createManagedWriteCheckpoints).toHaveBeenCalledWith([
146
+ {
147
+ user_id: 'user-a/client-1',
148
+ heads: { '1': 'head-1' },
149
+ checkpoint_request_id: 42n
150
+ }
151
+ ]);
152
+ });
153
+
154
+ test('does not advance the source marker when storage reports no checkpoint updates', async () => {
155
+ const { storage } = createStorage({ shouldAdvance: () => false });
156
+ const api = createApi();
157
+ const batcher = createBatcher(api, storage);
158
+
159
+ await expect(createWriteCheckpoint({ userId: 'user-a', clientId: undefined, batcher })).resolves.toEqual({
160
+ writeCheckpoint: '1',
161
+ replicationHead: 'head-1'
162
+ });
163
+
164
+ expect(api.createReplicationHead).toHaveBeenCalledTimes(1);
165
+ expect(api.advanceMarker).not.toHaveBeenCalled();
166
+ });
167
+
168
+ test('advances the source marker when storage reports a checkpoint update', async () => {
169
+ const { storage } = createStorage();
170
+ const api = createApi();
171
+ const batcher = createBatcher(api, storage);
172
+
173
+ await createWriteCheckpoint({ userId: 'user-a', clientId: undefined, batcher });
174
+
175
+ expect(api.advanceMarker).toHaveBeenCalledTimes(1);
176
+ expect(api.advanceMarker).toHaveBeenCalledWith('head-1');
177
+ });
178
+
95
179
  test('allows three executing batches and queues later requests until one completes', async () => {
96
180
  const gates = [deferred(), deferred(), deferred(), deferred()];
97
181
  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
- };
182
+ const api = createApi({ gates });
106
183
  const batcher = createBatcher(api, storage);
107
184
 
108
185
  const first = createWriteCheckpoint({
@@ -160,11 +237,13 @@ describe('write checkpoint batching', () => {
160
237
  const api = {
161
238
  createReplicationHead: vi.fn(async () => {
162
239
  throw error;
163
- })
240
+ }),
241
+ advanceMarker: vi.fn(async () => undefined)
164
242
  };
165
243
  const batcher = createBatcher(api, storage);
166
244
 
167
245
  await expect(createWriteCheckpoint({ userId: 'user-a', clientId: undefined, batcher })).rejects.toBe(error);
168
246
  expect(api.createReplicationHead).toHaveBeenCalledTimes(1);
247
+ expect(api.advanceMarker).not.toHaveBeenCalled();
169
248
  });
170
249
  });