@powersync/service-core 1.14.0 → 1.15.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 (71) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +3 -3
  3. package/dist/api/api-metrics.js +5 -0
  4. package/dist/api/api-metrics.js.map +1 -1
  5. package/dist/api/diagnostics.js +1 -1
  6. package/dist/api/diagnostics.js.map +1 -1
  7. package/dist/metrics/open-telemetry/util.d.ts +0 -3
  8. package/dist/metrics/open-telemetry/util.js +18 -13
  9. package/dist/metrics/open-telemetry/util.js.map +1 -1
  10. package/dist/routes/compression.d.ts +19 -0
  11. package/dist/routes/compression.js +70 -0
  12. package/dist/routes/compression.js.map +1 -0
  13. package/dist/routes/configure-fastify.d.ts +40 -5
  14. package/dist/routes/endpoints/socket-route.js +24 -9
  15. package/dist/routes/endpoints/socket-route.js.map +1 -1
  16. package/dist/routes/endpoints/sync-rules.js +1 -27
  17. package/dist/routes/endpoints/sync-rules.js.map +1 -1
  18. package/dist/routes/endpoints/sync-stream.d.ts +80 -10
  19. package/dist/routes/endpoints/sync-stream.js +17 -12
  20. package/dist/routes/endpoints/sync-stream.js.map +1 -1
  21. package/dist/storage/BucketStorage.d.ts +1 -1
  22. package/dist/storage/BucketStorage.js.map +1 -1
  23. package/dist/storage/BucketStorageBatch.d.ts +4 -4
  24. package/dist/storage/BucketStorageBatch.js.map +1 -1
  25. package/dist/storage/ChecksumCache.d.ts +4 -19
  26. package/dist/storage/ChecksumCache.js +4 -0
  27. package/dist/storage/ChecksumCache.js.map +1 -1
  28. package/dist/storage/ReplicationEventPayload.d.ts +2 -2
  29. package/dist/storage/SyncRulesBucketStorage.d.ts +9 -0
  30. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  31. package/dist/sync/BucketChecksumState.d.ts +40 -10
  32. package/dist/sync/BucketChecksumState.js +154 -18
  33. package/dist/sync/BucketChecksumState.js.map +1 -1
  34. package/dist/sync/RequestTracker.d.ts +7 -1
  35. package/dist/sync/RequestTracker.js +22 -2
  36. package/dist/sync/RequestTracker.js.map +1 -1
  37. package/dist/sync/sync.d.ts +3 -3
  38. package/dist/sync/sync.js +23 -42
  39. package/dist/sync/sync.js.map +1 -1
  40. package/dist/sync/util.js +1 -1
  41. package/dist/sync/util.js.map +1 -1
  42. package/dist/util/protocol-types.d.ts +153 -9
  43. package/dist/util/protocol-types.js +41 -6
  44. package/dist/util/protocol-types.js.map +1 -1
  45. package/dist/util/utils.d.ts +18 -3
  46. package/dist/util/utils.js +33 -9
  47. package/dist/util/utils.js.map +1 -1
  48. package/package.json +14 -14
  49. package/src/api/api-metrics.ts +6 -0
  50. package/src/api/diagnostics.ts +1 -1
  51. package/src/metrics/open-telemetry/util.ts +22 -21
  52. package/src/routes/compression.ts +75 -0
  53. package/src/routes/endpoints/socket-route.ts +24 -9
  54. package/src/routes/endpoints/sync-rules.ts +1 -28
  55. package/src/routes/endpoints/sync-stream.ts +18 -15
  56. package/src/storage/BucketStorage.ts +2 -2
  57. package/src/storage/BucketStorageBatch.ts +10 -4
  58. package/src/storage/ChecksumCache.ts +8 -22
  59. package/src/storage/ReplicationEventPayload.ts +2 -2
  60. package/src/storage/SyncRulesBucketStorage.ts +12 -0
  61. package/src/sync/BucketChecksumState.ts +192 -29
  62. package/src/sync/RequestTracker.ts +27 -2
  63. package/src/sync/sync.ts +53 -51
  64. package/src/sync/util.ts +1 -1
  65. package/src/util/protocol-types.ts +138 -10
  66. package/src/util/utils.ts +59 -12
  67. package/test/src/checksum_cache.test.ts +6 -8
  68. package/test/src/routes/mocks.ts +59 -0
  69. package/test/src/routes/stream.test.ts +84 -0
  70. package/test/src/sync/BucketChecksumState.test.ts +340 -42
  71. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,75 @@
