@powersync/service-core 0.0.0-dev-20241111122558 → 0.0.0-dev-20241119082750

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 (34) hide show
  1. package/CHANGELOG.md +12 -5
  2. package/dist/routes/configure-fastify.d.ts +16 -0
  3. package/dist/routes/configure-fastify.js +2 -1
  4. package/dist/routes/configure-fastify.js.map +1 -1
  5. package/dist/routes/endpoints/probes.d.ts +74 -0
  6. package/dist/routes/endpoints/probes.js +51 -0
  7. package/dist/routes/endpoints/probes.js.map +1 -0
  8. package/dist/routes/router.d.ts +2 -2
  9. package/dist/storage/BucketStorage.d.ts +10 -0
  10. package/dist/storage/BucketStorage.js.map +1 -1
  11. package/dist/storage/mongo/MongoBucketBatch.d.ts +11 -1
  12. package/dist/storage/mongo/MongoBucketBatch.js +55 -39
  13. package/dist/storage/mongo/MongoBucketBatch.js.map +1 -1
  14. package/dist/storage/mongo/MongoSyncBucketStorage.js +9 -1
  15. package/dist/storage/mongo/MongoSyncBucketStorage.js.map +1 -1
  16. package/dist/storage/mongo/MongoWriteCheckpointAPI.js +9 -2
  17. package/dist/storage/mongo/MongoWriteCheckpointAPI.js.map +1 -1
  18. package/dist/storage/mongo/OperationBatch.d.ts +6 -1
  19. package/dist/storage/mongo/OperationBatch.js +9 -0
  20. package/dist/storage/mongo/OperationBatch.js.map +1 -1
  21. package/package.json +5 -5
  22. package/src/routes/configure-fastify.ts +2 -1
  23. package/src/routes/endpoints/probes.ts +58 -0
  24. package/src/routes/router.ts +2 -2
  25. package/src/storage/BucketStorage.ts +10 -0
  26. package/src/storage/mongo/MongoBucketBatch.ts +74 -54
  27. package/src/storage/mongo/MongoSyncBucketStorage.ts +13 -12
  28. package/src/storage/mongo/MongoWriteCheckpointAPI.ts +9 -2
  29. package/src/storage/mongo/OperationBatch.ts +10 -1
  30. package/test/src/routes/probes.integration.test.ts +235 -0
  31. package/test/src/routes/probes.test.ts +153 -0
  32. package/test/src/sync.test.ts +62 -1
  33. package/test/src/util.ts +2 -1
  34. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,235 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import Fastify, { FastifyInstance } from 'fastify';
