@powersync/service-core 1.21.0 → 1.23.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 (151) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/api/RouteAPI.d.ts +2 -2
  3. package/dist/api/diagnostics.d.ts +12 -1
  4. package/dist/api/diagnostics.js +34 -22
  5. package/dist/api/diagnostics.js.map +1 -1
  6. package/dist/auth/RemoteJWKSCollector.js +3 -2
  7. package/dist/auth/RemoteJWKSCollector.js.map +1 -1
  8. package/dist/entry/commands/compact-action.js +1 -1
  9. package/dist/entry/commands/compact-action.js.map +1 -1
  10. package/dist/modules/AbstractModule.d.ts +6 -2
  11. package/dist/modules/AbstractModule.js.map +1 -1
  12. package/dist/replication/AbstractReplicator.d.ts +2 -2
  13. package/dist/replication/AbstractReplicator.js +24 -23
  14. package/dist/replication/AbstractReplicator.js.map +1 -1
  15. package/dist/replication/RelationCache.d.ts +9 -2
  16. package/dist/replication/RelationCache.js +21 -2
  17. package/dist/replication/RelationCache.js.map +1 -1
  18. package/dist/replication/ReplicationModule.d.ts +3 -3
  19. package/dist/replication/ReplicationModule.js.map +1 -1
  20. package/dist/routes/configure-fastify.d.ts +12 -12
  21. package/dist/routes/configure-fastify.js +3 -1
  22. package/dist/routes/configure-fastify.js.map +1 -1
  23. package/dist/routes/endpoints/admin.d.ts +24 -24
  24. package/dist/routes/endpoints/admin.js +49 -35
  25. package/dist/routes/endpoints/admin.js.map +1 -1
  26. package/dist/routes/endpoints/checkpointing.js +1 -1
  27. package/dist/routes/endpoints/checkpointing.js.map +1 -1
  28. package/dist/routes/endpoints/socket-route.js +1 -1
  29. package/dist/routes/endpoints/socket-route.js.map +1 -1
  30. package/dist/routes/endpoints/sync-rules.js +22 -13
  31. package/dist/routes/endpoints/sync-rules.js.map +1 -1
  32. package/dist/routes/endpoints/sync-stream.js +1 -1
  33. package/dist/routes/endpoints/sync-stream.js.map +1 -1
  34. package/dist/routes/route-register.d.ts +2 -0
  35. package/dist/routes/route-register.js +65 -3
  36. package/dist/routes/route-register.js.map +1 -1
  37. package/dist/runner/teardown.js +8 -8
  38. package/dist/runner/teardown.js.map +1 -1
  39. package/dist/storage/BucketStorageBatch.d.ts +29 -6
  40. package/dist/storage/BucketStorageBatch.js.map +1 -1
  41. package/dist/storage/BucketStorageFactory.d.ts +26 -27
  42. package/dist/storage/BucketStorageFactory.js +5 -19
  43. package/dist/storage/BucketStorageFactory.js.map +1 -1
  44. package/dist/storage/ParsedSyncConfigSet.d.ts +23 -0
  45. package/dist/storage/ParsedSyncConfigSet.js +2 -0
  46. package/dist/storage/ParsedSyncConfigSet.js.map +1 -0
  47. package/dist/storage/PersistedReplicationStream.d.ts +32 -0
  48. package/dist/storage/PersistedReplicationStream.js +26 -0
  49. package/dist/storage/PersistedReplicationStream.js.map +1 -0
  50. package/dist/storage/PersistedSyncConfigContent.d.ts +60 -0
  51. package/dist/storage/PersistedSyncConfigContent.js +123 -0
  52. package/dist/storage/PersistedSyncConfigContent.js.map +1 -0
  53. package/dist/storage/PersistedSyncConfigStatus.d.ts +13 -0
  54. package/dist/storage/PersistedSyncConfigStatus.js +2 -0
  55. package/dist/storage/PersistedSyncConfigStatus.js.map +1 -0
  56. package/dist/storage/SourceEntity.d.ts +8 -1
  57. package/dist/storage/SourceTable.d.ts +37 -10
  58. package/dist/storage/SourceTable.js +50 -14
  59. package/dist/storage/SourceTable.js.map +1 -1
  60. package/dist/storage/SyncRulesBucketStorage.d.ts +80 -24
  61. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  62. package/dist/storage/implementation/BucketDefinitionMapping.d.ts +117 -0
  63. package/dist/storage/implementation/BucketDefinitionMapping.js +277 -0
  64. package/dist/storage/implementation/BucketDefinitionMapping.js.map +1 -0
  65. package/dist/storage/implementation/IncrementalReprocessingCompatibility.d.ts +6 -0
  66. package/dist/storage/implementation/IncrementalReprocessingCompatibility.js +31 -0
  67. package/dist/storage/implementation/IncrementalReprocessingCompatibility.js.map +1 -0
  68. package/dist/storage/implementation/IncrementalReprocessingSyncConfigLog.d.ts +17 -0
  69. package/dist/storage/implementation/IncrementalReprocessingSyncConfigLog.js +62 -0
  70. package/dist/storage/implementation/IncrementalReprocessingSyncConfigLog.js.map +1 -0
  71. package/dist/storage/implementation/ReplicationStreamStorageIds.d.ts +15 -0
  72. package/dist/storage/implementation/ReplicationStreamStorageIds.js +20 -0
  73. package/dist/storage/implementation/ReplicationStreamStorageIds.js.map +1 -0
  74. package/dist/storage/storage-index.d.ts +8 -1
  75. package/dist/storage/storage-index.js +8 -1
  76. package/dist/storage/storage-index.js.map +1 -1
  77. package/dist/sync/BucketChecksumState.d.ts +4 -4
  78. package/dist/sync/BucketChecksumState.js +1 -1
  79. package/dist/sync/BucketChecksumState.js.map +1 -1
  80. package/dist/sync/sync.d.ts +2 -2
  81. package/dist/sync/sync.js +5 -0
  82. package/dist/sync/sync.js.map +1 -1
  83. package/dist/tracing/PerformanceTracer.d.ts +17 -1
  84. package/dist/tracing/PerformanceTracer.js +3 -0
  85. package/dist/tracing/PerformanceTracer.js.map +1 -1
  86. package/dist/util/checkpointing.js +1 -1
  87. package/dist/util/checkpointing.js.map +1 -1
  88. package/dist/util/config/types.d.ts +1 -1
  89. package/dist/util/lsn.d.ts +4 -0
  90. package/dist/util/lsn.js +10 -0
  91. package/dist/util/lsn.js.map +1 -1
  92. package/dist/util/util-index.d.ts +1 -0
  93. package/dist/util/util-index.js +1 -0
  94. package/dist/util/util-index.js.map +1 -1
  95. package/dist/util/utils.d.ts +5 -0
  96. package/dist/util/utils.js +7 -0
  97. package/dist/util/utils.js.map +1 -1
  98. package/package.json +6 -7
  99. package/src/api/RouteAPI.ts +2 -2
  100. package/src/api/diagnostics.ts +45 -28
  101. package/src/auth/RemoteJWKSCollector.ts +3 -1
  102. package/src/entry/commands/compact-action.ts +1 -1
  103. package/src/modules/AbstractModule.ts +6 -2
  104. package/src/replication/AbstractReplicator.ts +27 -26
  105. package/src/replication/RelationCache.ts +23 -4
  106. package/src/replication/ReplicationModule.ts +2 -3
  107. package/src/routes/configure-fastify.ts +8 -1
  108. package/src/routes/endpoints/admin.ts +75 -49
  109. package/src/routes/endpoints/checkpointing.ts +1 -1
  110. package/src/routes/endpoints/socket-route.ts +1 -1
  111. package/src/routes/endpoints/sync-rules.ts +23 -13
  112. package/src/routes/endpoints/sync-stream.ts +1 -1
  113. package/src/routes/route-register.ts +73 -4
  114. package/src/runner/teardown.ts +8 -8
  115. package/src/storage/BucketStorageBatch.ts +32 -7
  116. package/src/storage/BucketStorageFactory.ts +36 -40
  117. package/src/storage/ParsedSyncConfigSet.ts +25 -0
  118. package/src/storage/PersistedReplicationStream.ts +55 -0
  119. package/src/storage/PersistedSyncConfigContent.ts +188 -0
  120. package/src/storage/PersistedSyncConfigStatus.ts +14 -0
  121. package/src/storage/SourceEntity.ts +9 -1
  122. package/src/storage/SourceTable.ts +72 -21
  123. package/src/storage/SyncRulesBucketStorage.ts +87 -26
  124. package/src/storage/implementation/BucketDefinitionMapping.ts +436 -0
  125. package/src/storage/implementation/IncrementalReprocessingCompatibility.ts +41 -0
  126. package/src/storage/implementation/IncrementalReprocessingSyncConfigLog.ts +109 -0
  127. package/src/storage/implementation/ReplicationStreamStorageIds.ts +21 -0
  128. package/src/storage/storage-index.ts +9 -1
  129. package/src/sync/BucketChecksumState.ts +5 -5
  130. package/src/sync/sync.ts +8 -3
  131. package/src/tracing/PerformanceTracer.ts +24 -1
  132. package/src/util/checkpointing.ts +1 -1
  133. package/src/util/config/types.ts +1 -1
  134. package/src/util/lsn.ts +9 -0
  135. package/src/util/util-index.ts +1 -0
  136. package/src/util/utils.ts +8 -0
  137. package/test/src/auth.test.ts +11 -0
  138. package/test/src/diagnostics.test.ts +64 -33
  139. package/test/src/module-loader.test.ts +33 -1
  140. package/test/src/routes/admin.test.ts +180 -18
  141. package/test/src/routes/error-handler.integration.test.ts +275 -0
  142. package/test/src/routes/mocks.ts +6 -2
  143. package/test/src/routes/stream.test.ts +15 -4
  144. package/test/src/storage/SourceTable.test.ts +89 -0
  145. package/test/src/sync/BucketChecksumState.test.ts +25 -17
  146. package/tsconfig.json +1 -4
  147. package/tsconfig.tsbuildinfo +1 -1
  148. package/dist/storage/PersistedSyncRulesContent.d.ts +0 -59
  149. package/dist/storage/PersistedSyncRulesContent.js +0 -100
  150. package/dist/storage/PersistedSyncRulesContent.js.map +0 -1
  151. package/src/storage/PersistedSyncRulesContent.ts +0 -174
