@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
@@ -0,0 +1,186 @@
1
+ import { ErrorCode, logger, ServiceError } from '@powersync/lib-services-framework';
2
+ import { parseSyncConfig, SyncConfigErrors } from '@powersync/service-sync-rules';
3
+ import { updateSyncRulesFromConfig } from '../storage/BucketStorageFactory.js';
4
+ import { ConfigurationFileSyncRulesProvider } from '../util/config/sync-rules/sync-rules-provider.js';
5
+ import { syncConfigYamlErrorToReplicationError } from '../util/errors.js';
6
+ /**
7
+ * Outcome of loading or validating sync config, independent of persisted replication state.
8
+ */
9
+ export var SyncConfigResultStatus;
10
+ (function (SyncConfigResultStatus) {
11
+ /**
12
+ * No sync config is supplied through service configuration; use API-managed deployments.
13
+ */
14
+ SyncConfigResultStatus["ABSENT"] = "absent";
15
+ /**
16
+ * Configured input could not be loaded. This is distinct from invalid YAML and API-managed mode.
17
+ */
18
+ SyncConfigResultStatus["UNAVAILABLE"] = "unavailable";
19
+ /**
20
+ * Parsing and source-capability checks passed without fatal diagnostics; warnings may remain.
21
+ */
22
+ SyncConfigResultStatus["ACCEPTED"] = "accepted";
23
+ /**
24
+ * Parsing, compilation, or source-capability checks failed; the input cannot be deployed.
25
+ */
26
+ SyncConfigResultStatus["REJECTED"] = "rejected";
27
+ })(SyncConfigResultStatus || (SyncConfigResultStatus = {}));
28
+ /**
29
+ * Validation stage responsible for rejecting a sync config.
30
+ */
31
+ export var SyncConfigRejectionReason;
32
+ (function (SyncConfigRejectionReason) {
33
+ /**
34
+ * YAML parsing, query validation, or compilation failed.
35
+ */
36
+ SyncConfigRejectionReason["PARSE"] = "parse";
37
+ /**
38
+ * The source adapter rejected required execution capabilities or its capability check failed.
39
+ */
40
+ SyncConfigRejectionReason["CAPABILITY"] = "capability";
41
+ })(SyncConfigRejectionReason || (SyncConfigRejectionReason = {}));
42
+ /**
43
+ * Owns admission of configured and submitted YAML. "Configured" means supplied through service
44
+ * configuration from a filesystem path, Base64 input, or inline YAML, excluding deployment API submissions.
45
+ * Each process caches its configured result;
46
+ * replication jobs retain their storage-owned parsed configs and hydration identity.
47
+ */
48
+ export class SyncConfigEngine {
49
+ settings;
50
+ getRouteAPI;
51
+ configured;
52
+ /**
53
+ * @param settings Input source and startup policy from service configuration. Raw YAML is loaded
54
+ * on the first call to `getConfigured()`.
55
+ * @param getRouteAPI Resolves the registered source adapter only when YAML validation is needed.
56
+ * Adapter shutdown remains owned by the router lifecycle.
57
+ */
58
+ constructor(settings, getRouteAPI) {
59
+ this.settings = settings;
60
+ this.getRouteAPI = getRouteAPI;
61
+ }
62
+ /**
63
+ * Evaluate YAML supplied through service configuration (filesystem, Base64, or inline content),
64
+ * excluding deployment API submissions. Concurrent consumers share the same promise
65
+ * and result until restart. Reads configured input once on first use, then retains the loaded,
66
+ * parsed, or unavailable result without rereading files or mutating storage.
67
+ * Input load failures return `UNAVAILABLE`; absent configured input selects API-managed mode.
68
+ */
69
+ getConfigured() {
70
+ return (this.configured ??= this.evaluateConfigured());
71
+ }
72
+ async evaluateConfigured() {
73
+ if (!this.settings.present) {
74
+ return { status: SyncConfigResultStatus.ABSENT };
75
+ }
76
+ let content;
77
+ try {
78
+ content = await new ConfigurationFileSyncRulesProvider(this.settings).get();
79
+ if (content == null) {
80
+ throw new Error('Sync config is configured but no content or file path is available');
81
+ }
82
+ }
83
+ catch (error) {
84
+ logger.warn('Unable to read local sync config', error);
85
+ return { status: SyncConfigResultStatus.UNAVAILABLE, error };
86
+ }
87
+ return this.validate(content);
88
+ }
89
+ /**
90
+ * Parse supplied YAML, validate source capabilities even when the returned parse has fatal diagnostics,
91
+ * and prepare accepted input for storage. A parser exception with no config skips capability checks.
92
+ * Does not replace the configured snapshot or mutate storage. Validation failures become rejected
93
+ * results; source lookup or failures obtaining adapter parse options reject the promise.
94
+ */
95
+ async validate(content) {
96
+ const routeAPI = this.getRouteAPI();
97
+ const parseOptions = routeAPI.getParseSyncRulesOptions();
98
+ const now = new Date().toISOString();
99
+ const diagnostics = [];
100
+ let parsed;
101
+ try {
102
+ parsed = parseSyncConfig(content, { ...parseOptions, schema: undefined, throwOnError: false });
103
+ diagnostics.push(...parsed.errors.map((error) => syncConfigYamlErrorToReplicationError(error, now)));
104
+ const hasFatalParseErrors = diagnostics.some((error) => error.level === 'fatal');
105
+ try {
106
+ diagnostics.push(...((await routeAPI.validateSourceCapabilities?.(parsed.config)) ?? []).map((diagnostic) => ({
107
+ ...diagnostic,
108
+ ts: diagnostic.ts ?? now
109
+ })));
110
+ }
111
+ catch (error) {
112
+ diagnostics.push(...validationFailureDiagnostics(error, now));
113
+ }
114
+ if (diagnostics.some((error) => error.level === 'fatal')) {
115
+ return {
116
+ status: SyncConfigResultStatus.REJECTED,
117
+ reason: hasFatalParseErrors ? SyncConfigRejectionReason.PARSE : SyncConfigRejectionReason.CAPABILITY,
118
+ content,
119
+ parsed,
120
+ diagnostics
121
+ };
122
+ }
123
+ return {
124
+ status: SyncConfigResultStatus.ACCEPTED,
125
+ content,
126
+ compiled: updateSyncRulesFromConfig(parsed).config,
127
+ diagnostics
128
+ };
129
+ }
130
+ catch (error) {
131
+ diagnostics.push(...validationFailureDiagnostics(error, now));
132
+ return {
133
+ status: SyncConfigResultStatus.REJECTED,
134
+ reason: SyncConfigRejectionReason.PARSE,
135
+ content,
136
+ parsed,
137
+ diagnostics
138
+ };
139
+ }
140
+ }
141
+ }
142
+ function validationFailureDiagnostics(error, ts) {
143
+ return error instanceof SyncConfigErrors
144
+ ? error.errors.map((error) => syncConfigYamlErrorToReplicationError(error, ts))
145
+ : [{ level: 'fatal', message: error instanceof Error ? error.message : String(error), ts }];
146
+ }
147
+ /**
148
+ * Return deployable input from an accepted result.
149
+ * @throws A 422 `PSYNC_R0001` service error containing fatal diagnostics when the input was rejected.
150
+ */
151
+ export function assertAcceptedSyncConfig(result) {
152
+ if (result.status === SyncConfigResultStatus.ACCEPTED) {
153
+ return result.compiled;
154
+ }
155
+ throw new ServiceError({
156
+ status: 422,
157
+ code: ErrorCode.PSYNC_R0001,
158
+ description: result.reason === SyncConfigRejectionReason.PARSE
159
+ ? 'Sync config parsing failed'
160
+ : 'Source capability validation failed',
161
+ details: result.diagnostics
162
+ .filter((error) => error.level === 'fatal')
163
+ .map((error) => error.message)
164
+ .join('\n')
165
+ });
166
+ }
167
+ /**
168
+ * Check local admission before manual reprocessing; callers separately check stored processing work.
169
+ * Both manual reprocess endpoints use this guard; automatic recovery does not.
170
+ * @throws A 409 `PSYNC_S4106` service error for rejected input, or the original input load failure.
171
+ */
172
+ export async function assertConfiguredSyncConfigValid(engine) {
173
+ const result = await engine.getConfigured();
174
+ if (result.status === SyncConfigResultStatus.REJECTED) {
175
+ throw new ServiceError({
176
+ status: 409,
177
+ code: ErrorCode.PSYNC_S4106,
178
+ description: 'Cannot reprocess while the configured sync config is invalid',
179
+ details: 'Correct the sync config and restart the affected service processes'
180
+ });
181
+ }
182
+ if (result.status === SyncConfigResultStatus.UNAVAILABLE) {
183
+ throw result.error;
184
+ }
185
+ }
186
+ //# sourceMappingURL=SyncConfigEngine.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SyncConfigEngine.js","sourceRoot":"","sources":["../../src/system/SyncConfigEngine.ts"],"names":[],"mappings":"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"}
@@ -1 +1,2 @@
1
1
  export * from './ServiceContext.js';
