@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,5 +1,13 @@
1
+ import * as sqlite from 'node:sqlite';
2
+
1
3
  import { ErrorCode, errors, router, schema } from '@powersync/lib-services-framework';
2
- import { SourceSchema, SqlSyncRules, StaticSchema } from '@powersync/service-sync-rules';
4
+ import {
5
+ HydratedSyncConfig,
6
+ nodeSqlite,
7
+ SourceSchema,
8
+ SqlSyncRules,
9
+ StaticSchema
10
+ } from '@powersync/service-sync-rules';
3
11
  import { internal_routes } from '@powersync/service-types';
4
12
 
5
13
  import { DEFAULT_HYDRATION_STATE } from '@powersync/service-sync-rules';
@@ -63,20 +71,35 @@ export const diagnostics = routeDefinition({
63
71
  const {
64
72
  storageEngine: { activeBucketStorage }
65
73
  } = service_context;
66
- const active = await activeBucketStorage.getActiveSyncRulesContent();
67
- const next = await activeBucketStorage.getNextSyncRulesContent();
74
+ const active = await activeBucketStorage.getActiveSyncConfig();
75
+ const deploying = await activeBucketStorage.getDeployingSyncConfig();
68
76
 
69
- const active_status = await api.getSyncRulesStatus(activeBucketStorage, apiHandler, active, {
70
- include_content,
71
- check_connection: status.connected,
72
- live_status: true
73
- });
77
+ const active_status = await api.getSyncRulesStatus(
78
+ apiHandler,
79
+ active?.content ?? null,
80
+ {
81
+ include_content,
82
+ check_connection: status.connected,
83
+ live_status: true,
84
+ active: true
85
+ },
86
+ active?.storage
87
+ );
74
88
 
75
- const next_status = await api.getSyncRulesStatus(activeBucketStorage, apiHandler, next, {
76
- include_content,
77
- check_connection: status.connected,
78
- live_status: true
79
- });
89
+ const deploying_status =
90
+ deploying == null
91
+ ? undefined
92
+ : await api.getSyncRulesStatus(
93
+ apiHandler,
94
+ deploying.content,
95
+ {
96
+ include_content,
97
+ check_connection: status.connected,
98
+ live_status: true,
99
+ active: false
100
+ },
101
+ deploying.storage
102
+ );
80
103
 
81
104
  return internal_routes.DiagnosticsResponse.encode({
82
105
  connections: [
@@ -87,7 +110,7 @@ export const diagnostics = routeDefinition({
87
110
  }
88
111
  ],
89
112
  active_sync_rules: active_status,
90
- deploying_sync_rules: next_status
113
+ deploying_sync_rules: deploying_status
91
114
  });
92
115
  }
93
116
  });
