@powersync/service-core 1.27.1 → 1.28.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 (131) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +201 -67
  3. package/dist/api/RouteAPI.d.ts +6 -0
  4. package/dist/api/api-metrics.js +4 -0
  5. package/dist/api/api-metrics.js.map +1 -1
  6. package/dist/api/diagnostics.d.ts +5 -0
  7. package/dist/api/diagnostics.js +17 -2
  8. package/dist/api/diagnostics.js.map +1 -1
  9. package/dist/metrics/connection-metrics.d.ts +28 -0
  10. package/dist/metrics/connection-metrics.js +55 -0
  11. package/dist/metrics/connection-metrics.js.map +1 -0
  12. package/dist/metrics/metrics-index.d.ts +1 -0
  13. package/dist/metrics/metrics-index.js +1 -0
  14. package/dist/metrics/metrics-index.js.map +1 -1
  15. package/dist/metrics/metrics-interfaces.d.ts +7 -1
  16. package/dist/metrics/metrics-interfaces.js.map +1 -1
  17. package/dist/modules/loader.js +8 -2
  18. package/dist/modules/loader.js.map +1 -1
  19. package/dist/replication/AbstractReplicationJob.d.ts +2 -0
  20. package/dist/replication/AbstractReplicationJob.js +17 -1
  21. package/dist/replication/AbstractReplicationJob.js.map +1 -1
  22. package/dist/replication/AbstractReplicator.d.ts +8 -18
  23. package/dist/replication/AbstractReplicator.js +63 -64
  24. package/dist/replication/AbstractReplicator.js.map +1 -1
  25. package/dist/replication/ReplicationModule.js +0 -6
  26. package/dist/replication/ReplicationModule.js.map +1 -1
  27. package/dist/routes/configure-fastify.js +5 -1
  28. package/dist/routes/configure-fastify.js.map +1 -1
  29. package/dist/routes/endpoints/admin.js +24 -22
  30. package/dist/routes/endpoints/admin.js.map +1 -1
  31. package/dist/routes/endpoints/socket-route.js +19 -27
  32. package/dist/routes/endpoints/socket-route.js.map +1 -1
  33. package/dist/routes/endpoints/sync-rules.js +17 -40
  34. package/dist/routes/endpoints/sync-rules.js.map +1 -1
  35. package/dist/routes/endpoints/sync-stream.js +25 -23
  36. package/dist/routes/endpoints/sync-stream.js.map +1 -1
  37. package/dist/routes/hooks.d.ts +1 -1
  38. package/dist/routes/hooks.js +2 -1
  39. package/dist/routes/hooks.js.map +1 -1
  40. package/dist/routes/sync-connection.d.ts +7 -0
  41. package/dist/routes/sync-connection.js +34 -0
  42. package/dist/routes/sync-connection.js.map +1 -0
  43. package/dist/storage/BucketStorageFactory.d.ts +30 -22
  44. package/dist/storage/BucketStorageFactory.js +14 -14
  45. package/dist/storage/BucketStorageFactory.js.map +1 -1
  46. package/dist/storage/PersistedSyncConfigContent.js +2 -2
  47. package/dist/storage/PersistedSyncConfigContent.js.map +1 -1
  48. package/dist/storage/SyncRulesBucketStorage.d.ts +23 -2
  49. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  50. package/dist/streams/BroadcastIterable.js +8 -7
  51. package/dist/streams/BroadcastIterable.js.map +1 -1
  52. package/dist/streams/LastValueSink.d.ts +2 -2
  53. package/dist/streams/LastValueSink.js +8 -13
  54. package/dist/streams/LastValueSink.js.map +1 -1
  55. package/dist/streams/merge.d.ts +15 -31
  56. package/dist/streams/merge.js +136 -152
  57. package/dist/streams/merge.js.map +1 -1
  58. package/dist/sync/BucketChecksumState.d.ts +3 -2
  59. package/dist/sync/BucketChecksumState.js +12 -5
  60. package/dist/sync/BucketChecksumState.js.map +1 -1
  61. package/dist/sync/sync.js +48 -38
  62. package/dist/sync/sync.js.map +1 -1
  63. package/dist/sync/util.d.ts +1 -2
  64. package/dist/sync/util.js +2 -5
  65. package/dist/sync/util.js.map +1 -1
  66. package/dist/system/ServiceContext.d.ts +7 -0
  67. package/dist/system/ServiceContext.js +3 -3
  68. package/dist/system/ServiceContext.js.map +1 -1
  69. package/dist/system/SyncConfigEngine.d.ts +135 -0
  70. package/dist/system/SyncConfigEngine.js +186 -0
  71. package/dist/system/SyncConfigEngine.js.map +1 -0
  72. package/dist/system/system-index.d.ts +1 -0
  73. package/dist/system/system-index.js +1 -0
  74. package/dist/system/system-index.js.map +1 -1
  75. package/dist/util/config/types.d.ts +4 -0
  76. package/package.json +8 -9
  77. package/src/api/RouteAPI.ts +6 -0
  78. package/src/api/api-metrics.ts +5 -0
  79. package/src/api/diagnostics.ts +22 -2
  80. package/src/metrics/connection-metrics.ts +75 -0
  81. package/src/metrics/metrics-index.ts +1 -0
  82. package/src/metrics/metrics-interfaces.ts +8 -1
  83. package/src/modules/loader.ts +16 -2
  84. package/src/replication/AbstractReplicationJob.ts +17 -1
  85. package/src/replication/AbstractReplicator.ts +83 -81
  86. package/src/replication/ReplicationModule.ts +0 -5
  87. package/src/routes/configure-fastify.ts +7 -1
  88. package/src/routes/endpoints/admin.ts +27 -20
  89. package/src/routes/endpoints/socket-route.ts +26 -38
  90. package/src/routes/endpoints/sync-rules.ts +22 -46
  91. package/src/routes/endpoints/sync-stream.ts +31 -27
  92. package/src/routes/hooks.ts +5 -1
  93. package/src/routes/sync-connection.ts +39 -0
  94. package/src/storage/BucketStorageFactory.ts +45 -29
  95. package/src/storage/PersistedSyncConfigContent.ts +2 -2
  96. package/src/storage/SyncRulesBucketStorage.ts +23 -2
  97. package/src/streams/BroadcastIterable.ts +8 -7
  98. package/src/streams/LastValueSink.ts +10 -16
  99. package/src/streams/merge.ts +149 -169
  100. package/src/sync/BucketChecksumState.ts +17 -5
  101. package/src/sync/sync.ts +71 -57
  102. package/src/sync/util.ts +2 -7
  103. package/src/system/ServiceContext.ts +8 -3
  104. package/src/system/SyncConfigEngine.ts +247 -0
  105. package/src/system/system-index.ts +1 -0
  106. package/src/util/config/types.ts +4 -0
  107. package/test/src/AbstractReplicator.test.ts +375 -45
  108. package/test/src/PersistedSyncConfigContent.test.ts +5 -5
  109. package/test/src/SyncConfigEngine.test.ts +208 -0
  110. package/test/src/broadcast_iterable.test.ts +59 -31
  111. package/test/src/config.test.ts +65 -3
  112. package/test/src/connection-metrics-export.test.ts +76 -0
  113. package/test/src/connection-metrics.test.ts +184 -0
  114. package/test/src/diagnostics.test.ts +3 -3
  115. package/test/src/last_value_sink.test.ts +34 -0
  116. package/test/src/merge_iterable.test.ts +304 -55
  117. package/test/src/module-loader.test.ts +14 -1
  118. package/test/src/recording-metrics.ts +36 -0
  119. package/test/src/routes/admin.test.ts +2 -2
  120. package/test/src/routes/concurrency-limit.test.ts +108 -0
  121. package/test/src/routes/connection-metrics.test.ts +269 -0
  122. package/test/src/routes/invalid-config.test.ts +184 -0
  123. package/test/src/routes/mocks.ts +26 -13
  124. package/test/src/routes/stream-disconnect.integration.test.ts +129 -0
  125. package/test/src/routes/stream.test.ts +200 -107
  126. package/test/src/routes/sync-rules.test.ts +19 -1
  127. package/test/src/source_table_config.test.ts +7 -7
  128. package/test/src/sync/BucketChecksumState.test.ts +10 -10
  129. package/test/src/sync/stream_response.test.ts +389 -0
  130. package/test/src/sync/util.test.ts +14 -3
  131. package/tsconfig.tsbuildinfo +1 -1