2
+ export * from './SyncConfigEngine.js';
@@ -1,2 +1,3 @@
1
1
  export * from './ServiceContext.js';
2
+ export * from './SyncConfigEngine.js';
2
3
  //# sourceMappingURL=system-index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"system-index.js","sourceRoot":"","sources":["../../src/system/system-index.ts"],"names":[],"mappings":"AAAA,cAAc,qBAAqB,CAAC"}
1
+ {"version":3,"file":"system-index.js","sourceRoot":"","sources":["../../src/system/system-index.ts"],"names":[],"mappings":"AAAA,cAAc,qBAAqB,CAAC;AACpC,cAAc,uBAAuB,CAAC"}
@@ -15,6 +15,10 @@ export type MigrationContext = {
15
15
  runner_config: RunnerConfig;
16
16
  };
17
17
  export type Runner = (config: RunnerConfig) => Promise<void>;
18
+ /**
19
+ * Sync config supplied through service configuration: a filesystem path, Base64 input, or inline YAML.
20
+ * This excludes configs submitted through the deployment API; `present: false` selects API-managed mode.
21
+ */
18
22
  export type SyncRulesConfig = {
19
23
  present: boolean;
20
24
  version_label?: string;
package/package.json CHANGED
@@ -4,8 +4,8 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "1.27.1",
8
- "license": "FSL-1.1-ALv2",
7
+ "version": "1.28.0",
8
+ "license": "Apache-2.0",
9
9
  "type": "module",
10
10
  "exports": "./dist/index.js",
11
11
  "dependencies": {
@@ -22,7 +22,6 @@
22
22
  "commander": "^12.0.0",
23
23
  "cors": "^2.8.5",
24
24
  "ipaddr.js": "^2.1.0",
25
- "ix": "^5.0.0",
26
25
  "jose": "^4.15.1",
27
26
  "lodash": "^4.18.1",
28
27
  "lru-cache": "^10.2.2",
@@ -33,17 +32,17 @@
33
32
  "uuid": "^14.0.0",
34
33
  "winston": "^3.13.0",
35
34
  "yaml": "^2.8.3",
36
- "@powersync/service-rsocket-router": "0.2.29",
37
- "@powersync/service-jsonbig": "0.17.13",
38
- "@powersync/service-types": "0.20.0",
39
- "@powersync/service-sync-rules": "0.44.0",
40
- "@powersync/lib-services-framework": "0.10.4"
35
+ "@powersync/lib-services-framework": "0.10.5",
36
+ "@powersync/service-jsonbig": "0.17.14",
37
+ "@powersync/service-rsocket-router": "0.3.0",
38
+ "@powersync/service-sync-rules": "0.45.0",
39
+ "@powersync/service-types": "0.21.0"
41
40
  },
42
41
  "devDependencies": {
43
42
  "@types/async": "^3.2.24",
44
43
  "@types/lodash": "^4.17.5",
45
44
  "@types/negotiator": "^0.6.4",
46
- "fastify": "^5.12.5",
45
+ "fastify": "^5.12.7",
47
46
  "fastify-plugin": "^5.0.1"
48
47
  },
49
48
  "scripts": {
@@ -44,6 +44,12 @@ export interface RouteAPI {
44
44
  /**
45
45
  * Checks whether the source supports the capabilities required by this sync config.
46
46
  * Must not modify the source. Return advisory warnings and fatal validation diagnostics.
47
+ * May receive a partial config with fatal parser diagnostics; report capability issues independently.
48
+ * SyncConfigEngine currently caches admission results for the configured snapshot until restart,
49
+ * including fatal diagnostics created from unexpected validation exceptions. Temporary failures
50
+ * can therefore leave diagnostics reporting a stale rejection. Retrying failed validation attempts
51
+ * from diagnostics without reloading the configured YAML is a future improvement.
52
+ * Check configured executor capabilities here; live connectivity and table metadata checks are separate.
47
53
  * An empty array means no issues; throw only for unexpected failures.
48
54
  */
49
55
  validateSourceCapabilities?(syncConfig: SyncConfig): Promise<types.ValidationDiagnostic[]>;
@@ -27,6 +27,11 @@ export function createCoreAPIMetrics(engine: MetricsEngine): void {
27
27
  name: APIMetric.CONCURRENT_CONNECTIONS,
28
28
  description: 'Number of concurrent sync connections'
29
29
  });
30
+
31
+ engine.createCounter({
32
+ name: APIMetric.SYNC_CONNECTIONS,
33
+ description: 'Sync stream closes and setup rejections, by outcome, close_reason, error_code and transport'
34
+ });
30
35
  }
31
36
 
32
37
  /**
@@ -1,8 +1,9 @@
1
1
  import { logger, ServiceAssertionError } from '@powersync/lib-services-framework';
2
- import { DEFAULT_TAG, SourceTableRef, SyncConfigWithErrors, SyncRulesErrors } from '@powersync/service-sync-rules';
2
+ import { DEFAULT_TAG, SourceTableRef, SyncConfigErrors, SyncConfigWithErrors } from '@powersync/service-sync-rules';
3
3
  import { ReplicationError, SyncRulesStatus, TableInfo } from '@powersync/service-types';
4
4
 
5
5
  import * as storage from '../storage/storage-index.js';
6
+ import { ConfiguredSyncConfigResult, SyncConfigResultStatus } from '../system/SyncConfigEngine.js';
6
7
  import { syncConfigYamlErrorToReplicationError } from '../util/errors.js';
7
8
  import { RouteAPI, SlotWalBudgetInfo } from './RouteAPI.js';
8
9
 
@@ -32,6 +33,25 @@ export interface DiagnosticsOptions {
32
33
 
33
34
  export const DEFAULT_DATASOURCE_ID = 'default';
34
35
 
36
+ /**
37
+ * Project the cached local admission result without creating a persisted deployment.
38
+ */
39
+ export function getInvalidLocalSyncConfigStatus(
40
+ result: ConfiguredSyncConfigResult,
41
+ includeContent: boolean,
42
+ versionLabel?: string
43
+ ): SyncRulesStatus | undefined {
44
+ if (result.status !== SyncConfigResultStatus.REJECTED) {
45
+ return undefined;
46
+ }
47
+ return {
48
+ version_label: versionLabel,
49
+ content: includeContent ? result.content : undefined,
50
+ connections: [],
51
+ errors: result.diagnostics
52
+ };
53
+ }
54
+
35
55
  export async function getSyncRulesStatus(
36
56
  apiHandler: RouteAPI,
37
57
  syncConfig: storage.PersistedSyncConfigContent | null,
@@ -70,7 +90,7 @@ export async function getSyncRulesStatus(
70
90
  connections: [],
71
91
  // Report each sync config error separately, retaining its source location.
72
92
  errors:
73
- e instanceof SyncRulesErrors
93
+ e instanceof SyncConfigErrors
74
94
  ? e.errors.map((error) => syncConfigYamlErrorToReplicationError(error, now))
75
95
  : [{ level: 'fatal', message: e.message, ts: now }]
76
96
  };
@@ -0,0 +1,75 @@
1
+ import { ErrorCode, errors } from '@powersync/lib-services-framework';
2
+ import { APIMetric } from '@powersync/service-types';
3
+ import type { MetricsEngine } from './MetricsEngine.js';
4
+ import type { MetricAttributes } from './metrics-interfaces.js';
5
+
6
+ export enum SyncCloseReason {
7
+ /** Routine client disconnect/reconnect. */
8
+ ClientClosed = 'client_closed',
9
+ /** Server-side close: auth expiry or switch to a new sync config. */
10
+ ServiceClosed = 'service_closed',
11
+ ProcessShutdown = 'process_shutdown',
12
+ StreamError = 'stream_error',
13
+ ServiceUnavailable = 'service_unavailable',
14
+ NoSyncConfig = 'no_sync_config',
15
+ StorageError = 'storage_error',
16
+ SyncConfigError = 'sync_config_error',
17
+ ConcurrencyLimit = 'concurrency_limit',
18
+ Unknown = 'unknown'
19
+ }
20
+
21
+ export enum SyncTransport {
22
+ HttpStream = 'http_stream',
23
+ RSocket = 'rsocket'
24
+ }
25
+
26
+ type SyncConnectionReasonPolicy = Readonly<{
27
+ outcome: 'ok' | 'error' | 'rejected';
28
+ logText: string;
29
+ }>;
30
+
31
+ const SYNC_CONNECTION_REASON_POLICY = {
32
+ [SyncCloseReason.ClientClosed]: { outcome: 'ok', logText: 'client closing stream' },
33
+ [SyncCloseReason.ServiceClosed]: { outcome: 'ok', logText: 'service closing stream' },
34
+ [SyncCloseReason.ProcessShutdown]: { outcome: 'ok', logText: 'process shutdown' },
35
+ [SyncCloseReason.StreamError]: { outcome: 'error', logText: 'stream error' },
36
+ [SyncCloseReason.ServiceUnavailable]: { outcome: 'rejected', logText: 'service unavailable' },
37
+ [SyncCloseReason.NoSyncConfig]: { outcome: 'rejected', logText: 'no sync config' },
38
+ [SyncCloseReason.StorageError]: { outcome: 'rejected', logText: 'storage error' },
39
+ [SyncCloseReason.SyncConfigError]: { outcome: 'rejected', logText: 'sync config error' },
40
+ [SyncCloseReason.ConcurrencyLimit]: { outcome: 'rejected', logText: 'concurrency limit' },
41
+ // Nothing was thrown or reported, so the stream ended cleanly without a specific reason.
42
+ [SyncCloseReason.Unknown]: { outcome: 'ok', logText: 'unknown' }
43
+ } as const satisfies Readonly<Record<SyncCloseReason, SyncConnectionReasonPolicy>>;
44
+
45
+ /** Wording used by existing log-based dashboards for the `close_reason` field. */
46
+ export function syncConnectionCloseReasonLogText(closeReason?: SyncCloseReason): string {
47
+ return SYNC_CONNECTION_REASON_POLICY[closeReason ?? SyncCloseReason.Unknown].logText;
48
+ }
49
+
50
+ export interface SyncConnectionMetric {
51
+ transport: SyncTransport;
52
+ closeReason: SyncCloseReason;
53
+ /** The original failure, before any transport-specific wrapping. */
54
+ error?: unknown;
55
+ }
56
+
57
+ const ERROR_CODES: ReadonlySet<string> = new Set(Object.values(ErrorCode));
58
+
59
+ export function recordSyncConnection(engine: MetricsEngine, metric: SyncConnectionMetric): void {
60
+ const { outcome } = SYNC_CONNECTION_REASON_POLICY[metric.closeReason];
61
+
62
+ const attributes: MetricAttributes = {
63
+ outcome,
64
+ close_reason: metric.closeReason,
65
+ transport: metric.transport
66
+ };
67
+
68
+ if (outcome !== 'ok') {
69
+ const code = errors.ServiceError.isServiceError(metric.error) ? metric.error.errorData?.code : undefined;
70
+ const errorCode = typeof code == 'string' && ERROR_CODES.has(code) ? code : 'other';
71
+ attributes.error_code = errorCode;
72
+ }
73
+
74
+ engine.getCounter(APIMetric.SYNC_CONNECTIONS).add(1, attributes);
75
+ }
@@ -1,3 +1,4 @@
1
+ export * from './connection-metrics.js';
1
2
  export * from './metrics-interfaces.js';
2
3
  export * from './MetricsEngine.js';
3
4
  export * from './open-telemetry/OpenTelemetryMetricsFactory.js';
@@ -1,9 +1,16 @@
1
+ /**
2
+ * Bounded-cardinality attributes (labels) attached to a metric data point.
3
+ * Only use low-cardinality enum-like values here — never user/client/request identifiers.
4
+ */
5
+ export type MetricAttributes = Record<string, string | number | boolean>;
6
+
1
7
  export interface Counter {
2
8
  /**
3
9
  * Increment the counter by the given value. Only positive numbers are valid.
4
10
  * @param value
11
+ * @param attributes optional low-cardinality labels for this increment
5
12
  */
6
- add(value: number): void;
13
+ add(value: number, attributes?: MetricAttributes): void;
7
14
  }
8
15
 
9
16
  export interface UpDownCounter {
@@ -1,3 +1,4 @@
1
+ import { ErrorCode, ServiceError } from '@powersync/lib-services-framework';
1
2
  import { ResolvedPowerSyncConfig } from '../util/util-index.js';
2
3
  import { AbstractModule } from './AbstractModule.js';
3
4
 
@@ -9,6 +10,11 @@ export interface ModuleLoaders {
9
10
  storage: DynamicModuleMap;
10
11
  connection: DynamicModuleMap;
11
12
  }
13
+
14
+ function quoteList(values: string[]): string {
15
+ return values.map((value) => `"${value}"`).join(', ');
16
+ }
17
+
12
18
  /**
13
19
  * Utility function to dynamically load and instantiate modules.
14
20
  */
@@ -30,13 +36,21 @@ export async function loadModules(config: ResolvedPowerSyncConfig, loaders: Modu
30
36
 
31
37
  // Fail if any connection types are not found.
32
38
  if (missingConnectionModules.length > 0) {
33
- throw new Error(`Invalid connection types: "${[...missingConnectionModules].join(', ')}"`);
39
+ const validConnections = Object.keys(loaders.connection);
40
+ throw new ServiceError(
41
+ ErrorCode.PSYNC_S3201,
42
+ `Invalid connection types: ${quoteList(missingConnectionModules)}${validConnections.length > 0 ? `. Supported types: ${quoteList(validConnections)}.` : ''}`
43
+ );
34
44
  }
35
45
 
36
46
  if (loaders.storage[config.storage.type] !== undefined) {
37
47
  modulePromises.push(loaders.storage[config.storage.type]());
38
48
  } else {
39
- throw new Error(`Invalid storage type: "${config.storage.type}"`);
49
+ const validStorage = Object.keys(loaders.storage);
50
+ throw new ServiceError(
51
+ ErrorCode.PSYNC_S3201,
52
+ `Invalid storage type: "${config.storage.type}"${validStorage.length > 0 ? `. Supported types: ${quoteList(validStorage)}.` : ''}`
53
+ );
40
54
  }
41
55
 
42
56
  // 2. Dynamically import and instantiate module classes and resolve all promises
@@ -1,4 +1,4 @@
1
- import { container, logger } from '@powersync/lib-services-framework';
1
+ import { container, logger, ReplicationAbortedError } from '@powersync/lib-services-framework';
2
2
  import winston from 'winston';
3
3
  import { MetricsEngine } from '../metrics/MetricsEngine.js';
4
4
  import type { CreateJobOptions } from './AbstractReplicator.js';
@@ -73,6 +73,22 @@ export abstract class AbstractReplicationJob {
73
73
  return this.options.rateLimiter;
74
74
  }
75
75
 
76
+ /** Log expected restarts without reporting them as replication failures. */
77
+ protected shouldReportReplicationError(error: unknown): boolean {
78
+ if (this.isStopped) {
79
+ return false;
80
+ }
81
+ if (error instanceof ReplicationAbortedError) {
82
+ if (error.cause != null) {
83
+ this.logger.warn('Replication aborted', error);
84
+ } else {
85
+ this.logger.info('Replication aborted', error);
86
+ }
87
+ return false;
88
+ }
89
+ return true;
90
+ }
91
+
76
92
  public get isStopped(): boolean {
77
93
  return this.abortController.signal.aborted;
78
94
  }