@@ -1,27 +1,90 @@
1
- import { BasicRouterRequest, Context, JwtPayload } from '@/index.js';
1
+ import { BasicRouterRequest, Context, JwtPayload, ParsedSyncConfigSet, storage } from '@/index.js';
2
2
  import { logger } from '@powersync/lib-services-framework';
3
- import { describe, expect, it } from 'vitest';
4
- import { validate } from '../../../src/routes/endpoints/admin.js';
3
+ import { SqlSyncRules } from '@powersync/service-sync-rules';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { diagnostics, reprocess, validate } from '../../../src/routes/endpoints/admin.js';
5
6
  import { mockServiceContext } from './mocks.js';
6
7
 
7
8
  describe('admin routes', () => {
9
+ const request: BasicRouterRequest = {
10
+ headers: {},
11
+ hostname: '',
12
+ protocol: 'http'
13
+ };
14
+
15
+ function makeContext(activeBucketStorage?: any): Context {
16
+ const service_context = mockServiceContext(null);
17
+ if (activeBucketStorage != null) {
18
+ (service_context.storageEngine as any).activeBucketStorage = activeBucketStorage;
19
+ }
20
+
21
+ return {
22
+ logger: logger,
23
+ service_context,
24
+ token_payload: new JwtPayload({
25
+ sub: '',
26
+ exp: 0,
27
+ iat: 0
28
+ })
29
+ };
30
+ }
31
+
32
+ function makeSyncConfigContent(options: {
33
+ id?: number;
34
+ syncConfigId?: string;
35
+ active?: boolean;
36
+ content?: string;
37
+ }): storage.PersistedSyncConfigContent {
38
+ const id = options.id ?? 1;
39
+ const syncConfigId = options.syncConfigId ?? String(id);
40
+ const active = options.active ?? true;
41
+ const state = active ? storage.SyncRuleState.ACTIVE : storage.SyncRuleState.PROCESSING;
42
+ const lastKeepaliveTs = new Date('2026-01-01T00:00:00.000Z');
43
+ const lastCheckpointTs = new Date('2026-01-01T00:00:00.000Z');
44
+ const content = {
45
+ replicationStreamId: id,
46
+ syncConfigId,
47
+ replicationStreamName: `slot_${id}`,
48
+ sync_rules_content:
49
+ options.content ??
50
+ `
51
+ bucket_definitions:
52
+ global:
53
+ data:
54
+ - SELECT id FROM test
55
+ `,
56
+ compiled_plan: null,
57
+ storageVersion: storage.LEGACY_STORAGE_VERSION,
58
+ parsed(options?: any) {
59
+ const syncRules = SqlSyncRules.fromYaml(content.sync_rules_content, {
60
+ ...options,
61
+ defaultSchema: 'public'
62
+ });
63
+ return {
64
+ syncConfigs: [syncRules]
65
+ } as ParsedSyncConfigSet;
66
+ },
67
+ asUpdateOptions: vi.fn(),
68
+ getStorageConfig: vi.fn(),
69
+ async getSyncConfigStatus() {
70
+ return {
71
+ id: syncConfigId,
72
+ replicationStreamId: id,
73
+ state,
74
+ last_checkpoint_lsn: null,
75
+ last_fatal_error: null,
76
+ last_fatal_error_ts: null,
77
+ last_keepalive_ts: lastKeepaliveTs,
78
+ last_checkpoint_ts: lastCheckpointTs
79
+ };
80
+ }
81
+ };
82
+ return content as unknown as storage.PersistedSyncConfigContent;
83
+ }
84
+
8
85
  describe('validate', () => {
9
86
  it('reports errors with source location', async () => {
10
- const context: Context = {
11
- logger: logger,
12
- service_context: mockServiceContext(null),
13
- token_payload: new JwtPayload({
14
- sub: '',
15
- exp: 0,
16
- iat: 0
17
- })
18
- };
19
-
20
- const request: BasicRouterRequest = {
21
- headers: {},
22
- hostname: '',
23
- protocol: 'http'
24
- };
87
+ const context = makeContext();
25
88
 
26
89
  const response = await validate.handler({
27
90
  context,
@@ -45,4 +108,103 @@ bucket_definitions:
45
108
  ]);
46
109
  });
47
110
  });
111
+
112
+ describe('diagnostics', () => {
113
+ it('returns deploying config status', async () => {
114
+ const active = makeSyncConfigContent({ id: 1, syncConfigId: 'active-config' });
115
+ const deploying = makeSyncConfigContent({ id: 2, syncConfigId: 'deploying-config', active: false });
116
+ const getInstance = vi.fn(() => ({
117
+ async getStatus() {
118
+ return {
119
+ snapshotDone: false,
120
+ resumeLsn: null
121
+ };
122
+ }
123
+ }));
124
+ const activeBucketStorage = {
125
+ getActiveSyncConfig: vi.fn(async () => ({
126
+ content: active,
127
+ replicationStream: {},
128
+ storage: getInstance()
129
+ })),
130
+ getDeployingSyncConfig: vi.fn(async () => ({
131
+ content: deploying,
132
+ replicationStream: {},
133
+ storage: getInstance()
134
+ }))
135
+ };
136
+
137
+ const response = await diagnostics.handler({
138
+ context: makeContext(activeBucketStorage),
139
+ params: {},
140
+ request
141
+ });
142
+
143
+ expect(response.deploying_sync_rules?.connections[0].slot_name).toBe('slot_2');
144
+ expect(response.active_sync_rules?.connections[0].slot_name).toBe('slot_1');
145
+ });
146
+ });
147
+
148
+ describe('reprocess', () => {
149
+ it('reprocesses the active sync config', async () => {
150
+ const active = makeSyncConfigContent({ id: 7, syncConfigId: 'active-config' });
151
+ const updateSyncRules = vi.fn(async () => ({
152
+ replicationStreamId: 8,
153
+ replicationStreamName: 'new_slot',
154
+ state: storage.SyncRuleState.PROCESSING,
155
+ storageVersion: storage.LEGACY_STORAGE_VERSION,
156
+ replicationJobId: '8',
157
+ current_lock: null
158
+ }));
159
+ const activeBucketStorage = {
160
+ getDeployingSyncConfig: vi.fn(async () => null),
161
+ getActiveSyncConfig: vi.fn(async () => ({
162
+ content: active,
163
+ replicationStream: {},
164
+ storage: {}
165
+ })),
166
+ getSyncConfigContent: vi.fn(),
167
+ updateSyncRules
168
+ };
169
+
170
+ const response = await reprocess.handler({
171
+ context: makeContext(activeBucketStorage),
172
+ params: {},
173
+ request
174
+ });
175
+
176
+ expect(activeBucketStorage.getActiveSyncConfig).toHaveBeenCalledTimes(1);
177
+ expect(activeBucketStorage.getSyncConfigContent).not.toHaveBeenCalled();
178
+ expect(updateSyncRules).toHaveBeenCalledTimes(1);
179
+ expect(response.connections[0].slot_name).toBe('new_slot');
180
+ });
181
+
182
+ it('rejects reprocess while a sync config is deploying', async () => {
183
+ const activeBucketStorage = {
184
+ getDeployingSyncConfig: vi.fn(async () => ({
185
+ content: makeSyncConfigContent({ id: 2, active: false }),
186
+ replicationStream: {},
187
+ storage: {}
188
+ })),
189
+ getActiveSyncConfig: vi.fn(),
190
+ updateSyncRules: vi.fn()
191
+ };
192
+
193
+ await expect(
194
+ reprocess.handler({
195
+ context: makeContext(activeBucketStorage),
196
+ params: {},
197
+ request
198
+ })
199
+ ).rejects.toMatchObject({
200
+ errorData: {
201
+ status: 409,
202
+ code: 'PSYNC_S4106',
203
+ description: 'Busy processing sync config - cannot reprocess'
204
+ }
205
+ });
206
+ expect(activeBucketStorage.getActiveSyncConfig).not.toHaveBeenCalled();
207
+ expect(activeBucketStorage.updateSyncRules).not.toHaveBeenCalled();
208
+ });
209
+ });
48
210
  });