@@ -1,4 +1,4 @@
1
- import { ErrorCode, errors, router, schema } from '@powersync/lib-services-framework';
1
+ import { router, schema } from '@powersync/lib-services-framework';
2
2
  import Negotiator from 'negotiator';
3
3
  import { Readable } from 'stream';
4
4
 
@@ -9,8 +9,15 @@ import { APIMetric, event_types } from '@powersync/service-types';
9
9
  import { authUser } from '../auth.js';
10
10
  import { routeDefinition } from '../router.js';
11
11
 
12
+ import {
13
+ recordSyncConnection,
14
+ SyncCloseReason,
15
+ syncConnectionCloseReasonLogText,
16
+ SyncTransport
17
+ } from '../../metrics/connection-metrics.js';
12
18
  import { limitParamsForLogging } from '../../util/param-logging.js';
13
19
  import { maybeCompressResponseStream } from '../compression.js';
20
+ import { resolveSyncConnectionSetup } from '../sync-connection.js';
14
21
 
15
22
  export enum SyncRoutes {
16
23
  STREAM = '/sync/stream'
@@ -27,7 +34,7 @@ export const syncStreamed = routeDefinition({
27
34
  validator: schema.createTsCodecValidator(util.StreamingSyncRequest, { allowAdditional: true }),
28
35
  handler: async (payload) => {
29
36
  const { service_context, logger, token_payload } = payload.context;
30
- const { routerEngine, storageEngine, metricsEngine, syncContext } = service_context;
37
+ const { routerEngine, metricsEngine, syncContext } = service_context;
31
38
  const headers = payload.request.headers;
32
39
  const userAgent = headers['x-user-agent'] ?? headers['user-agent'];
33
40
  const clientId = payload.params.client_id;
@@ -54,25 +61,7 @@ export const syncStreamed = routeDefinition({
54
61
  connected_at: new Date(streamStart)
55
62
  };
56
63
 
57
- if (routerEngine.closed) {
58
- throw new errors.ServiceError({
59
- status: 503,
60
- code: ErrorCode.PSYNC_S2003,
61
- description: 'Service temporarily unavailable'
62
- });
63
- }
64
-
65
- const bucketStorage = (await storageEngine.activeBucketStorage.getActiveSyncConfig())?.storage;
66
-
67
- if (bucketStorage == null) {
68
- throw new errors.ServiceError({
69
- status: 500,
70
- code: ErrorCode.PSYNC_S2302,
71
- description: 'No sync config available'
72
- });
73
- }
74
-
75
- const syncRules = bucketStorage.getParsedSyncRules(routerEngine.getAPI().getParseSyncRulesOptions());
64
+ const { bucketStorage, syncRules } = await resolveSyncConnectionSetup(service_context, SyncTransport.HttpStream);
76
65
 
77
66
  const controller = new AbortController();
78
67
  const tracker = new sync.RequestTracker(metricsEngine);
@@ -111,18 +100,19 @@ export const syncStreamed = routeDefinition({
111
100
  // Best effort guess on why the stream was closed.
112
101
  // We use the `??=` operator everywhere, so that we catch the first relevant
113
102
  // event, which is usually the most specific.
114
- let closeReason: string | undefined = undefined;
103
+ let closeReason: SyncCloseReason | undefined = undefined;
104
+ let connectionError: unknown;
115
105
 
116
106
  const deregister = routerEngine.addStopHandler(() => {
117
107
  // This error is not currently propagated to the client
118
108
  controller.abort();
119
- closeReason ??= 'process shutdown';
109
+ closeReason ??= SyncCloseReason.ProcessShutdown;
120
110
  stream.destroy(new Error('Shutting down system'));
121
111
  });
122
112
 
123
113
  stream.on('end', () => {
124
114
  // Auth failure or switch to new sync config
125
- closeReason ??= 'service closing stream';
115
+ closeReason ??= SyncCloseReason.ServiceClosed;
126
116
  });
127
117
 
128
118
  stream.on('close', () => {
@@ -130,7 +120,10 @@ export const syncStreamed = routeDefinition({
130
120
  });
131
121
 
132
122
  stream.on('error', (error) => {
133
- closeReason ??= 'stream error';
123
+ if (closeReason == null) {
124
+ closeReason = SyncCloseReason.StreamError;
125
+ connectionError = error;
126
+ }
134
127
  controller.abort();
135
128
  // Note: This appears as a 200 response in the logs.
136
129
  if (error.message != 'Shutting down system') {
@@ -151,11 +144,17 @@ export const syncStreamed = routeDefinition({
151
144
  },
152
145
  data: stream,
153
146
  afterSend: async (details) => {
147
+ // A client disconnect must not overwrite an earlier error or server close.
154
148
  if (details.clientClosed) {
155
- closeReason ??= 'client closing stream';
149
+ closeReason ??= SyncCloseReason.ClientClosed;
156
150
  }
157
151
  controller.abort();
158
152
  metricsEngine.getUpDownCounter(APIMetric.CONCURRENT_CONNECTIONS).add(-1);
153
+ recordSyncConnection(metricsEngine, {
154
+ transport: SyncTransport.HttpStream,
155
+ closeReason: closeReason ?? SyncCloseReason.Unknown,
156
+ error: connectionError
157
+ });
159
158
  service_context.eventsEngine.emit(event_types.EventsEngineEventType.SDK_DISCONNECT_EVENT, {
160
159
  ...sdkData,
161
160
  disconnected_at: new Date()
@@ -164,13 +163,18 @@ export const syncStreamed = routeDefinition({
164
163
  ...tracker.getLogMeta(),
165
164
  app_metadata: formattedAppMetadata,
166
165
  stream_ms: Date.now() - streamStart,
167
- close_reason: closeReason ?? 'unknown'
166
+ close_reason: syncConnectionCloseReasonLogText(closeReason)
168
167
  });
169
168
  }
170
169
  });
171
170
  } catch (ex) {
172
171
  controller.abort();
173
172
  metricsEngine.getUpDownCounter(APIMetric.CONCURRENT_CONNECTIONS).add(-1);
173
+ recordSyncConnection(metricsEngine, {
174
+ transport: SyncTransport.HttpStream,
175
+ closeReason: SyncCloseReason.StreamError,
176
+ error: ex
177
+ });
174
178
  service_context.eventsEngine.emit(event_types.EventsEngineEventType.SDK_DISCONNECT_EVENT, {
175
179
  ...sdkData,
176
180
  disconnected_at: new Date()
@@ -11,7 +11,10 @@ export type CreateRequestQueueParams = {
11
11
  * Creates a request queue which limits the amount of concurrent connections which
12
12
  * are active at any time.
13
13
  */
14
- export const createRequestQueueHook = (params: CreateRequestQueueParams): fastify.onRequestHookHandler => {
14
+ export const createRequestQueueHook = (
15
+ params: CreateRequestQueueParams,
16
+ onRejected?: () => void
17
+ ): fastify.onRequestHookHandler => {
15
18
  const request_queue = a.queue<() => Promise<void>>((event, done) => {
16
19
  event().finally(done);
17
20
  }, params.concurrency);
@@ -27,6 +30,7 @@ export const createRequestQueueHook = (params: CreateRequestQueueParams): fastif
27
30
  path: request.url,
28
31
  queue_overflow: true
29
32
  });
33
+ onRejected?.();
30
34
  return reply.status(429).send();
31
35
  }
32
36
 
@@ -0,0 +1,39 @@
1
+ import { ErrorCode, errors } from '@powersync/lib-services-framework';
2
+
3
+ import { recordSyncConnection, SyncCloseReason, SyncTransport } from '../metrics/connection-metrics.js';
4
+ import type { RouterServiceContext } from './router.js';
5
+
6
+ /** Resolves stream resources, counting setup failures as rejections before rethrowing. */
7
+ export async function resolveSyncConnectionSetup(serviceContext: RouterServiceContext, transport: SyncTransport) {
8
+ const { routerEngine, storageEngine, metricsEngine } = serviceContext;
9
+ let closeReason = SyncCloseReason.ServiceUnavailable;
10
+
11
+ try {
12
+ if (routerEngine.closed) {
13
+ throw new errors.ServiceError({
14
+ status: 503,
15
+ code: ErrorCode.PSYNC_S2003,
16
+ description: 'Service temporarily unavailable'
17
+ });
18
+ }
19
+
20
+ closeReason = SyncCloseReason.StorageError;
21
+ const bucketStorage = (await storageEngine.activeBucketStorage.getActiveSyncConfig())?.storage;
22
+
23
+ closeReason = SyncCloseReason.NoSyncConfig;
24
+ if (bucketStorage == null) {
25
+ throw new errors.ServiceError({
26
+ status: 500,
27
+ code: ErrorCode.PSYNC_S2302,
28
+ description: 'No sync config available'
29
+ });
30
+ }
31
+
32
+ closeReason = SyncCloseReason.SyncConfigError;
33
+ const syncRules = bucketStorage.getParsedSyncRules(routerEngine.getAPI().getParseSyncRulesOptions());
34
+ return { bucketStorage, syncRules };
35
+ } catch (error) {
36
+ recordSyncConnection(metricsEngine, { transport, closeReason, error });
37
+ throw error;
38
+ }
39
+ }
@@ -1,13 +1,13 @@
1
1
  import { BaseObserver, logger } from '@powersync/lib-services-framework';
2
2
  import {
3
+ parseSyncConfig,
3
4
  PrecompiledSyncConfig,
4
5
  SerializedSyncPlan as RawSerializedSyncPlan,
5
6
  SerializedCompatibilityContext,
6
7
  serializeSyncPlan,
7
- SqlSyncRules,
8
8
  SyncConfigWithErrors
9
9
  } from '@powersync/service-sync-rules';
10
- import { ReplicationError } from '@powersync/service-types';
10
+ import { ReplicationError, ValidationDiagnostic } from '@powersync/service-types';
11
11
  import { syncConfigYamlErrorToReplicationError } from '../util/errors.js';
12
12
  import { PersistedReplicationStream } from './PersistedReplicationStream.js';
13
13
  import { PersistedSyncConfigContent } from './PersistedSyncConfigContent.js';
@@ -70,6 +70,11 @@ export abstract class BucketStorageFactory
70
70
  */
71
71
  abstract updateSyncRules(options: UpdateSyncRulesOptions): Promise<PersistedReplicationStream>;
72
72
 
73
+ /**
74
+ * Stop pending processing atomically, preserving active configs. Used for rejected startup submissions.
75
+ */
76
+ abstract stopProcessingSyncConfigs(): Promise<void>;
77
+
73
78
  /**
74
79
  * Indicate that a slot was removed, and we should re-sync by creating
75
80
  * a new replication stream.
@@ -78,6 +83,8 @@ export abstract class BucketStorageFactory
78
83
  * config, but also accounts for cases where the current sync config is not
79
84
  * the latest one.
80
85
  *
86
+ * Recovery of the active config must atomically recheck pending work and create its replacement,
87
+ * preserving any newer pending deployment rather than superseding it with the active config.
81
88
  * Replication should be restarted after this.
82
89
  */
83
90
  abstract restartReplication(replicationStreamId: number): Promise<void>;
@@ -175,23 +182,29 @@ export interface StorageSyncConfigMetrics {
175
182
  attributed_object_storage_bytes: number;
176
183
  }
177
184
 
185
+ /**
186
+ * Sync config content and its compiled representation, shared by startup validation and deployment.
187
+ * Validation errors are retained in parsed even when the config cannot be deployed.
188
+ */
189
+ export interface CompiledSyncConfig {
190
+ yaml: string;
191
+ /**
192
+ * The serialized sync plan for the sync configuration, or `null` for configurations not using the Sync Streams
193
+ * compiler.
194
+ */
195
+ plan: SerializedSyncPlan | null;
196
+
197
+ /**
198
+ * Parsed sync config, primarily to generate a definition mapping.
199
+ * Not persisted, and the defaultSchema used for parsing is not relevant.
200
+ */
201
+ parsed: SyncConfigWithErrors;
202
+ }
203
+
178
204
  export interface UpdateSyncRulesOptions {
179
205
  /** Optional operator-supplied label identifying this deployed sync config version. */
180
206
  version_label?: string;
181
- config: {
182
- yaml: string;
183
- /**
184
- * The serialized sync plan for the sync configuration, or `null` for configurations not using the Sync Streams
185
- * compiler.
186
- */
187
- plan: SerializedSyncPlan | null;
188
-
189
- /**
190
- * Parsed sync config, primarily to generate a definition mapping.
191
- * Not persisted, and the defaultSchema used for parsing is not relevant.
192
- */
193
- parsed: SyncConfigWithErrors;
194
- };
207
+ config: CompiledSyncConfig;
195
208
  lock?: boolean;
196
209
  storageVersion?: number;
197
210
 
@@ -228,7 +241,7 @@ export function updateSyncRulesFromYaml(
228
241
  options?: Omit<UpdateSyncRulesOptions, 'config'> & { validate?: boolean }
229
242
  ): UpdateSyncRulesOptions {
230
243
  const { validate, ...updateOptions } = options ?? {};
231
- const config = SqlSyncRules.fromYaml(content, {
244
+ const config = parseSyncConfig(content, {
232
245
  // No schema-based validation at this point
233
246
  schema: undefined,
234
247
  defaultSchema: options?.defaultSchema ?? 'not_applicable', // Not needed for validation
@@ -263,20 +276,23 @@ export function updateSyncRulesFromConfig(
263
276
  }
264
277
 
265
278
  /**
266
- * Logs the errors and warnings of a parsed sync config
267
- *
268
- * Call this where a sync config is loaded or deployed, so that operators see these in the service logs and not only
269
- * in the diagnostics and validate API responses.
270
- *
271
- * Fatal errors usually throw during parsing before this is reached. They can reach this when the sync config is parsed
272
- * without validation, such as at startup with `exit_on_error: false`.
279
+ * Log parser errors and warnings from a parsed config, or combined parsing and source-capability
280
+ * diagnostics from SyncConfigEngine. Use when loading or deploying a sync config so operators see
281
+ * diagnostics in service logs as well as API responses. Fatal diagnostics are logged at error level;
282
+ * warnings remain advisory. Uses the supplied logger or the service logger by default.
273
283
  */
274
- export function logSyncConfigErrors(parsed: SyncConfigWithErrors, log: typeof logger = logger) {
275
- for (const error of parsed.errors) {
276
- if (error.type == 'warning') {
277
- log.warn(`Sync config warning: ${error.message}`);
284
+ export function logSyncConfigErrors(
285
+ result: SyncConfigWithErrors | ValidationDiagnostic[],
286
+ log: typeof logger = logger
287
+ ) {
288
+ const diagnostics = Array.isArray(result)
289
+ ? result
290
+ : result.errors.map((error) => ({ level: error.type, message: error.message }));
291
+ for (const diagnostic of diagnostics) {
292
+ if (diagnostic.level == 'warning') {
293
+ log.warn(`Sync config warning: ${diagnostic.message}`);
278
294
  } else {
279
- log.error(`Sync config error: ${error.message}`);
295
+ log.error(`Sync config error: ${diagnostic.message}`);
280
296
  }
281
297
  }
282
298
  }
@@ -9,9 +9,9 @@ import {
9
9
  HydratedSyncConfig,
10
10
  HydrationState,
11
11
  nodeSqlite,
12
+ parseSyncConfig,
12
13
  PrecompiledSyncConfig,
13
14
  SourceConnectionMap,
14
- SqlSyncRules,
15
15
  SyncConfigWithErrors,
16
16
  versionedHydrationState,
17
17
  YamlError
@@ -36,7 +36,7 @@ export function parsePersistedSyncConfigContent(options: ParsePersistedSyncConfi
36
36
 
37
37
  if (compiledPlan == null) {
38
38
  // Fallback: Only parse from YAML if no compiled plan is available.
39
- return SqlSyncRules.fromYaml(content, parseOptions);
39
+ return parseSyncConfig(content, parseOptions);
40
40
  }
41
41
 
42
42
  const plan = deserializeSyncPlan(compiledPlan.plan);
@@ -274,9 +274,25 @@ export interface CreateWriterOptions extends ParseSyncConfigOptions {
274
274
  skipExistingRows?: boolean;
275
275
 
276
276
  /**
277
- * Callback called if we streamed an update to a record that we don't have yet.
277
+ * Callback called when an update cannot reconstruct a complete current row,
278
+ * including replay of an update whose incomplete row was already persisted.
278
279
  *
279
280
  * This is expected to happen in some initial replication edge cases, only if storeCurrentData = true.
281
+ *
282
+ * The callback must synchronously enqueue repair keys, without mutating the
283
+ * record, throwing, returning a promise, or re-entering storage. It can run
284
+ * during flush rather than save, including after the data transaction commits.
285
+ * It is not an indication that a source checkpoint has advanced. Notifications
286
+ * can repeat on source replay; consumers must tolerate repeated repair keys.
287
+ *
288
+ * Consumers must explicitly await flush(), complete its queued repairs, and
289
+ * only then commit/keepalive, persist a resume position, or acknowledge the
290
+ * source. Do not rely on commit() auto-flushing pending rows when supplying
291
+ * this callback. Repair failures must stop progress and restart replication;
292
+ * the in-memory repair queue is not durable. Keep input records stable until
293
+ * their flush completes so deferred callbacks observe the original key. The
294
+ * update's after record must contain the full repair key; the callback receives
295
+ * that original update, rather than the merged current row.
280
296
  */
281
297
  markRecordUnavailable?: BucketStorageMarkRecordUnavailable;
282
298
 
@@ -582,4 +598,9 @@ export const CHECKPOINT_INVALIDATE_ALL: CheckpointChanges = {
582
598
  invalidateParameterBuckets: true
583
599
  };
584
600
 
585
- export type BucketStorageMarkRecordUnavailable = (record: SaveUpdate) => void;
601
+ /**
602
+ * Synchronously queues a repair; see CreateWriterOptions.markRecordUnavailable for ordering requirements.
603
+ *
604
+ * Requires an explicit undefined return type to avoid accidental async usage.
605
+ */
606
+ export type BucketStorageMarkRecordUnavailable = (record: SaveUpdate) => undefined;
@@ -1,4 +1,3 @@
1
- import { AbortError } from 'ix/aborterror.js';
2
1
  import { LastValueSink } from './LastValueSink.js';
3
2
 
4
3
  export type IterableSource<T> = (signal: AbortSignal) => AsyncIterable<T>;
@@ -47,8 +46,9 @@ export class BroadcastIterable<T> implements AsyncIterable<T> {
47
46
  private async loop(abortController: AbortController, sinks: Set<LastValueSink<T>>) {
48
47
  try {
49
48
  for await (let doc of this.source(abortController.signal)) {
50
- if (abortController.signal.aborted || sinks.size == 0) {
51
- throw new AbortError();
49
+ abortController.signal.throwIfAborted();
50
+ if (sinks.size == 0) {
51
+ throw new DOMException('No subscribers remain', 'AbortError');
52
52
  }
53
53
  this.last = doc;
54
54
  for (let sink of sinks) {
@@ -62,7 +62,7 @@ export class BroadcastIterable<T> implements AsyncIterable<T> {
62
62
  }
63
63
  } catch (e) {
64
64
  // Just in case the error is not from the source
65
- abortController.abort();
65
+ abortController.abort(e);
66
66
 
67
67
  for (let listener of sinks) {
68
68
  listener.error(e);
@@ -77,12 +77,12 @@ export class BroadcastIterable<T> implements AsyncIterable<T> {
77
77
  }
78
78
  }
79
79
 
80
- private removeSink(listener: LastValueSink<T>) {
80
+ private removeSink(listener: LastValueSink<T>, reason?: unknown) {
81
81
  this.subscribers?.delete(listener);
82
82
  if (this.subscribers?.size == 0) {
83
83
  // This is not immediate - there may be a delay until it is fully stopped,
84
84
  // depending on the underlying source.
85
- this.abortController?.abort();
85
+ this.abortController?.abort(reason);
86
86
  this.last = undefined;
87
87
  this.subscribers = undefined;
88
88
  this.abortController = undefined;
@@ -98,12 +98,13 @@ export class BroadcastIterable<T> implements AsyncIterable<T> {
98
98
  }
99
99
 
100
100
  async *[Symbol.asyncIterator](signal?: AbortSignal): AsyncIterableIterator<T> {
101
+ signal?.throwIfAborted();
101
102
  const sink = new LastValueSink(this.last);
102
103
  this.addSink(sink);
103
104
  try {
104
105
  yield* sink.withSignal(signal);
105
106
  } finally {
106
- this.removeSink(sink);
107
+ this.removeSink(sink, signal?.reason);
107
108
  }
108
109
  }
109
110
 
@@ -1,5 +1,3 @@
1
- import { AbortError } from 'ix/aborterror.js';
2
-
3
1
  /**
4
2
  * An AsyncIterable that allows pushing values.
5
3
  *
@@ -13,27 +11,25 @@ export class LastValueSink<T> implements AsyncIterable<T> {
13
11
 
14
12
  constructor(initial: T | undefined) {
15
13
  if (initial != null) {
16
- this.buffer = { value: initial, done: false, error: undefined };
14
+ this.buffer = { value: initial, done: false };
17
15
  }
18
16
  }
19
17
 
20
18
  write(value: T) {
21
19
  this.push({
22
20
  value,
23
- done: false,
24
- error: undefined
21
+ done: false
25
22
  });
26
23
  }
27
24
 
28
25
  end() {
29
26
  this.push({
30
27
  value: undefined,
31
- done: true,
32
- error: undefined
28
+ done: true
33
29
  });
34
30
  }
35
31
 
36
- error(e: any) {
32
+ error(e: unknown) {
37
33
  this.push({
38
34
  value: undefined,
39
35
  done: true,
@@ -55,19 +51,17 @@ export class LastValueSink<T> implements AsyncIterable<T> {
55
51
  return;
56
52
  }
57
53
 
58
- if (signal?.aborted) {
59
- throw new AbortError();
60
- }
54
+ signal.throwIfAborted();
61
55
 
62
56
  const onAbort = () => {
63
- this.error(new AbortError());
57
+ this.error(signal.reason);
64
58
  };
65
- signal?.addEventListener('abort', onAbort);
59
+ signal.addEventListener('abort', onAbort, { once: true });
66
60
 
67
61
  try {
68
62
  yield* this;
69
63
  } finally {
70
- signal?.removeEventListener('abort', onAbort);
64
+ signal.removeEventListener('abort', onAbort);
71
65
  }
72
66
  }
73
67
 
@@ -82,7 +76,7 @@ export class LastValueSink<T> implements AsyncIterable<T> {
82
76
  const n = this.buffer!;
83
77
  this.buffer = undefined;
84
78
 
85
- if (n.error) {
79
+ if ('error' in n) {
86
80
  throw n.error;
87
81
  } else if (n.done) {
88
82
  return;
@@ -96,5 +90,5 @@ export class LastValueSink<T> implements AsyncIterable<T> {
96
90
  interface NextResult<T> {
97
91
  value: T | undefined;
98
92
  done: boolean;
99
- error: any;
93
+ error?: unknown;
100
94
  }