@@ -117,12 +140,16 @@ export const reprocess = routeDefinition({
117
140
  storageEngine: { activeBucketStorage }
118
141
  } = service_context;
119
142
  const apiHandler = service_context.routerEngine.getAPI();
120
- const next = await activeBucketStorage.getNextSyncRules(apiHandler.getParseSyncRulesOptions());
143
+ const next = await activeBucketStorage.getDeployingSyncConfig();
121
144
  if (next != null) {
122
- throw new Error(`Busy processing sync config - cannot reprocess`);
145
+ throw new errors.ServiceError({
146
+ status: 409,
147
+ code: ErrorCode.PSYNC_S4106,
148
+ description: 'Busy processing sync config - cannot reprocess'
149
+ });
123
150
  }
124
151
 
125
- const active = await activeBucketStorage.getActiveSyncRules(apiHandler.getParseSyncRulesOptions());
152
+ const active = await activeBucketStorage.getActiveSyncConfig();
126
153
  if (active == null) {
127
154
  throw new errors.ServiceError({
128
155
  status: 422,
@@ -130,13 +157,12 @@ export const reprocess = routeDefinition({
130
157
  description: 'No active sync config'
131
158
  });
132
159
  }
133
-
134
160
  // There are some differences between this and using asUpdateOptions():
135
161
  // 1. This always re-parses the source YAML. If there are changes to the sync stream compiler, that can affect the sync plan.
136
162
  // 2. If the source does not set the storage version, this will update it do the current version.
137
163
  // We can consider tweaking this behavior in the future.
138
164
  const new_rules = await activeBucketStorage.updateSyncRules(
139
- storage.updateSyncRulesFromYaml(active.sync_rules.config.content, {
165
+ storage.updateSyncRulesFromYaml(active.content.sync_rules_content, {
140
166
  // This sync config already passed validation. But if the config is not valid anymore due
141
167
  // to a service change, we do want to report the error here.
142
168
  validate: true
@@ -151,41 +177,47 @@ export const reprocess = routeDefinition({
151
177
  // Previously the connection was asserted with `!`
152
178
  tag: baseConfig.tag,
153
179
  id: baseConfig.id,
154
- slot_name: new_rules.slot_name
180
+ slot_name: new_rules.replicationStreamName
155
181
  }
156
182
  ]
157
183
  });
158
184
  }
159
185
  });
160
186
 
161
- class FakeSyncRulesContentForValidation extends storage.PersistedSyncRulesContent {
187
+ class FakeSyncRulesContentForValidation extends storage.PersistedSyncConfigContent {
162
188
  constructor(
163
189
  private readonly apiHandler: api.RouteAPI,
164
190
  private readonly schema: SourceSchema,
165
- data: storage.PersistedSyncRulesContentData
191
+ data: storage.PersistedSyncConfigContentData
166
192
  ) {
167
193
  super(data);
168
194
  }
169
195
 
170
- current_lock: storage.ReplicationLock | null = null;
171
-
172
- async lock(): Promise<storage.ReplicationLock> {
173
- throw new Error('Lock not implemented');
174
- }
196
+ parsed(options: storage.ParseSyncConfigOptions): storage.ParsedSyncConfigSet {
197
+ const syncConfig = SqlSyncRules.fromYaml(this.sync_rules_content, {
198
+ ...this.apiHandler.getParseSyncRulesOptions(),
199
+ schema: this.schema
200
+ });
175
201
 
176
- parsed(options: storage.ParseSyncRulesOptions): storage.PersistedSyncRules {
202
+ let hydrated: HydratedSyncConfig | undefined;
177
203
  return {
178
- ...this,
179
- sync_rules: SqlSyncRules.fromYaml(this.sync_rules_content, {
180
- ...this.apiHandler.getParseSyncRulesOptions(),
181
- schema: this.schema
182
- }),
204
+ replicationStreamId: this.replicationStreamId,
205
+ replicationStreamName: this.replicationStreamName,
206
+ syncConfigs: [syncConfig],
183
207
  hydrationState: DEFAULT_HYDRATION_STATE,
184
- hydratedSyncRules() {
185
- return this.sync_rules.config.hydrate({ hydrationState: DEFAULT_HYDRATION_STATE });
208
+ get hydratedSyncConfig(): HydratedSyncConfig {
209
+ hydrated ??= syncConfig.config.hydrate({
210
+ hydrationState: DEFAULT_HYDRATION_STATE,
211
+ sqlite: nodeSqlite(sqlite)
212
+ });
213
+ return hydrated;
186
214
  }
187
215
  };
188
216
  }
217
+
218
+ async getSyncConfigStatus(): Promise<storage.PersistedSyncConfigStatus | null> {
219
+ return null;
220
+ }
189
221
  }
190
222
 
191
223
  export const validate = routeDefinition({
@@ -205,10 +237,8 @@ export const validate = routeDefinition({
205
237
 
206
238
  const sync_rules = new FakeSyncRulesContentForValidation(apiHandler, schema, {
207
239
  // Dummy values
208
- id: 0,
209
- slot_name: '',
210
- active: false,
211
- last_checkpoint_lsn: '',
240
+ replicationStreamId: 0,
241
+ replicationStreamName: '',
212
242
  storageVersion: storage.LEGACY_STORAGE_VERSION,
213
243
  sync_rules_content: content,
214
244
  compiled_plan: null
@@ -222,16 +252,12 @@ export const validate = routeDefinition({
222
252
  });
223
253
  }
224
254
 
225
- const status = (await api.getSyncRulesStatus(
226
- service_context.storageEngine.activeBucketStorage,
227
- apiHandler,
228
- sync_rules,
229
- {
230
- include_content: false,
231
- check_connection: connectionStatus.connected,
232
- live_status: false
233
- }
234
- ))!;
255
+ const status = (await api.getSyncRulesStatus(apiHandler, sync_rules, {
256
+ include_content: false,
257
+ check_connection: connectionStatus.connected,
258
+ live_status: false,
259
+ active: false
260
+ }))!;
235
261
 
236
262
  if (connectionStatus == null) {
237
263
  status.errors.push({ level: 'fatal', message: 'No connection configured', ts: new Date().toISOString() });
@@ -30,7 +30,7 @@ export const writeCheckpoint = routeDefinition({
30
30
 
31
31
  logger.info(`Waiting for LSN checkpoint: ${head}`);
32
32
  while (Date.now() - start < timeout) {
33
- const bucketStorage = await service_context.storageEngine.activeBucketStorage.getActiveStorage();
33
+ const bucketStorage = (await service_context.storageEngine.activeBucketStorage.getActiveSyncConfig())?.storage;
34
34
  const cp = await bucketStorage?.getCheckpoint();
35
35
  if (cp == null) {
36
36
  throw new Error('No sync config available');
@@ -71,7 +71,7 @@ export const syncStreamReactive: SocketRouteGenerator = (router) =>
71
71
  storageEngine: { activeBucketStorage }
72
72
  } = service_context;
73
73
 
74
- const bucketStorage = await activeBucketStorage.getActiveStorage();
74
+ const bucketStorage = (await activeBucketStorage.getActiveSyncConfig())?.storage;
75
75
  if (bucketStorage == null) {
76
76
  responder.onError(
77
77
  new errors.ServiceError({
@@ -73,7 +73,7 @@ export const deploySyncRules = routeDefinition({
73
73
  const sync_rules = await storageEngine.activeBucketStorage.updateSyncRules(updateSyncRulesFromConfig(syncConfig));
74
74
 
75
75
  return {
76
- slot_name: sync_rules.slot_name
76
+ slot_name: sync_rules.replicationStreamName
77
77
  };
78
78
  }
79
79
  });
@@ -110,8 +110,8 @@ export const currentSyncRules = routeDefinition({
110
110
  storageEngine: { activeBucketStorage }
111
111
  } = service_context;
112
112
 
113
- const sync_rules = await activeBucketStorage.getActiveSyncRulesContent();
114
- if (!sync_rules) {
113
+ const active = await activeBucketStorage.getActiveSyncConfig();
114
+ if (!active) {
115
115
  throw new errors.ServiceError({
116
116
  status: 422,
117
117
  code: ErrorCode.PSYNC_S4104,
@@ -119,15 +119,16 @@ export const currentSyncRules = routeDefinition({
119
119
  });
120
120
  }
121
121
 
122
+ const sync_rules = active.content;
122
123
  const apiHandler = service_context.routerEngine.getAPI();
123
124
  const info = await debugSyncRules(apiHandler, sync_rules.sync_rules_content);
124
- const next = await activeBucketStorage.getNextSyncRulesContent();
125
+ const next = await activeBucketStorage.getDeployingSyncConfig();
125
126
 
126
- const next_info = next ? await debugSyncRules(apiHandler, next.sync_rules_content) : null;
127
+ const next_info = next ? await debugSyncRules(apiHandler, next.content.sync_rules_content) : null;
127
128
 
128
129
  const response = {
129
130
  current: {
130
- slot_name: sync_rules.slot_name,
131
+ slot_name: sync_rules.replicationStreamName,
131
132
  content: sync_rules.sync_rules_content,
132
133
  ...info
133
134
  },
@@ -135,8 +136,8 @@ export const currentSyncRules = routeDefinition({
135
136
  next == null
136
137
  ? null
137
138
  : {
138
- slot_name: next.slot_name,
139
- content: next.sync_rules_content,
139
+ slot_name: next.content.replicationStreamName,
140
+ content: next.content.sync_rules_content,
140
141
  ...next_info
141
142
  }
142
143
  };
@@ -156,9 +157,17 @@ export const reprocessSyncRules = routeDefinition({
156
157
  const {
157
158
  storageEngine: { activeBucketStorage }
158
159
  } = payload.context.service_context;
159
- const apiHandler = payload.context.service_context.routerEngine.getAPI();
160
- const sync_rules = await activeBucketStorage.getActiveSyncRules(apiHandler.getParseSyncRulesOptions());
161
- if (sync_rules == null) {
160
+ const next = await activeBucketStorage.getDeployingSyncConfig();
161
+ if (next != null) {
162
+ throw new errors.ServiceError({
163
+ status: 409,
164
+ code: ErrorCode.PSYNC_S4106,
165
+ description: 'Busy processing sync config - cannot reprocess'
166
+ });
167
+ }
168
+
169
+ const active = await activeBucketStorage.getActiveSyncConfig();
170
+ if (active == null) {
162
171
  throw new errors.ServiceError({
163
172
  status: 422,
164
173
  code: ErrorCode.PSYNC_S4104,
@@ -166,15 +175,16 @@ export const reprocessSyncRules = routeDefinition({
166
175
  });
167
176
  }
168
177
 
178
+ const sync_rules = active.content;
169
179
  const new_rules = await activeBucketStorage.updateSyncRules(
170
- updateSyncRulesFromYaml(sync_rules.sync_rules.config.content, {
180
+ updateSyncRulesFromYaml(sync_rules.sync_rules_content, {
171
181
  // This sync config already passed validation. But if the rules are not valid anymore due
172
182
  // to a service change, we do want to report the error here.
173
183
  validate: true
174
184
  })
175
185
  );
176
186
  return {
177
- slot_name: new_rules.slot_name
187
+ slot_name: new_rules.replicationStreamName
178
188
  };
179
189
  }
180
190
  });
@@ -62,7 +62,7 @@ export const syncStreamed = routeDefinition({
62
62
  });
63
63
  }
64
64
 
65
- const bucketStorage = await storageEngine.activeBucketStorage.getActiveStorage();
65
+ const bucketStorage = (await storageEngine.activeBucketStorage.getActiveSyncConfig())?.storage;
66
66
 
67
67
  if (bucketStorage == null) {
68
68
  throw new errors.ServiceError({
@@ -1,7 +1,8 @@
1
1
  import type fastify from 'fastify';
2
+ import * as zlib from 'node:zlib';
2
3
  import * as uuid from 'uuid';
3
4
 
4
- import { errors, HTTPMethod, logger, RouteNotFound, router, ServiceError } from '@powersync/lib-services-framework';
5
+ import { errors, HTTPMethod, logger, RouteNotFound, router } from '@powersync/lib-services-framework';
5
6
  import { FastifyReply } from 'fastify';
6
7
  import { Context, ContextProvider, RequestEndpoint, RequestEndpointHandlerPayload } from './router.js';
7
8
 
@@ -105,18 +106,86 @@ export function registerFastifyNotFoundHandler(app: fastify.FastifyInstance) {
105
106
  });
106
107
  }
107
108
 
108
- function serviceErrorToResponse(error: ServiceError): router.RouterResponse {
109
+ /** Registers a custom error handler that emits service-error JSON honouring any negotiated `Content-Encoding`. */
110
+ export function registerFastifyErrorHandler(app: fastify.FastifyInstance) {
111
+ app.setErrorHandler<Error>(async (error, _request, reply) => {
112
+ if (reply.raw.headersSent) {
113
+ // Headers already flushed - reply.code/header would throw ERR_HTTP_HEADERS_SENT.
114
+ reply.raw.destroy(error);
115
+ return;
116
+ }
117
+
118
+ const { status, data } = fastifyErrorToResponse(error);
119
+ if (status >= 500) {
120
+ logger.error('Request failed', error);
121
+ } else {
122
+ logger.warn(`Request failed: ${error.message}`, {
123
+ status,
124
+ code: data.error.code
125
+ });
126
+ }
127
+
128
+ const json = JSON.stringify(data);
129
+
130
+ // Fastify's default handler leaves `content-encoding` intact when it rewrites the body.
131
+ const encoding = reply.getHeader('content-encoding');
132
+ let body: string | Buffer = json;
133
+ if (encoding === 'gzip') {
134
+ body = zlib.gzipSync(json);
135
+ } else if (encoding === 'zstd') {
136
+ body = zlib.zstdCompressSync(json);
137
+ } else {
138
+ reply.removeHeader('content-encoding');
139
+ }
140
+
141
+ reply
142
+ .code(status)
143
+ .header('content-type', 'application/json; charset=utf-8')
144
+ .header('content-length', Buffer.byteLength(body))
145
+ .send(body);
146
+ });
147
+ }
148
+
149
+ type ErrorResponseData = { error: errors.ErrorData };
150
+
151
+ function serviceErrorToResponse(serviceError: errors.ServiceError): router.RouterResponse<ErrorResponseData> {
109
152
  return new router.RouterResponse({
110
- status: error.errorData.status || 500,
153
+ status: serviceError.errorData.status || 500,
111
154
  headers: {
112
155
  'Content-Type': 'application/json'
113
156
  },
114
157
  data: {
115
- error: error.errorData
158
+ error: serviceError.errorData
116
159
  }
117
160
  });
118
161
  }
119
162
 
163
+ function fastifyErrorToResponse(error: any): router.RouterResponse<ErrorResponseData> {
164
+ // Preserve 4xx status from Fastify built-ins (validation, invalid JSON body, etc.) instead of collapsing to 500.
165
+ if (
166
+ typeof error?.statusCode === 'number' &&
167
+ error.statusCode >= 400 &&
168
+ error.statusCode < 500 &&
169
+ typeof error.code === 'string'
170
+ ) {
171
+ return new router.RouterResponse({
172
+ status: error.statusCode,
173
+ headers: {
174
+ 'Content-Type': 'application/json'
175
+ },
176
+ data: {
177
+ error: {
178
+ name: error.name,
179
+ code: error.code,
180
+ status: error.statusCode,
181
+ description: error.message
182
+ }
183
+ }
184
+ });
185
+ }
186
+ return serviceErrorToResponse(errors.asServiceError(error));
187
+ }
188
+
120
189
  async function respond(reply: FastifyReply, response: router.RouterResponse) {
121
190
  Object.keys(response.headers).forEach((key) => {
122
191
  reply.header(key, response.headers[key]);
@@ -40,22 +40,22 @@ async function terminateSyncRules(storageFactory: storage.BucketStorageFactory,
40
40
  const locks: storage.ReplicationLock[] = [];
41
41
  while (Date.now() - start < 120_000) {
42
42
  let retry = false;
43
- const replicatingSyncRules = await storageFactory.getReplicatingSyncRules();
43
+ const replicatingStreams = await storageFactory.getReplicatingReplicationStreams();
44
44
  // Lock all the replicating replication streams
45
- for (const replicatingSyncRule of replicatingSyncRules) {
46
- const lock = await replicatingSyncRule.lock();
45
+ for (const replicatingStream of replicatingStreams) {
46
+ const lock = await replicatingStream.lock();
47
47
  locks.push(lock);
48
48
  }
49
49
 
50
- const stoppedSyncRules = await storageFactory.getStoppedSyncRules();
51
- const combinedSyncRules = [...replicatingSyncRules, ...stoppedSyncRules];
50
+ const stoppedStreams = await storageFactory.getStoppedReplicationStreams();
51
+ const combinedStreams = [...replicatingStreams, ...stoppedStreams];
52
52
  try {
53
53
  // Clean up any module specific configuration for the replication stream
54
- await moduleManager.tearDown({ syncRules: combinedSyncRules });
54
+ await moduleManager.tearDown({ replicationStreams: combinedStreams, syncRules: combinedStreams });
55
55
 
56
56
  // Mark the replication stream as terminated
57
- for (let syncRules of combinedSyncRules) {
58
- const syncRulesStorage = storageFactory.getInstance(syncRules);
57
+ for (let replicationStream of combinedStreams) {
58
+ const syncRulesStorage = storageFactory.getInstance(replicationStream);
59
59
  // The storage will be dropped at the end of the teardown, so we don't need to clear it here
60
60
  await syncRulesStorage.terminate({ clearStorage: false });
61
61
  }
@@ -5,6 +5,7 @@ import { InternalOpId } from '../util/utils.js';
5
5
  import { ReplicationEventPayload } from './ReplicationEventPayload.js';
6
6
  import { SourceTable, TableSnapshotStatus } from './SourceTable.js';
7
7
  import { BatchedCustomWriteCheckpointOptions } from './storage-index.js';
8
+ import { ResolveTablesOptions, ResolveTablesResult } from './SyncRulesBucketStorage.js';
8
9
 
9
10
  export const DEFAULT_BUCKET_BATCH_COMMIT_OPTIONS: ResolvedBucketBatchCommitOptions = {
10
11
  createEmptyCheckpoints: true,
@@ -22,6 +23,11 @@ export interface BucketStorageBatch extends ObserverClient<BucketBatchStorageLis
22
23
  */
23
24
  last_flushed_op: InternalOpId | null;
24
25
 
26
+ /**
27
+ * True for snapshot batches that should skip rows already present in current_data.
28
+ */
29
+ readonly skipExistingRows: boolean;
30
+
25
31
  /**
26
32
  * Save an op, and potentially flush.
27
33
  *
@@ -77,11 +83,6 @@ export interface BucketStorageBatch extends ObserverClient<BucketBatchStorageLis
77
83
  */
78
84
  setResumeLsn(lsn: string): Promise<void>;
79
85
 
80
- /**
81
- * Get the last checkpoint LSN, from either commit or keepalive.
82
- */
83
- lastCheckpointLsn: string | null;
84
-
85
86
  /**
86
87
  * LSN to resume from.
87
88
  *
@@ -91,10 +92,36 @@ export interface BucketStorageBatch extends ObserverClient<BucketBatchStorageLis
91
92
 
92
93
  markTableSnapshotDone(tables: SourceTable[], no_checkpoint_before_lsn?: string): Promise<SourceTable[]>;
93
94
  markTableSnapshotRequired(table: SourceTable): Promise<void>;
95
+
96
+ /**
97
+ * Mark the full replication snapshot as done without validating individual source table snapshot state.
98
+ *
99
+ * This is primarily intended for storage tests and setup helpers that manually construct storage state.
100
+ * Replicators should use `markSnapshotDone()` instead, because that validates that all known source tables
101
+ * have completed snapshotting before allowing checkpoints to be unblocked.
102
+ */
94
103
  markAllSnapshotDone(no_checkpoint_before_lsn: string): Promise<void>;
95
104
 
105
+ /**
106
+ * Mark the full replication snapshot as done after validating that all known source tables have completed snapshotting.
107
+ *
108
+ * Replicators should use this method when completing an initial snapshot. The validation prevents races where
109
+ * new source tables are marked as requiring a snapshot while global snapshot finalization is running.
110
+ *
111
+ * If `throwOnConflict` is false, this will instead return early without throwing if there are source tables that still require snapshotting.
112
+ * Use that only in cases where concurrency is expected, and can automatically retry/continue.
113
+ */
114
+ markSnapshotDone(no_checkpoint_before_lsn: string, options?: { throwOnConflict?: boolean }): Promise<void>;
115
+
96
116
  updateTableProgress(table: SourceTable, progress: Partial<TableSnapshotStatus>): Promise<SourceTable>;
97
117
 
118
+ /**
119
+ * Get the current status for an existing source table without creating or resolving a replacement table.
120
+ */
121
+ getSourceTableStatus(table: SourceTable): Promise<SourceTable | null>;
122
+
123
+ resolveTables(options: ResolveTablesOptions): Promise<ResolveTablesResult>;
124
+
98
125
  /**
99
126
  * Queues the creation of a custom Write Checkpoint. This will be persisted after operations are flushed.
100
127
  */
@@ -184,8 +211,6 @@ export interface CheckpointResult {
184
211
  /**
185
212
  * True if a checkpoint was actually created by this operation. This can be false even if checkpointBlocked is false,
186
213
  * if the checkpoint was empty.
187
- *
188
- * This is primarily used for testing.
189
214
  */
190
215
  checkpointCreated: boolean;
191
216
  }