1
+ import type Negotiator from 'negotiator';
2
+ import { PassThrough, pipeline, Readable, Transform } from 'node:stream';
3
+ import * as zlib from 'node:zlib';
4
+ import { RequestTracker } from '../sync/RequestTracker.js';
5
+
6
+ /**
7
+ * Compress a streamed response.
8
+ *
9
+ * `@fastify/compress` can do something similar, but does not appear to work as well on streamed responses.
10
+ * The manual implementation is simple enough, and gives us more control over the low-level details.
11
+ *
12
+ * @param negotiator Negotiator from the request, to negotiate response encoding
13
+ * @param stream plain-text stream
14
+ * @returns
15
+ */
16
+ export function maybeCompressResponseStream(
17
+ negotiator: Negotiator,
18
+ stream: Readable,
19
+ tracker: RequestTracker
20
+ ): { stream: Readable; encodingHeaders: { 'content-encoding'?: string } } {
21
+ const encoding = (negotiator as any).encoding(['identity', 'gzip', 'zstd'], { preferred: 'zstd' });
22
+ const transform = createCompressionTransform(encoding);
23
+ if (transform == null) {
24
+ // No matching compression supported - leave stream as-is
25
+ return {
26
+ stream,
27
+ encodingHeaders: {}
28
+ };
29
+ } else {
30
+ tracker.setCompressed(encoding);
31
+ return {
32
+ stream: transformStream(stream, transform, tracker),
33
+ encodingHeaders: { 'content-encoding': encoding }
34
+ };
35
+ }
36
+ }
37
+
38
+ function createCompressionTransform(encoding: string | undefined): Transform | null {
39
+ if (encoding == 'zstd') {
40
+ // Available since Node v23.8.0, v22.15.0
41
+ // This does the actual compression in a background thread pool.
42
+ return zlib.createZstdCompress({
43
+ // We need to flush the frame after every new input chunk, to avoid delaying data
44
+ // in the output stream.
45
+ flush: zlib.constants.ZSTD_e_flush,
46
+ params: {
47
+ // Default compression level is 3. We reduce this slightly to limit CPU overhead
48
+ [zlib.constants.ZSTD_c_compressionLevel]: 2
49
+ }
50
+ });
51
+ } else if (encoding == 'gzip') {
52
+ return zlib.createGzip({
53
+ // We need to flush the frame after every new input chunk, to avoid delaying data
54
+ // in the output stream.
55
+ flush: zlib.constants.Z_SYNC_FLUSH
56
+ });
57
+ }
58
+ return null;
59
+ }
60
+
61
+ function transformStream(source: Readable, transform: Transform, tracker: RequestTracker) {
62
+ // pipe does not forward error events automatically, resulting in unhandled error
63
+ // events. This forwards it.
64
+ const out = new PassThrough();
65
+ const trackingTransform = new Transform({
66
+ transform(chunk, _encoding, callback) {
67
+ tracker.addCompressedDataSent(chunk.length);
68
+ callback(null, chunk);
69
+ }
70
+ });
71
+ pipeline(source, transform, trackingTransform, out, (err) => {
72
+ if (err) out.destroy(err);
73
+ });
74
+ return out;
75
+ }
@@ -11,7 +11,7 @@ import { APIMetric } from '@powersync/service-types';
11
11
  export const syncStreamReactive: SocketRouteGenerator = (router) =>
