@powersync/service-core 1.27.2 → 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.
- package/CHANGELOG.md +30 -0
- package/LICENSE +201 -67
- package/dist/api/RouteAPI.d.ts +6 -0
- package/dist/api/api-metrics.js +4 -0
- package/dist/api/api-metrics.js.map +1 -1
- package/dist/api/diagnostics.d.ts +5 -0
- package/dist/api/diagnostics.js +17 -2
- package/dist/api/diagnostics.js.map +1 -1
- package/dist/metrics/connection-metrics.d.ts +28 -0
- package/dist/metrics/connection-metrics.js +55 -0
- package/dist/metrics/connection-metrics.js.map +1 -0
- package/dist/metrics/metrics-index.d.ts +1 -0
- package/dist/metrics/metrics-index.js +1 -0
- package/dist/metrics/metrics-index.js.map +1 -1
- package/dist/metrics/metrics-interfaces.d.ts +7 -1
- package/dist/metrics/metrics-interfaces.js.map +1 -1
- package/dist/modules/loader.js +8 -2
- package/dist/modules/loader.js.map +1 -1
- package/dist/replication/AbstractReplicationJob.d.ts +2 -0
- package/dist/replication/AbstractReplicationJob.js +17 -1
- package/dist/replication/AbstractReplicationJob.js.map +1 -1
- package/dist/replication/AbstractReplicator.d.ts +8 -18
- package/dist/replication/AbstractReplicator.js +63 -64
- package/dist/replication/AbstractReplicator.js.map +1 -1
- package/dist/replication/ReplicationModule.js +0 -6
- package/dist/replication/ReplicationModule.js.map +1 -1
- package/dist/routes/configure-fastify.js +5 -1
- package/dist/routes/configure-fastify.js.map +1 -1
- package/dist/routes/endpoints/admin.js +24 -22
- package/dist/routes/endpoints/admin.js.map +1 -1
- package/dist/routes/endpoints/socket-route.js +19 -27
- package/dist/routes/endpoints/socket-route.js.map +1 -1
- package/dist/routes/endpoints/sync-rules.js +17 -40
- package/dist/routes/endpoints/sync-rules.js.map +1 -1
- package/dist/routes/endpoints/sync-stream.js +25 -23
- package/dist/routes/endpoints/sync-stream.js.map +1 -1
- package/dist/routes/hooks.d.ts +1 -1
- package/dist/routes/hooks.js +2 -1
- package/dist/routes/hooks.js.map +1 -1
- package/dist/routes/sync-connection.d.ts +7 -0
- package/dist/routes/sync-connection.js +34 -0
- package/dist/routes/sync-connection.js.map +1 -0
- package/dist/storage/BucketStorageFactory.d.ts +30 -22
- package/dist/storage/BucketStorageFactory.js +14 -14
- package/dist/storage/BucketStorageFactory.js.map +1 -1
- package/dist/storage/PersistedSyncConfigContent.js +2 -2
- package/dist/storage/PersistedSyncConfigContent.js.map +1 -1
- package/dist/storage/SyncRulesBucketStorage.d.ts +23 -2
- package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
- package/dist/streams/BroadcastIterable.js +8 -7
- package/dist/streams/BroadcastIterable.js.map +1 -1
- package/dist/streams/LastValueSink.d.ts +2 -2
- package/dist/streams/LastValueSink.js +8 -13
- package/dist/streams/LastValueSink.js.map +1 -1
- package/dist/streams/merge.d.ts +15 -31
- package/dist/streams/merge.js +136 -152
- package/dist/streams/merge.js.map +1 -1
- package/dist/sync/BucketChecksumState.d.ts +3 -2
- package/dist/sync/BucketChecksumState.js +12 -5
- package/dist/sync/BucketChecksumState.js.map +1 -1
- package/dist/sync/sync.js +48 -38
- package/dist/sync/sync.js.map +1 -1
- package/dist/sync/util.d.ts +1 -2
- package/dist/sync/util.js +2 -5
- package/dist/sync/util.js.map +1 -1
- package/dist/system/ServiceContext.d.ts +7 -0
- package/dist/system/ServiceContext.js +3 -3
- package/dist/system/ServiceContext.js.map +1 -1
- package/dist/system/SyncConfigEngine.d.ts +135 -0
- package/dist/system/SyncConfigEngine.js +186 -0
- package/dist/system/SyncConfigEngine.js.map +1 -0
- package/dist/system/system-index.d.ts +1 -0
- package/dist/system/system-index.js +1 -0
- package/dist/system/system-index.js.map +1 -1
- package/dist/util/config/types.d.ts +4 -0
- package/package.json +8 -9
- package/src/api/RouteAPI.ts +6 -0
- package/src/api/api-metrics.ts +5 -0
- package/src/api/diagnostics.ts +22 -2
- package/src/metrics/connection-metrics.ts +75 -0
- package/src/metrics/metrics-index.ts +1 -0
- package/src/metrics/metrics-interfaces.ts +8 -1
- package/src/modules/loader.ts +16 -2
- package/src/replication/AbstractReplicationJob.ts +17 -1
- package/src/replication/AbstractReplicator.ts +83 -81
- package/src/replication/ReplicationModule.ts +0 -5
- package/src/routes/configure-fastify.ts +7 -1
- package/src/routes/endpoints/admin.ts +27 -20
- package/src/routes/endpoints/socket-route.ts +26 -38
- package/src/routes/endpoints/sync-rules.ts +22 -46
- package/src/routes/endpoints/sync-stream.ts +31 -27
- package/src/routes/hooks.ts +5 -1
- package/src/routes/sync-connection.ts +39 -0
- package/src/storage/BucketStorageFactory.ts +45 -29
- package/src/storage/PersistedSyncConfigContent.ts +2 -2
- package/src/storage/SyncRulesBucketStorage.ts +23 -2
- package/src/streams/BroadcastIterable.ts +8 -7
- package/src/streams/LastValueSink.ts +10 -16
- package/src/streams/merge.ts +149 -169
- package/src/sync/BucketChecksumState.ts +17 -5
- package/src/sync/sync.ts +71 -57
- package/src/sync/util.ts +2 -7
- package/src/system/ServiceContext.ts +8 -3
- package/src/system/SyncConfigEngine.ts +247 -0
- package/src/system/system-index.ts +1 -0
- package/src/util/config/types.ts +4 -0
- package/test/src/AbstractReplicator.test.ts +375 -45
- package/test/src/PersistedSyncConfigContent.test.ts +5 -5
- package/test/src/SyncConfigEngine.test.ts +208 -0
- package/test/src/broadcast_iterable.test.ts +59 -31
- package/test/src/config.test.ts +65 -3
- package/test/src/connection-metrics-export.test.ts +76 -0
- package/test/src/connection-metrics.test.ts +184 -0
- package/test/src/diagnostics.test.ts +3 -3
- package/test/src/last_value_sink.test.ts +34 -0
- package/test/src/merge_iterable.test.ts +304 -55
- package/test/src/module-loader.test.ts +14 -1
- package/test/src/recording-metrics.ts +36 -0
- package/test/src/routes/admin.test.ts +2 -2
- package/test/src/routes/concurrency-limit.test.ts +108 -0
- package/test/src/routes/connection-metrics.test.ts +269 -0
- package/test/src/routes/invalid-config.test.ts +184 -0
- package/test/src/routes/mocks.ts +26 -13
- package/test/src/routes/stream-disconnect.integration.test.ts +129 -0
- package/test/src/routes/stream.test.ts +200 -107
- package/test/src/routes/sync-rules.test.ts +19 -1
- package/test/src/source_table_config.test.ts +7 -7
- package/test/src/sync/BucketChecksumState.test.ts +10 -10
- package/test/src/sync/stream_response.test.ts +389 -0
- package/test/src/sync/util.test.ts +14 -3
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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 ??=
|
|
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 ??=
|
|
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
|
|
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 ??=
|
|
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
|
|
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()
|
package/src/routes/hooks.ts
CHANGED
|
@@ -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 = (
|
|
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 =
|
|
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
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
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(
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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: ${
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
59
|
-
throw new AbortError();
|
|
60
|
-
}
|
|
54
|
+
signal.throwIfAborted();
|
|
61
55
|
|
|
62
56
|
const onAbort = () => {
|
|
63
|
-
this.error(
|
|
57
|
+
this.error(signal.reason);
|
|
64
58
|
};
|
|
65
|
-
signal
|
|
59
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
66
60
|
|
|
67
61
|
try {
|
|
68
62
|
yield* this;
|
|
69
63
|
} finally {
|
|
70
|
-
signal
|
|
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
|
|
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
|
|
93
|
+
error?: unknown;
|
|
100
94
|
}
|