3
+ import { container } from '@powersync/lib-services-framework';
4
+ import * as auth from '../../../src/routes/auth.js';
5
+ import * as system from '../../../src/system/system-index.js';
6
+ import { configureFastifyServer } from '../../../src/index.js';
7
+ import { ProbeRoutes } from '../../../src/routes/endpoints/probes.js';
8
+
9
+ vi.mock('@powersync/lib-services-framework', async () => {
10
+ const actual = (await vi.importActual('@powersync/lib-services-framework')) as any;
11
+ return {
12
+ ...actual,
13
+ container: {
14
+ ...actual.container,
15
+ probes: {
16
+ state: vi.fn()
17
+ }
18
+ }
19
+ };
20
+ });
21
+
22
+ describe('Probe Routes Integration', () => {
23
+ let app: FastifyInstance;
24
+ let mockSystem: system.ServiceContext;
25
+
26
+ beforeEach(async () => {
27
+ app = Fastify();
28
+ mockSystem = { routerEngine: {} } as system.ServiceContext;
29
+ await configureFastifyServer(app, { service_context: mockSystem });
30
+ await app.ready();
31
+ });
32
+
33
+ afterEach(async () => {
34
+ await app.close();
35
+ });
36
+
37
+ describe('Startup Probe', () => {
38
+ it('returns 200 when system is started', async () => {
39
+ const mockState = {
40
+ started: true,
41
+ ready: true,
42
+ touched_at: new Date()
43
+ };
44
+ vi.spyOn(auth, 'authUser').mockResolvedValue({ authorized: true });
45
+ vi.mocked(container.probes.state).mockReturnValue(mockState);
46
+
47
+ const response = await app.inject({
48
+ method: 'GET',
49
+ url: ProbeRoutes.STARTUP
50
+ });
51
+
52
+ expect(response.statusCode).toBe(200);
53
+ expect(JSON.parse(response.payload)).toEqual({
54
+ ...mockState,
55
+ touched_at: mockState.touched_at.toISOString()
56
+ });
57
+ });
58
+
59
+ it('returns 400 when system is not started', async () => {
60
+ const mockState = {
61
+ started: false,
62
+ ready: false,
63
+ touched_at: new Date()
64
+ };
65
+
66
+ vi.mocked(container.probes.state).mockReturnValue(mockState);
67
+
68
+ const response = await app.inject({
69
+ method: 'GET',
70
+ url: ProbeRoutes.STARTUP
71
+ });
72
+
73
+ expect(response.statusCode).toBe(400);
74
+ expect(JSON.parse(response.payload)).toEqual({
75
+ ...mockState,
76
+ touched_at: mockState.touched_at.toISOString()
77
+ });
78
+ });
79
+ });
80
+
81
+ describe('Liveness Probe', () => {
82
+ it('returns 200 when system was touched recently', async () => {
83
+ const mockState = {
84
+ started: true,
85
+ ready: true,
86
+ touched_at: new Date()
87
+ };
88
+
89
+ vi.mocked(container.probes.state).mockReturnValue(mockState);
90
+
91
+ const response = await app.inject({
92
+ method: 'GET',
93
+ url: ProbeRoutes.LIVENESS
94
+ });
95
+
96
+ expect(response.statusCode).toBe(200);
97
+ expect(JSON.parse(response.payload)).toEqual({
98
+ ...mockState,
99
+ touched_at: mockState.touched_at.toISOString()
100
+ });
101
+ });
102
+
103
+ it('returns 400 when system has not been touched recently', async () => {
104
+ const mockState = {
105
+ started: true,
106
+ ready: true,
107
+ touched_at: new Date(Date.now() - 15000) // 15 seconds ago
108
+ };
109
+
110
+ vi.mocked(container.probes.state).mockReturnValue(mockState);
111
+
112
+ const response = await app.inject({
113
+ method: 'GET',
114
+ url: ProbeRoutes.LIVENESS
115
+ });
116
+
117
+ expect(response.statusCode).toBe(400);
118
+ expect(JSON.parse(response.payload)).toEqual({
119
+ ...mockState,
120
+ touched_at: mockState.touched_at.toISOString()
121
+ });
122
+ });
123
+ });
124
+
125
+ describe('Readiness Probe', () => {
126
+ it('returns 200 when system is ready', async () => {
127
+ const mockState = {
128
+ started: true,
129
+ ready: true,
130
+ touched_at: new Date()
131
+ };
132
+
133
+ vi.mocked(container.probes.state).mockReturnValue(mockState);
134
+
135
+ const response = await app.inject({
136
+ method: 'GET',
137
+ url: ProbeRoutes.READINESS
138
+ });
139
+
140
+ expect(response.statusCode).toBe(200);
141
+ expect(JSON.parse(response.payload)).toEqual({
142
+ ...mockState,
143
+ touched_at: mockState.touched_at.toISOString()
144
+ });
145
+ });
146
+
147
+ it('returns 400 when system is not ready', async () => {
148
+ const mockState = {
149
+ started: true,
150
+ ready: false,
151
+ touched_at: new Date()
152
+ };
153
+
154
+ vi.mocked(container.probes.state).mockReturnValue(mockState);
155
+
156
+ const response = await app.inject({
157
+ method: 'GET',
158
+ url: ProbeRoutes.READINESS
159
+ });
160
+
161
+ expect(response.statusCode).toBe(400);
162
+ expect(JSON.parse(response.payload)).toEqual({
163
+ ...mockState,
164
+ touched_at: mockState.touched_at.toISOString()
165
+ });
166
+ });
167
+ });
168
+
169
+ describe('Request Queue Behavior', () => {
170
+ it('handles concurrent requests within limits', async () => {
171
+ const mockState = { started: true, ready: true, touched_at: new Date() };
172
+ vi.mocked(container.probes.state).mockReturnValue(mockState);
173
+
174
+ // Create array of 15 concurrent requests (default concurrency is 10)
175
+ const requests = Array(15)
176
+ .fill(null)
177
+ .map(() =>
178
+ app.inject({
179
+ method: 'GET',
180
+ url: ProbeRoutes.STARTUP
181
+ })
182
+ );
183
+
184
+ const responses = await Promise.all(requests);
185
+
186
+ // All requests should complete successfully
187
+ responses.forEach((response) => {
188
+ expect(response.statusCode).toBe(200);
189
+ expect(JSON.parse(response.payload)).toEqual({
190
+ ...mockState,
191
+ touched_at: mockState.touched_at.toISOString()
192
+ });
193
+ });
194
+ });
195
+
196
+ it('respects max queue depth', async () => {
197
+ const mockState = { started: true, ready: true, touched_at: new Date() };
198
+ vi.mocked(container.probes.state).mockReturnValue(mockState);
199
+
200
+ // Create array of 35 concurrent requests (default max_queue_depth is 20)
201
+ const requests = Array(35)
202
+ .fill(null)
203
+ .map(() =>
204
+ app.inject({
205
+ method: 'GET',
206
+ url: ProbeRoutes.STARTUP
207
+ })
208
+ );
209
+
210
+ const responses = await Promise.all(requests);
211
+
212
+ // Some requests should succeed and some should fail with 429
213
+ const successCount = responses.filter((r) => r.statusCode === 200).length;
214
+ const queueFullCount = responses.filter((r) => r.statusCode === 429).length;
215
+
216
+ expect(successCount).toBeGreaterThan(0);
217
+ expect(queueFullCount).toBeGreaterThan(0);
218
+ expect(successCount + queueFullCount).toBe(35);
219
+ });
220
+ });
221
+
222
+ describe('Content Types', () => {
223
+ it('returns correct content type headers', async () => {
224
+ const mockState = { started: true, ready: true, touched_at: new Date() };
225
+ vi.mocked(container.probes.state).mockReturnValue(mockState);
226
+
227
+ const response = await app.inject({
228
+ method: 'GET',
229
+ url: ProbeRoutes.STARTUP
230
+ });
231
+
232
+ expect(response.headers['content-type']).toMatch(/application\/json/);
233
+ });
234
+ });
235
+ });
@@ -0,0 +1,153 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { container } from '@powersync/lib-services-framework';
3
+ import { startupCheck, livenessCheck, readinessCheck } from '../../../src/routes/endpoints/probes.js';
4
+
5
+ // Mock the container
6
+ vi.mock('@powersync/lib-services-framework', () => ({
7
+ container: {
8
+ probes: {
9
+ state: vi.fn()
10
+ }
11
+ },
12
+ router: {
13
+ HTTPMethod: {
14
+ GET: 'GET'
15
+ },
16
+ RouterResponse: class RouterResponse {
17
+ status: number;
18
+ data: any;
19
+ headers: Record<string, string>;
20
+ afterSend: () => Promise<void>;
21
+ __micro_router_response = true;
22
+
23
+ constructor({ status, data, headers, afterSend }: {
24
+ status?: number;
25
+ data: any;
26
+ headers?: Record<string, string>;
27
+ afterSend?: () => Promise<void>;
28
+ }) {
29
+ this.status = status || 200;
30
+ this.data = data;
31
+ this.headers = headers || { 'Content-Type': 'application/json' };
32
+ this.afterSend = afterSend ?? (() => Promise.resolve());
33
+ }
34
+ }
35
+ }
36
+ }));
37
+
38
+ describe('Probe Routes', () => {
39
+ beforeEach(() => {
40
+ vi.clearAllMocks();
41
+ });
42
+
43
+ describe('startupCheck', () => {
44
+ it('has the correct route definitions', () => {
45
+ expect(startupCheck.path).toBe('/probes/startup');
46
+ expect(startupCheck.method).toBe('GET');
47
+ });
48
+
49
+ it('returns 200 when started is true', async () => {
50
+ const mockState = {
51
+ started: true,
52
+ ready: true,
53
+ touched_at: new Date()
54
+ };
55
+
56
+ vi.mocked(container.probes.state).mockReturnValue(mockState);
57
+
58
+ const response = await startupCheck.handler();
59
+
60
+ expect(response.status).toEqual(200);
61
+ expect(response.data).toEqual(mockState);
62
+ });
63
+
64
+ it('returns 400 when started is false', async () => {
65
+ const mockState = {
66
+ started: false,
67
+ ready: false,
68
+ touched_at: new Date()
69
+ };
70
+
71
+ vi.mocked(container.probes.state).mockReturnValue(mockState);
72
+
73
+ const response = await startupCheck.handler();
74
+
75
+ expect(response.status).toBe(400);
76
+ expect(response.data).toEqual(mockState);
77
+ });
78
+ });
79
+
80
+ describe('livenessCheck', () => {
81
+ it('has the correct route definitions', () => {
82
+ expect(livenessCheck.path).toBe('/probes/liveness');
83
+ expect(livenessCheck.method).toBe('GET');
84
+ });
85
+
86
+ it('returns 200 when last touch was less than 10 seconds ago', async () => {
87
+ const mockState = {
88
+ started: true,
89
+ ready: true,
90
+ touched_at: new Date(Date.now() - 9000) // 11 seconds ago
91
+ };
92
+
93
+ vi.mocked(container.probes.state).mockReturnValue(mockState);
94
+
95
+ const response = await livenessCheck.handler();
96
+
97
+ expect(response.status).toBe(200);
98
+ expect(response.data).toEqual(mockState);
99
+ });
100
+
101
+ it('returns 400 when last touch was more than 10 seconds ago', async () => {
102
+ const mockState = {
103
+ started: true,
104
+ ready: true,
105
+ touched_at: new Date(Date.now() - 11000)
106
+ };
107
+
108
+ vi.mocked(container.probes.state).mockReturnValue(mockState);
109
+
110
+ const response = await livenessCheck.handler();
111
+
112
+ expect(response.status).toBe(400);
113
+ expect(response.data).toEqual(mockState);
114
+ });
115
+ });
116
+
117
+ describe('readinessCheck', () => {
118
+ it('has the correct route definitions', () => {
119
+ expect(readinessCheck.path).toBe('/probes/readiness');
120
+ expect(readinessCheck.method).toBe('GET');
121
+ });
122
+
123
+ it('returns 200 when ready is true', async () => {
124
+ const mockState = {
125
+ started: true,
126
+ ready: true,
127
+ touched_at: new Date()
128
+ };
129
+
130
+ vi.mocked(container.probes.state).mockReturnValue(mockState);
131
+
132
+ const response = await readinessCheck.handler();
133
+
134
+ expect(response.status).toBe(200);
135
+ expect(response.data).toEqual(mockState);
136
+ });
137
+
138
+ it('returns 400 when ready is false', async () => {
139
+ const mockState = {
140
+ started: true,
141
+ ready: false,
142
+ touched_at: new Date()
143
+ };
144
+
145
+ vi.mocked(container.probes.state).mockReturnValue(mockState);
146
+
147
+ const response = await readinessCheck.handler();
148
+
149
+ expect(response.status).toBe(400);
150
+ expect(response.data).toEqual(mockState);
151
+ });
152
+ });
153
+ });
@@ -1,6 +1,6 @@
1
1
  import { SaveOperationTag } from '@/storage/storage-index.js';