12
12
  router.reactiveStream<util.StreamingSyncRequest, any>(SyncRoutes.STREAM, {
13
13
  validator: schema.createTsCodecValidator(util.StreamingSyncRequest, { allowAdditional: true }),
14
- handler: async ({ context, params, responder, observer, initialN, signal: upstreamSignal }) => {
14
+ handler: async ({ context, params, responder, observer, initialN, signal: upstreamSignal, connection }) => {
15
15
  const { service_context, logger } = context;
16
16
  const { routerEngine, metricsEngine, syncContext } = service_context;
17
17
 
@@ -58,8 +58,6 @@ export const syncStreamReactive: SocketRouteGenerator = (router) =>
58
58
  return;
59
59
  }
60
60
 
61
- const syncParams = new RequestParameters(context.token_payload!, params.parameters ?? {});
62
-
63
61
  const {
64
62
  storageEngine: { activeBucketStorage }
65
63
  } = service_context;
@@ -86,16 +84,21 @@ export const syncStreamReactive: SocketRouteGenerator = (router) =>
86
84
 
87
85
  metricsEngine.getUpDownCounter(APIMetric.CONCURRENT_CONNECTIONS).add(1);
88
86
  const tracker = new sync.RequestTracker(metricsEngine);
87
+ if (connection.tracker.encoding) {
88
+ // Must be set before we start the stream
89
+ tracker.setCompressed(connection.tracker.encoding);
90
+ }
89
91
  try {
90
92
  for await (const data of sync.streamResponse({
91
93
  syncContext: syncContext,
92
94
  bucketStorage: bucketStorage,
93
- syncRules: syncRules,
95
+ syncRules: {
96
+ syncRules,
97
+ version: bucketStorage.group_id
98
+ },
94
99
  params: {
95
- ...params,
96
- binary_data: true // always true for web sockets
100
+ ...params
97
101
  },
98
- syncParams,
99
102
  token: context!.token_payload!,
100
103
  tokenStreamOptions: {
101
104
  // RSocket handles keepalive events by default
@@ -103,7 +106,8 @@ export const syncStreamReactive: SocketRouteGenerator = (router) =>
103
106
  },
104
107
  tracker,
105
108
  signal,
106
- logger
109
+ logger,
110
+ isEncodingAsBson: true
107
111
  })) {
108
112
  if (signal.aborted) {
109
113
  break;
@@ -116,7 +120,7 @@ export const syncStreamReactive: SocketRouteGenerator = (router) =>
116
120
  const serialized = sync.syncLineToBson(data);
117
121
  responder.onNext({ data: serialized }, false);
118
122
  requestedN--;
119
- tracker.addDataSynced(serialized.length);
123
+ tracker.addPlaintextDataSynced(serialized.length);
120
124
  }
121
125
 
122
126
  if (requestedN <= 0 && !signal.aborted) {
@@ -153,6 +157,17 @@ export const syncStreamReactive: SocketRouteGenerator = (router) =>
153
157
  responder.onComplete();
154
158
  removeStopHandler();
155
159
  disposer();
160
+ if (connection.tracker.encoding) {
161
+ // Technically, this may not be unique to this specific stream, since there could be multiple
162
+ // rsocket streams on the same websocket connection. We don't have a way to track compressed bytes
163
+ // on individual streams, and we generally expect 1 stream per connection, so this is a reasonable
164
+ // approximation.
165
+ // If there are multiple streams, bytes written would be split arbitrarily across them, but the
166
+ // total should be correct.
167
+ // For non-compressed cases, this is tracked by the stream itself.
168
+ const socketBytes = connection.tracker.getBytesWritten();
169
+ tracker.addCompressedDataSent(socketBytes);
170
+ }
156
171
  logger.info(`Sync stream complete`, {
157
172
  ...tracker.getLogMeta(),
158
173
  stream_ms: Date.now() - streamStart,
@@ -202,34 +202,7 @@ async function debugSyncRules(apiHandler: RouteAPI, sync_rules: string) {
202
202
 
203
203
  return {
204
204
  valid: true,
205
- bucket_definitions: rules.bucketDescriptors.map((d) => {
206
- let all_parameter_queries = [...d.parameterQueries.values()].flat();
207
- let all_data_queries = [...d.dataQueries.values()].flat();
208
- return {
209
- name: d.name,
210
- bucket_parameters: d.bucketParameters,
211
- global_parameter_queries: d.globalParameterQueries.map((q) => {
212
- return {
213
- sql: q.sql
214
- };
215
- }),
216
- parameter_queries: all_parameter_queries.map((q) => {
217
- return {
218
- sql: q.sql,
219
- table: q.sourceTable,
220
- input_parameters: q.inputParameters
221
- };
222
- }),
223
-
224
- data_queries: all_data_queries.map((q) => {
225
- return {
226
- sql: q.sql,
227
- table: q.sourceTable,
228
- columns: q.columnOutputNames()
229
- };
230
- })
231
- };
232
- }),
205
+ bucket_definitions: rules.bucketSources.map((source) => source.debugRepresentation()),
233
206
  source_tables: resolved_tables,
234
207
  data_tables: rules.debugGetOutputTables()
235
208
  };
@@ -1,7 +1,6 @@
1
- import { ErrorCode, errors, logger, router, schema } from '@powersync/lib-services-framework';
2
- import { RequestParameters } from '@powersync/service-sync-rules';
3
- import { Readable } from 'stream';
1
+ import { ErrorCode, errors, router, schema } from '@powersync/lib-services-framework';
4
2
  import Negotiator from 'negotiator';
3
+ import { Readable } from 'stream';
5
4
 
6
5
  import * as sync from '../../sync/sync-index.js';
7
6
  import * as util from '../../util/util-index.js';
@@ -10,6 +9,7 @@ import { authUser } from '../auth.js';
10
9
  import { routeDefinition } from '../router.js';
11
10
 
12
11
  import { APIMetric } from '@powersync/service-types';
12
+ import { maybeCompressResponseStream } from '../compression.js';
13
13
 
14
14
  export enum SyncRoutes {
15
15
  STREAM = '/sync/stream'
@@ -31,10 +31,11 @@ export const syncStreamed = routeDefinition({
31
31
  const userAgent = headers['x-user-agent'] ?? headers['user-agent'];
32
32
  const clientId = payload.params.client_id;
33
33
  const streamStart = Date.now();
34
+ const negotiator = new Negotiator(payload.request);
34
35
  // This falls back to JSON unless there's preference for the bson-stream in the Accept header.
35
- const useBson =
36
- payload.request.headers.accept &&
37
- new Negotiator(payload.request).mediaType(supportedContentTypes) == concatenatedBsonContentType;
36
+ const useBson = payload.request.headers.accept
37
+ ? negotiator.mediaType(supportedContentTypes) == concatenatedBsonContentType
38
+ : false;
38
39
 
39
40
  logger.defaultMeta = {
40
41
  ...logger.defaultMeta,
@@ -52,9 +53,6 @@ export const syncStreamed = routeDefinition({
52
53
  });
53
54
  }
54
55
 
55
- const params: util.StreamingSyncRequest = payload.params;
56
- const syncParams = new RequestParameters(payload.context.token_payload!, payload.params.parameters ?? {});
57
-
58
56
  const bucketStorage = await storageEngine.activeBucketStorage.getActiveStorage();
59
57
 
60
58
  if (bucketStorage == null) {
@@ -74,20 +72,24 @@ export const syncStreamed = routeDefinition({
74
72
  const syncLines = sync.streamResponse({
75
73
  syncContext: syncContext,
76
74
  bucketStorage,
77
- syncRules: syncRules,
78
- params,
79
- syncParams,
75
+ syncRules: {
76
+ syncRules,
77
+ version: bucketStorage.group_id
78
+ },
79
+ params: payload.params,
80
80
  token: payload.context.token_payload!,
81
81
  tracker,
82
82
  signal: controller.signal,
83
- logger
83
+ logger,
84
+ isEncodingAsBson: useBson
84
85
  });
85
86
 
86
87
  const byteContents = useBson ? sync.bsonLines(syncLines) : sync.ndjson(syncLines);
87
- const stream = Readable.from(sync.transformToBytesTracked(byteContents, tracker), {
88
+ const plainStream = Readable.from(sync.transformToBytesTracked(byteContents, tracker), {
88
89
  objectMode: false,
89
90
  highWaterMark: 16 * 1024
90
91
  });
92
+ const { stream, encodingHeaders } = maybeCompressResponseStream(negotiator, plainStream, tracker);
91
93
 
92
94
  // Best effort guess on why the stream was closed.
93
95
  // We use the `??=` operator everywhere, so that we catch the first relevant
@@ -122,7 +124,8 @@ export const syncStreamed = routeDefinition({
122
124
  return new router.RouterResponse({
123
125
  status: 200,
124
126
  headers: {
125
- 'Content-Type': useBson ? concatenatedBsonContentType : ndJsonContentType
127
+ 'Content-Type': useBson ? concatenatedBsonContentType : ndJsonContentType,
128
+ ...encodingHeaders
126
129
  },
127
130
  data: stream,
128
131
  afterSend: async (details) => {
@@ -39,8 +39,8 @@ export enum SyncRuleState {
39
39
  export const DEFAULT_DOCUMENT_BATCH_LIMIT = 1000;
40
40
  export const DEFAULT_DOCUMENT_CHUNK_LIMIT_BYTES = 1 * 1024 * 1024;
41
41
 
42
- export function mergeToast(record: ToastableSqliteRow, persisted: ToastableSqliteRow): ToastableSqliteRow {
43
- const newRecord: ToastableSqliteRow = {};
42
+ export function mergeToast<V>(record: ToastableSqliteRow<V>, persisted: ToastableSqliteRow<V>): ToastableSqliteRow<V> {
43
+ const newRecord: ToastableSqliteRow<V> = {};
44
44
  for (let key in record) {
45
45
  if (typeof record[key] == 'undefined') {
46
46
  newRecord[key] = persisted[key];
@@ -1,5 +1,11 @@
1
1
  import { ObserverClient } from '@powersync/lib-services-framework';
2
- import { EvaluatedParameters, EvaluatedRow, SqliteRow, ToastableSqliteRow } from '@powersync/service-sync-rules';
2
+ import {
3
+ EvaluatedParameters,
4
+ EvaluatedRow,
5
+ SqliteInputRow,
6
+ SqliteRow,
7
+ ToastableSqliteRow
8
+ } from '@powersync/service-sync-rules';
3
9
  import { BSON } from 'bson';
4
10
  import { ReplicationEventPayload } from './ReplicationEventPayload.js';
5
11
  import { SourceTable, TableSnapshotStatus } from './SourceTable.js';
@@ -132,7 +138,7 @@ export interface SaveInsert {
132
138
  sourceTable: SourceTable;
133
139
  before?: undefined;
134
140
  beforeReplicaId?: undefined;
135
- after: SqliteRow;
141
+ after: SqliteInputRow;
136
142
  afterReplicaId: ReplicaId;
137
143
  }
138
144
 
@@ -143,7 +149,7 @@ export interface SaveUpdate {
143
149
  /**
144
150
  * This is only present when the id has changed, and will only contain replica identity columns.
145
151
  */
146
- before?: SqliteRow;
152
+ before?: SqliteInputRow;
147
153
  beforeReplicaId?: ReplicaId;
148
154
 
149
155
  /**
@@ -158,7 +164,7 @@ export interface SaveUpdate {
158
164
  export interface SaveDelete {
159
165
  tag: SaveOperationTag.DELETE;
160
166
  sourceTable: SourceTable;
161
- before?: SqliteRow;
167
+ before?: SqliteInputRow;
162
168
  beforeReplicaId: ReplicaId;
163
169
  after?: undefined;
164
170
  afterReplicaId?: undefined;
@@ -1,40 +1,21 @@
1
1
  import { OrderedSet } from '@js-sdsl/ordered-set';
2
2
  import { LRUCache } from 'lru-cache/min';
3
3
  import { BucketChecksum } from '../util/protocol-types.js';
4
- import { addBucketChecksums, ChecksumMap, InternalOpId } from '../util/utils.js';
4
+ import { addBucketChecksums, ChecksumMap, InternalOpId, PartialChecksum } from '../util/utils.js';
5
5
 
6
6
  interface ChecksumFetchContext {
7
7
  fetch(bucket: string): Promise<BucketChecksum>;
8
8
  checkpoint: InternalOpId;
9
9
  }
10
10
 
11
- export interface PartialChecksum {
12
- bucket: string;
13
- /**
14
- * 32-bit unsigned hash.
15
- */
16
- partialChecksum: number;
17
-
18
- /**
19
- * Count of operations - informational only.
20
- */
21
- partialCount: number;
22
-
23
- /**
24
- * True if the queried operations contains (starts with) a CLEAR
25
- * operation, indicating that the partial checksum is the full
26
- * checksum, and must not be added to a previously-cached checksum.
27
- */
28
- isFullChecksum: boolean;
29
- }
30
-
31
11
  export interface FetchPartialBucketChecksum {
32
12
  bucket: string;
33
13
  start?: InternalOpId;
34
14
  end: InternalOpId;
35
15
  }
36
16
 
37
- export type PartialChecksumMap = Map<string, PartialChecksum>;
17
+ export type PartialOrFullChecksum = PartialChecksum | BucketChecksum;
18
+ export type PartialChecksumMap = Map<string, PartialOrFullChecksum>;
38
19
 
39
20
  export type FetchChecksums = (batch: FetchPartialBucketChecksum[]) => Promise<PartialChecksumMap>;
40
21
 
@@ -127,6 +108,11 @@ export class ChecksumCache {
127
108
  });
128
109
  }
129
110
 
111
+ clear() {
112
+ this.cache.clear();
113
+ this.bucketCheckpoints.clear();
114
+ }
115
+
130
116
  async getChecksums(checkpoint: InternalOpId, buckets: string[]): Promise<BucketChecksum[]> {
131
117
  const checksums = await this.getChecksumMap(checkpoint, buckets);
132
118
  // Return results in the same order as the request
@@ -4,8 +4,8 @@ import { BucketStorageBatch, SaveOp } from './BucketStorageBatch.js';
4
4
 
5
5
  export type EventData = {
6
6
  op: SaveOp;
7
- before?: sync_rules.SqliteRow;
8
- after?: sync_rules.SqliteRow;
7
+ before?: sync_rules.SqliteInputRow;
8
+ after?: sync_rules.SqliteInputRow;
9
9
  };
10
10
 
11
11
  export type ReplicationEventPayload = {
@@ -62,6 +62,11 @@ export interface SyncRulesBucketStorage
62
62
 
63
63
  compact(options?: CompactOptions): Promise<void>;
64
64
 
65
+ /**
66
+ * Lightweight "compact" process to populate the checksum cache, if any.
67
+ */
68
+ populatePersistentChecksumCache(options?: Pick<CompactOptions, 'signal' | 'maxOpId'>): Promise<void>;
69
+
65
70
  // ## Read operations
66
71
 
67
72
  getCheckpoint(): Promise<ReplicationCheckpoint>;
@@ -108,6 +113,11 @@ export interface SyncRulesBucketStorage
108
113
  * Returns zero checksums for any buckets not found.
109
114
  */
110
115
  getChecksums(checkpoint: util.InternalOpId, buckets: string[]): Promise<util.ChecksumMap>;
116
+
117
+ /**
118
+ * Clear checksum cache. Primarily intended for tests.
119
+ */
120
+ clearChecksumCache(): void;
111
121
  }
112
122
 
113
123
  export interface SyncRulesBucketStorageListener {
@@ -208,6 +218,8 @@ export interface CompactOptions {
208
218
  * Internal/testing use: Cache size for compacting parameters.
209
219
  */
210
220
  compactParameterCacheLimit?: number;
221
+
222
+ signal?: AbortSignal;
211
223
  }
212
224
 
213
225
  export interface ClearStorageOptions {