@@ -0,0 +1,275 @@
1
+ import { errors } from '@powersync/lib-services-framework';
2
+ import Fastify, { FastifyInstance } from 'fastify';
3
+ import { Readable } from 'node:stream';
4
+ import * as zlib from 'node:zlib';
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
+ import { registerFastifyErrorHandler } from '../../../src/routes/route-register.js';
7
+
8
+ describe('Fastify error handler', () => {
9
+ let app: FastifyInstance;
10
+
11
+ beforeEach(() => {
12
+ app = Fastify();
13
+ registerFastifyErrorHandler(app);
14
+ });
15
+
16
+ afterEach(async () => {
17
+ await app.close();
18
+ });
19
+
20
+ describe('errors thrown in route before sending a response', () => {
21
+ it('returns a JSON error body with no content-encoding when none was set', async () => {
22
+ app.get('/boom', async () => {
23
+ throw new errors.ServiceError({
24
+ status: 503,
25
+ code: 'PSYNC_S2003' as any,
26
+ description: 'Service unavailable'
27
+ });
28
+ });
29
+ await app.ready();
30
+
31
+ const response = await app.inject({ method: 'GET', url: '/boom' });
32
+
33
+ expect(response.statusCode).toBe(503);
34
+ expect(response.headers['content-type']).toMatch(/application\/json/);
35
+ expect(response.headers['content-encoding']).toBeUndefined();
36
+ expect(JSON.parse(response.payload)).toMatchObject({
37
+ error: { code: 'PSYNC_S2003', status: 503 }
38
+ });
39
+ });
40
+
41
+ it('gzip-encodes the error body when content-encoding=gzip was set before the throw', async () => {
42
+ app.get('/boom', async (_request, reply) => {
43
+ reply.header('content-encoding', 'gzip');
44
+ throw new Error('kaboom');
45
+ });
46
+ await app.ready();
47
+
48
+ const response = await app.inject({ method: 'GET', url: '/boom' });
49
+
50
+ expect(response.statusCode).toBe(500);
51
+ expect(response.headers['content-encoding']).toBe('gzip');
52
+ const decoded = zlib.gunzipSync(response.rawPayload).toString('utf8');
53
+ expect(JSON.parse(decoded).error.code).toBe('PSYNC_S2001');
54
+ });
55
+
56
+ it('zstd-encodes the error body when content-encoding=zstd was set before the throw', async () => {
57
+ app.get('/boom', async (_request, reply) => {
58
+ reply.header('content-encoding', 'zstd');
59
+ throw new Error('kaboom');
60
+ });
61
+ await app.ready();
62
+
63
+ const response = await app.inject({ method: 'GET', url: '/boom' });
64
+
65
+ expect(response.statusCode).toBe(500);
66
+ expect(response.headers['content-encoding']).toBe('zstd');
67
+ const decoded = zlib.zstdDecompressSync(response.rawPayload).toString('utf8');
68
+ expect(JSON.parse(decoded).error.code).toBe('PSYNC_S2001');
69
+ });
70
+ });
71
+
72
+ describe('errors after responding with a stream, before any data is sent', () => {
73
+ it('still returns a JSON error response with no encoding', async () => {
74
+ app.get('/stream', async (_request, reply) => {
75
+ const stream = new Readable({
76
+ read() {
77
+ process.nextTick(() => this.destroy(new Error('pre-data failure')));
78
+ }
79
+ });
80
+ reply.header('content-type', 'application/x-ndjson');
81
+ return reply.send(stream);
82
+ });
83
+ await app.ready();
84
+
85
+ const response = await app.inject({ method: 'GET', url: '/stream' });
86
+
87
+ expect(response.statusCode).toBe(500);
88
+ expect(response.headers['content-encoding']).toBeUndefined();
89
+ expect(response.headers['content-type']).toMatch(/application\/json/);
90
+ expect(JSON.parse(response.payload).error.code).toBe('PSYNC_S2001');
91
+ });
92
+
93
+ it('still returns a gzip-encoded JSON error response when encoding was set first', async () => {
94
+ app.get('/stream', async (_request, reply) => {
95
+ reply.header('content-encoding', 'gzip');
96
+ reply.header('content-type', 'application/x-ndjson');
97
+ const stream = new Readable({
98
+ read() {
99
+ process.nextTick(() => this.destroy(new Error('pre-data failure')));
100
+ }
101
+ });
102
+ return reply.send(stream);
103
+ });
104
+ await app.ready();
105
+
106
+ const response = await app.inject({ method: 'GET', url: '/stream' });
107
+
108
+ expect(response.statusCode).toBe(500);
109
+ expect(response.headers['content-encoding']).toBe('gzip');
110
+ const decoded = zlib.gunzipSync(response.rawPayload).toString('utf8');
111
+ expect(JSON.parse(decoded).error.code).toBe('PSYNC_S2001');
112
+ });
113
+ });
114
+
115
+ describe('errors after a stream has sent data', () => {
116
+ it('lets fastify tear the response down without raising an uncaught exception', async () => {
117
+ app.get('/stream', async (_request, reply) => {
118
+ let chunks = 0;
119
+ const stream = new Readable({
120
+ read() {
121
+ if (chunks++ === 0) {
122
+ this.push('first chunk\n');
123
+ } else {
124
+ this.destroy(new Error('mid-stream failure'));
125
+ }
126
+ }
127
+ });
128
+ reply.header('content-type', 'application/x-ndjson');
129
+ return reply.send(stream);
130
+ });
131
+ await app.ready();
132
+
133
+ const uncaught: Error[] = [];
134
+ const onUncaught = (err: Error) => uncaught.push(err);
135
+ const onUnhandled = (err: any) => uncaught.push(err);
136
+ process.on('uncaughtException', onUncaught);
137
+ process.on('unhandledRejection', onUnhandled);
138
+
139
+ try {
140
+ await expect(app.inject({ method: 'GET', url: '/stream' })).rejects.toThrow(/destroyed/);
141
+ await new Promise((resolve) => setImmediate(resolve));
142
+ } finally {
143
+ process.off('uncaughtException', onUncaught);
144
+ process.off('unhandledRejection', onUnhandled);
145
+ }
146
+
147
+ expect(uncaught).toEqual([]);
148
+ });
149
+ });
150
+
151
+ describe('handler inheritance into child scopes', () => {
152
+ it('handles errors thrown in routes registered inside a child scope', async () => {
153
+ await app.register(async (child) => {
154
+ child.get('/boom', async () => {
155
+ throw new Error('child scope failure');
156
+ });
157
+ });
158
+ await app.ready();
159
+
160
+ const response = await app.inject({ method: 'GET', url: '/boom' });
161
+
162
+ expect(response.statusCode).toBe(500);
163
+ expect(response.headers['content-type']).toMatch(/application\/json/);
164
+ const body = JSON.parse(response.payload);
165
+ expect(body.error?.code).toBe('PSYNC_S2001');
166
+ });
167
+ });
168
+
169
+ describe('built-in Fastify errors', () => {
170
+ it('preserves the 400 status code for an invalid JSON body', async () => {
171
+ app.post('/echo', async (request) => ({ received: request.body }));
172
+ await app.ready();
173
+
174
+ const response = await app.inject({
175
+ method: 'POST',
176
+ url: '/echo',
177
+ headers: { 'content-type': 'application/json' },
178
+ payload: '{ not json'
179
+ });
180
+
181
+ expect(response.statusCode).toBe(400);
182
+ const body = JSON.parse(response.payload);
183
+ expect(body.error.status).toBe(400);
184
+ expect(body.error.code).toBe('FST_ERR_CTP_INVALID_JSON_BODY');
185
+ });
186
+
187
+ it('preserves the 400 status code for an empty JSON body', async () => {
188
+ app.post('/echo', async (request) => ({ received: request.body }));
189
+ await app.ready();
190
+
191
+ const response = await app.inject({
192
+ method: 'POST',
193
+ url: '/echo',
194
+ headers: { 'content-type': 'application/json' },
195
+ payload: ''
196
+ });
197
+
198
+ expect(response.statusCode).toBe(400);
199
+ const body = JSON.parse(response.payload);
200
+ expect(body.error.status).toBe(400);
201
+ expect(body.error.code).toBe('FST_ERR_CTP_EMPTY_JSON_BODY');
202
+ });
203
+
204
+ it('preserves the 400 status code for a schema validation failure', async () => {
205
+ app.post(
206
+ '/schema',
207
+ {
208
+ schema: {
209
+ body: {
210
+ type: 'object',
211
+ required: ['name'],
212
+ properties: { name: { type: 'string' } }
213
+ }
214
+ }
215
+ },
216
+ async () => ({ ok: true })
217
+ );
218
+ await app.ready();
219
+
220
+ const response = await app.inject({
221
+ method: 'POST',
222
+ url: '/schema',
223
+ headers: { 'content-type': 'application/json' },
224
+ payload: JSON.stringify({})
225
+ });
226
+
227
+ expect(response.statusCode).toBe(400);
228
+ const body = JSON.parse(response.payload);
229
+ expect(body.error.status).toBe(400);
230
+ expect(body.error.code).toBe('FST_ERR_VALIDATION');
231
+ });
232
+
233
+ it('preserves the 413 status code when the body exceeds the limit', async () => {
234
+ const tinyApp = Fastify({ bodyLimit: 16 });
235
+ registerFastifyErrorHandler(tinyApp);
236
+ tinyApp.post('/echo', async (request) => ({ received: request.body }));
237
+ await tinyApp.ready();
238
+
239
+ try {
240
+ const response = await tinyApp.inject({
241
+ method: 'POST',
242
+ url: '/echo',
243
+ headers: { 'content-type': 'application/json' },
244
+ payload: JSON.stringify({ data: 'x'.repeat(64) })
245
+ });
246
+
247
+ expect(response.statusCode).toBe(413);
248
+ const body = JSON.parse(response.payload);
249
+ expect(body.error.status).toBe(413);
250
+ expect(body.error.code).toBe('FST_ERR_CTP_BODY_TOO_LARGE');
251
+ } finally {
252
+ await tinyApp.close();
253
+ }
254
+ });
255
+
256
+ it('preserves the 415 status code for an unsupported media type', async () => {
257
+ app.post('/json-only', async (request) => ({ received: request.body }));
258
+ // Drop the default text/plain parser so non-JSON content types are rejected.
259
+ app.removeContentTypeParser('text/plain');
260
+ await app.ready();
261
+
262
+ const response = await app.inject({
263
+ method: 'POST',
264
+ url: '/json-only',
265
+ headers: { 'content-type': 'text/plain' },
266
+ payload: 'hello'
267
+ });
268
+
269
+ expect(response.statusCode).toBe(415);
270
+ const body = JSON.parse(response.payload);
271
+ expect(body.error.status).toBe(415);
272
+ expect(body.error.code).toBe('FST_ERR_CTP_INVALID_MEDIA_TYPE');
273
+ });
274
+ });
275
+ });
@@ -18,8 +18,12 @@ export function mockServiceContext(storage: Partial<SyncRulesBucketStorage> | nu
18
18
 
19
19
  const storageEngine: StorageEngine = {
20
20
  activeBucketStorage: {
21
- async getActiveStorage() {
22
- return storage;
21
+ async getActiveSyncConfig() {
22
+ return storage == null
23
+ ? null
24
+ : {
25
+ storage
26
+ };
23
27
  }
24
28
  } as Partial<BucketStorageFactory>
25
29
  } as any;
@@ -1,6 +1,12 @@
1
1
  import { BasicRouterRequest, Context, JwtPayload, SyncRulesBucketStorage } from '@/index.js';
2
- import { RouterResponse, ServiceError, logger } from '@powersync/lib-services-framework';
3
- import { DEFAULT_HYDRATION_STATE, SqlSyncRules } from '@powersync/service-sync-rules';
2
+ import { logger, RouterResponse, ServiceError } from '@powersync/lib-services-framework';
3
+ import {
4
+ DEFAULT_HYDRATION_STATE,
5
+ HydrateSyncConfigParams,
6
+ nodeSqlite,
7
+ SqlSyncRules
8
+ } from '@powersync/service-sync-rules';
9
+ import * as sqlite from 'node:sqlite';
4
10
  import { Readable, Writable } from 'stream';
5
11
  import { pipeline } from 'stream/promises';
6
12
  import { describe, expect, it } from 'vitest';
@@ -10,6 +16,11 @@ import { DEFAULT_PARAM_LOGGING_FORMAT_OPTIONS, limitParamsForLogging } from '../
10
16
  import { mockServiceContext } from './mocks.js';
11
17
 
12
18
  describe('Stream Route', () => {
19
+ const defaultHydrationOptions: HydrateSyncConfigParams = {
20
+ hydrationState: DEFAULT_HYDRATION_STATE,
21
+ sqlite: nodeSqlite(sqlite)
22
+ };
23
+
13
24
  describe('compressed stream', () => {
14
25
  it('handles missing sync rules', async () => {
15
26
  const context: Context = {
@@ -45,7 +56,7 @@ describe('Stream Route', () => {
45
56
 
46
57
  const storage = {
47
58
  getParsedSyncRules() {
48
- return new SqlSyncRules('bucket_definitions: {}').hydrate({ hydrationState: DEFAULT_HYDRATION_STATE });
59
+ return new SqlSyncRules('bucket_definitions: {}').hydrate(defaultHydrationOptions);
49
60
  },
50
61
  watchCheckpointChanges: async function* (options) {
51
62
  throw new Error('Simulated storage error');
@@ -83,7 +94,7 @@ describe('Stream Route', () => {
83
94
  it('logs the application metadata', async () => {
84
95
  const storage = {
85
96
  getParsedSyncRules() {
86
- return new SqlSyncRules('bucket_definitions: {}').hydrate({ hydrationState: DEFAULT_HYDRATION_STATE });
97
+ return new SqlSyncRules('bucket_definitions: {}').hydrate(defaultHydrationOptions);
87
98
  },
88
99
  watchCheckpointChanges: async function* (options) {
89
100
  throw new Error('Simulated storage error');
@@ -0,0 +1,89 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import * as storage from '../../../src/storage/storage-index.js';
3
+
4
+ /**
5
+ * Build a SourceTable with the current `ref`-based options shape.
6
+ * Data/parameter sources are not relevant to the storeCurrentData behaviour, so they default to empty.
7
+ */
8
+ function makeTable(options: Partial<storage.SourceTableOptions> = {}): storage.SourceTable {
9
+ return new storage.SourceTable({
10
+ id: 'test-id',
11
+ ref: { connectionTag: 'test', schema: 'public', name: 'test_table' },
12
+ objectId: 123,
13
+ replicaIdColumns: [{ name: 'id' }],
14
+ snapshotComplete: true,
15
+ bucketDataSources: [],
16
+ parameterLookupSources: [],
17
+ ...options
18
+ });
19
+ }
20
+
21
+ describe('SourceTable', () => {
22
+ describe('storeCurrentData property', () => {
23
+ test('defaults to true', () => {
24
+ const table = makeTable();
25
+ expect(table.storeCurrentData).toBe(true);
26
+ });
27
+
28
+ test('can be set to false', () => {
29
+ const table = makeTable();
30
+ table.storeCurrentData = false;
31
+ expect(table.storeCurrentData).toBe(false);
32
+ });
33
+
34
+ test('is preserved when cloning', () => {
35
+ const table = makeTable();
36
+ table.storeCurrentData = false;
37
+ const cloned = table.clone();
38
+
39
+ expect(cloned.storeCurrentData).toBe(false);
40
+ expect(cloned).not.toBe(table);
41
+ });
42
+
43
+ test('clone preserves all properties including storeCurrentData', () => {
44
+ const table = makeTable({
45
+ replicaIdColumns: [{ name: 'id', type: 'int4' }],
46
+ snapshotComplete: false
47
+ });
48
+
49
+ table.syncData = false;
50
+ table.syncParameters = false;
51
+ table.syncEvent = false;
52
+ table.storeCurrentData = false;
53
+ table.snapshotStatus = {
54
+ totalEstimatedCount: 100,
55
+ replicatedCount: 50,
56
+ lastKey: Buffer.from('test')
57
+ };
58
+
59
+ const cloned = table.clone();
60
+
61
+ expect(cloned.syncData).toBe(false);
62
+ expect(cloned.syncParameters).toBe(false);
63
+ expect(cloned.syncEvent).toBe(false);
64
+ expect(cloned.storeCurrentData).toBe(false);
65
+ expect(cloned.snapshotStatus).toEqual(table.snapshotStatus);
66
+ });
67
+ });
68
+
69
+ describe('integration with other properties', () => {
70
+ test('storeCurrentData does not affect syncAny', () => {
71
+ const table = makeTable();
72
+
73
+ table.storeCurrentData = false;
74
+ table.syncData = true;
75
+ table.syncParameters = false;
76
+ table.syncEvent = false;
77
+
78
+ expect(table.syncAny).toBe(true); // Should still be true
79
+ });
80
+
81
+ test('storeCurrentData is independent of snapshot status', () => {
82
+ const table = makeTable({ snapshotComplete: false });
83
+
84
+ table.storeCurrentData = false;
85
+ expect(table.snapshotComplete).toBe(false);
86
+ expect(table.storeCurrentData).toBe(false);
87
+ });
88
+ });
89
+ });