2
2
  import { RequestTracker } from '@/sync/RequestTracker.js';
3
- import { streamResponse } from '@/sync/sync.js';
3
+ import { streamResponse, SyncStreamParameters } from '@/sync/sync.js';
4
4
  import { StreamingSyncLine } from '@/util/protocol-types.js';
5
5
  import { JSONBig } from '@powersync/service-jsonbig';
6
6
  import { RequestParameters } from '@powersync/service-sync-rules';
@@ -381,6 +381,67 @@ function defineTests(factory: StorageFactory) {
381
381
  })
382
382
  });
383
383
  });
384
+
385
+ test('write checkpoint', async () => {
386
+ const f = await factory();
387
+
388
+ const syncRules = await f.updateSyncRules({
389
+ content: BASIC_SYNC_RULES
390
+ });
391
+
392
+ const storage = f.getInstance(syncRules);
393
+ await storage.autoActivate();
394
+
395
+ await storage.startBatch(BATCH_OPTIONS, async (batch) => {
396
+ // <= the managed write checkpoint LSN below
397
+ await batch.commit('0/1');
398
+ });
399
+
400
+ const checkpoint = await storage.createManagedWriteCheckpoint({
401
+ user_id: 'test',
402
+ heads: { '1': '1/0' }
403
+ });
404
+
405
+ const params: SyncStreamParameters = {
406
+ storage: f,
407
+ params: {
408
+ buckets: [],
409
+ include_checksum: true,
410
+ raw_data: true
411
+ },
412
+ parseOptions: PARSE_OPTIONS,
413
+ tracker,
414
+ syncParams: new RequestParameters({ sub: 'test' }, {}),
415
+ token: { sub: 'test', exp: Date.now() / 1000 + 10 } as any
416
+ };
417
+ const stream1 = streamResponse(params);
418
+ const lines1 = await consumeCheckpointLines(stream1);
419
+
420
+ // If write checkpoints are not correctly filtered, this may already
421
+ // contain the write checkpoint.
422
+ expect(lines1[0]).toMatchObject({
423
+ checkpoint: expect.objectContaining({
424
+ last_op_id: '0',
425
+ write_checkpoint: undefined
426
+ })
427
+ });
428
+
429
+ await storage.startBatch(BATCH_OPTIONS, async (batch) => {
430
+ // must be >= the managed write checkpoint LSN
431
+ await batch.commit('1/0');
432
+ });
433
+
434
+ // At this point the LSN has advanced, so the write checkpoint should be
435
+ // included in the next checkpoint message.
436
+ const stream2 = streamResponse(params);
437
+ const lines2 = await consumeCheckpointLines(stream2);
438
+ expect(lines2[0]).toMatchObject({
439
+ checkpoint: expect.objectContaining({
440
+ last_op_id: '0',
441
+ write_checkpoint: `${checkpoint}`
442
+ })
443
+ });
444
+ });
384
445
  }
385
446
 
386
447
  /**
package/test/src/util.ts CHANGED
@@ -51,7 +51,8 @@ export const PARSE_OPTIONS: ParseSyncRulesOptions = {
51
51
 
52
52
  export const BATCH_OPTIONS: StartBatchOptions = {
53
53
  ...PARSE_OPTIONS,
54
- zeroLSN: ZERO_LSN
54
+ zeroLSN: ZERO_LSN,
55
+ storeCurrentData: true
55
56
  };
56
57
 
57
58
  export function testRules(content: string): PersistedSyncRulesContent {