@powersync/service-core 1.13.4 → 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 (119) hide show
  1. package/CHANGELOG.md +61 -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 +31 -1
  6. package/dist/api/diagnostics.js.map +1 -1
  7. package/dist/auth/KeyStore.d.ts +19 -0
  8. package/dist/auth/KeyStore.js +16 -4
  9. package/dist/auth/KeyStore.js.map +1 -1
  10. package/dist/auth/RemoteJWKSCollector.d.ts +3 -0
  11. package/dist/auth/RemoteJWKSCollector.js +3 -1
  12. package/dist/auth/RemoteJWKSCollector.js.map +1 -1
  13. package/dist/auth/StaticSupabaseKeyCollector.d.ts +2 -1
  14. package/dist/auth/StaticSupabaseKeyCollector.js +1 -1
  15. package/dist/auth/StaticSupabaseKeyCollector.js.map +1 -1
  16. package/dist/auth/utils.d.ts +19 -0
  17. package/dist/auth/utils.js +106 -3
  18. package/dist/auth/utils.js.map +1 -1
  19. package/dist/entry/commands/compact-action.js +10 -1
  20. package/dist/entry/commands/compact-action.js.map +1 -1
  21. package/dist/metrics/open-telemetry/util.d.ts +0 -3
  22. package/dist/metrics/open-telemetry/util.js +19 -12
  23. package/dist/metrics/open-telemetry/util.js.map +1 -1
  24. package/dist/replication/AbstractReplicator.js +2 -2
  25. package/dist/replication/AbstractReplicator.js.map +1 -1
  26. package/dist/routes/compression.d.ts +19 -0
  27. package/dist/routes/compression.js +70 -0
  28. package/dist/routes/compression.js.map +1 -0
  29. package/dist/routes/configure-fastify.d.ts +40 -5
  30. package/dist/routes/configure-fastify.js +2 -1
  31. package/dist/routes/configure-fastify.js.map +1 -1
  32. package/dist/routes/endpoints/socket-route.js +25 -17
  33. package/dist/routes/endpoints/socket-route.js.map +1 -1
  34. package/dist/routes/endpoints/sync-rules.js +1 -27
  35. package/dist/routes/endpoints/sync-rules.js.map +1 -1
  36. package/dist/routes/endpoints/sync-stream.d.ts +80 -10
  37. package/dist/routes/endpoints/sync-stream.js +29 -11
  38. package/dist/routes/endpoints/sync-stream.js.map +1 -1
  39. package/dist/routes/route-register.d.ts +4 -0
  40. package/dist/routes/route-register.js +29 -15
  41. package/dist/routes/route-register.js.map +1 -1
  42. package/dist/storage/BucketStorage.d.ts +1 -1
  43. package/dist/storage/BucketStorage.js.map +1 -1
  44. package/dist/storage/BucketStorageBatch.d.ts +16 -6
  45. package/dist/storage/BucketStorageBatch.js.map +1 -1
  46. package/dist/storage/ChecksumCache.d.ts +4 -19
  47. package/dist/storage/ChecksumCache.js +4 -0
  48. package/dist/storage/ChecksumCache.js.map +1 -1
  49. package/dist/storage/ReplicationEventPayload.d.ts +2 -2
  50. package/dist/storage/SourceEntity.d.ts +5 -4
  51. package/dist/storage/SourceTable.d.ts +22 -20
  52. package/dist/storage/SourceTable.js +34 -30
  53. package/dist/storage/SourceTable.js.map +1 -1
  54. package/dist/storage/SyncRulesBucketStorage.d.ts +19 -4
  55. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  56. package/dist/sync/BucketChecksumState.d.ts +41 -11
  57. package/dist/sync/BucketChecksumState.js +155 -19
  58. package/dist/sync/BucketChecksumState.js.map +1 -1
  59. package/dist/sync/RequestTracker.d.ts +7 -1
  60. package/dist/sync/RequestTracker.js +22 -2
  61. package/dist/sync/RequestTracker.js.map +1 -1
  62. package/dist/sync/sync.d.ts +3 -3
  63. package/dist/sync/sync.js +23 -42
  64. package/dist/sync/sync.js.map +1 -1
  65. package/dist/sync/util.d.ts +3 -1
  66. package/dist/sync/util.js +30 -2
  67. package/dist/sync/util.js.map +1 -1
  68. package/dist/util/config/compound-config-collector.js +23 -0
  69. package/dist/util/config/compound-config-collector.js.map +1 -1
  70. package/dist/util/lsn.d.ts +4 -0
  71. package/dist/util/lsn.js +11 -0
  72. package/dist/util/lsn.js.map +1 -0
  73. package/dist/util/protocol-types.d.ts +153 -9
  74. package/dist/util/protocol-types.js +41 -6
  75. package/dist/util/protocol-types.js.map +1 -1
  76. package/dist/util/util-index.d.ts +1 -0
  77. package/dist/util/util-index.js +1 -0
  78. package/dist/util/util-index.js.map +1 -1
  79. package/dist/util/utils.d.ts +18 -3
  80. package/dist/util/utils.js +33 -9
  81. package/dist/util/utils.js.map +1 -1
  82. package/package.json +16 -14
  83. package/src/api/api-metrics.ts +6 -0
  84. package/src/api/diagnostics.ts +33 -1
  85. package/src/auth/KeyStore.ts +28 -4
  86. package/src/auth/RemoteJWKSCollector.ts +5 -2
  87. package/src/auth/StaticSupabaseKeyCollector.ts +1 -1
  88. package/src/auth/utils.ts +123 -3
  89. package/src/entry/commands/compact-action.ts +9 -1
  90. package/src/metrics/open-telemetry/util.ts +23 -19
  91. package/src/replication/AbstractReplicator.ts +2 -2
  92. package/src/routes/compression.ts +75 -0
  93. package/src/routes/configure-fastify.ts +3 -1
  94. package/src/routes/endpoints/socket-route.ts +25 -16
  95. package/src/routes/endpoints/sync-rules.ts +1 -28
  96. package/src/routes/endpoints/sync-stream.ts +37 -26
  97. package/src/routes/route-register.ts +41 -15
  98. package/src/storage/BucketStorage.ts +2 -2
  99. package/src/storage/BucketStorageBatch.ts +23 -6
  100. package/src/storage/ChecksumCache.ts +8 -22
  101. package/src/storage/ReplicationEventPayload.ts +2 -2
  102. package/src/storage/SourceEntity.ts +5 -5
  103. package/src/storage/SourceTable.ts +48 -34
  104. package/src/storage/SyncRulesBucketStorage.ts +26 -7
  105. package/src/sync/BucketChecksumState.ts +194 -31
  106. package/src/sync/RequestTracker.ts +27 -2
  107. package/src/sync/sync.ts +53 -51
  108. package/src/sync/util.ts +32 -3
  109. package/src/util/config/compound-config-collector.ts +24 -0
  110. package/src/util/lsn.ts +8 -0
  111. package/src/util/protocol-types.ts +138 -10
  112. package/src/util/util-index.ts +1 -0
  113. package/src/util/utils.ts +59 -12
  114. package/test/src/auth.test.ts +323 -1
  115. package/test/src/checksum_cache.test.ts +6 -8
  116. package/test/src/routes/mocks.ts +59 -0
  117. package/test/src/routes/stream.test.ts +84 -0
  118. package/test/src/sync/BucketChecksumState.test.ts +375 -76
  119. package/tsconfig.tsbuildinfo +1 -1
@@ -1,6 +1,16 @@
1
1
  import { DEFAULT_TAG } from '@powersync/service-sync-rules';
2
2
  import * as util from '../util/util-index.js';
3
- import { ColumnDescriptor } from './SourceEntity.js';
3
+ import { ColumnDescriptor, SourceEntityDescriptor } from './SourceEntity.js';
4
+
5
+ export interface SourceTableOptions {
6
+ id: any;
7
+ connectionTag: string;
8
+ objectId: number | string | undefined;
9
+ schema: string;
10
+ name: string;
11
+ replicaIdColumns: ColumnDescriptor[];
12
+ snapshotComplete: boolean;
13
+ }
4
14
 
5
15
  export interface TableSnapshotStatus {
6
16
  totalEstimatedCount: number;
@@ -8,7 +18,7 @@ export interface TableSnapshotStatus {
8
18
  lastKey: Uint8Array | null;
9
19
  }
10
20
 
11
- export class SourceTable {
21
+ export class SourceTable implements SourceEntityDescriptor {
12
22
  static readonly DEFAULT_TAG = DEFAULT_TAG;
13
23
 
14
24
  /**
@@ -45,37 +55,41 @@ export class SourceTable {
45
55
  */
46
56
  public snapshotStatus: TableSnapshotStatus | undefined = undefined;
47
57
 
48
- constructor(
49
- public readonly id: any,
50
- public readonly connectionTag: string,
51
- public readonly objectId: number | string | undefined,
52
- public readonly schema: string,
53
- public readonly table: string,
58
+ public snapshotComplete: boolean;
54
59
 
55
- public readonly replicaIdColumns: ColumnDescriptor[],
56
- public snapshotComplete: boolean
57
- ) {}
60
+ constructor(public readonly options: SourceTableOptions) {
61
+ this.snapshotComplete = options.snapshotComplete;
62
+ }
58
63
 
59
- get hasReplicaIdentity() {
60
- return this.replicaIdColumns.length > 0;
64
+ get id() {
65
+ return this.options.id;
61
66
  }
62
67
 
63
- /**
64
- * Use for postgres only.
65
- *
66
- * Usage: db.query({statement: `SELECT $1::regclass`, params: [{type: 'varchar', value: table.qualifiedName}]})
67
- */
68
- get qualifiedName() {
69
- return this.escapedIdentifier;
68
+ get connectionTag() {
69
+ return this.options.connectionTag;
70
+ }
71
+
72
+ get objectId() {
73
+ return this.options.objectId;
74
+ }
75
+
76
+ get schema() {
77
+ return this.options.schema;
78
+ }
79
+ get name() {
80
+ return this.options.name;
81
+ }
82
+
83
+ get replicaIdColumns() {
84
+ return this.options.replicaIdColumns;
70
85
  }
71
86
 
72
87
  /**
73
- * Use for postgres and logs only.
74
- *
75
- * Usage: db.query(`SELECT * FROM ${table.escapedIdentifier}`)
88
+ * Sanitized name of the entity in the format of "{schema}.{entity name}"
89
+ * Suitable for safe use in Postgres queries.
76
90
  */
77
- get escapedIdentifier() {
78
- return `${util.escapeIdentifier(this.schema)}.${util.escapeIdentifier(this.table)}`;
91
+ get qualifiedName() {
92
+ return `${util.escapeIdentifier(this.schema)}.${util.escapeIdentifier(this.name)}`;
79
93
  }
80
94
 
81
95
  get syncAny() {
@@ -86,15 +100,15 @@ export class SourceTable {
86
100
  * In-memory clone of the table status.
87
101
  */
88
102
  clone() {
89
- const copy = new SourceTable(
90
- this.id,
91
- this.connectionTag,
92
- this.objectId,
93
- this.schema,
94
- this.table,
95
- this.replicaIdColumns,
96
- this.snapshotComplete
97
- );
103
+ const copy = new SourceTable({
104
+ id: this.id,
105
+ connectionTag: this.connectionTag,
106
+ objectId: this.objectId,
107
+ schema: this.schema,
108
+ name: this.name,
109
+ replicaIdColumns: this.replicaIdColumns,
110
+ snapshotComplete: this.snapshotComplete
111
+ });
98
112
  copy.syncData = this.syncData;
99
113
  copy.syncParameters = this.syncParameters;
100
114
  copy.snapshotStatus = this.snapshotStatus;
@@ -50,8 +50,6 @@ export interface SyncRulesBucketStorage
50
50
  */
51
51
  clear(options?: ClearStorageOptions): Promise<void>;
52
52
 
53
- autoActivate(): Promise<void>;
54
-
55
53
  /**
56
54
  * Record a replication error.
57
55
  *
@@ -64,15 +62,15 @@ export interface SyncRulesBucketStorage
64
62
 
65
63
  compact(options?: CompactOptions): Promise<void>;
66
64
 
65
+ /**
66
+ * Lightweight "compact" process to populate the checksum cache, if any.
67
+ */
68
+ populatePersistentChecksumCache(options?: Pick<CompactOptions, 'signal' | 'maxOpId'>): Promise<void>;
69
+
67
70
  // ## Read operations
68
71
 
69
72
  getCheckpoint(): Promise<ReplicationCheckpoint>;
70
73
 
71
- /**
72
- * Used to resolve "dynamic" parameter queries.
73
- */
74
- getParameterSets(checkpoint: util.InternalOpId, lookups: ParameterLookup[]): Promise<SqliteJsonRow[]>;
75
-
76
74
  /**
77
75
  * Given two checkpoints, return the changes in bucket data and parameters that may have occurred
78
76
  * in that period.
@@ -115,6 +113,11 @@ export interface SyncRulesBucketStorage
115
113
  * Returns zero checksums for any buckets not found.
116
114
  */
117
115
  getChecksums(checkpoint: util.InternalOpId, buckets: string[]): Promise<util.ChecksumMap>;
116
+
117
+ /**
118
+ * Clear checksum cache. Primarily intended for tests.
119
+ */
120
+ clearChecksumCache(): void;
118
121
  }
119
122
 
120
123
  export interface SyncRulesBucketStorageListener {
@@ -200,6 +203,8 @@ export interface CompactOptions {
200
203
  */
201
204
  compactBuckets?: string[];
202
205
 
206
+ compactParameterData?: boolean;
207
+
203
208
  /** Minimum of 2 */
204
209
  clearBatchLimit?: number;
205
210
 
@@ -208,6 +213,13 @@ export interface CompactOptions {
208
213
 
209
214
  /** Minimum of 1 */
210
215
  moveBatchQueryLimit?: number;
216
+
217
+ /**
218
+ * Internal/testing use: Cache size for compacting parameters.
219
+ */
220
+ compactParameterCacheLimit?: number;
221
+
222
+ signal?: AbortSignal;
211
223
  }
212
224
 
213
225
  export interface ClearStorageOptions {
@@ -245,6 +257,13 @@ export interface SyncBucketDataChunk {
245
257
  export interface ReplicationCheckpoint {
246
258
  readonly checkpoint: util.InternalOpId;
247
259
  readonly lsn: string | null;
260
+
261
+ /**
262
+ * Used to resolve "dynamic" parameter queries.
263
+ *
264
+ * This gets parameter sets specific to this checkpoint.
265
+ */
266
+ getParameterSets(lookups: ParameterLookup[]): Promise<SqliteJsonRow[]>;
248
267
  }
249
268
 
250
269
  export interface WatchWriteCheckpointOptions {
@@ -1,4 +1,13 @@
1
- import { BucketDescription, RequestParameters, SqlSyncRules } from '@powersync/service-sync-rules';
1
+ import {
2
+ BucketDescription,
3
+ BucketPriority,
4
+ BucketSource,
5
+ RequestedStream,
6
+ RequestJwtPayload,
7
+ RequestParameters,
8
+ ResolvedBucket,
9
+ SqlSyncRules
10
+ } from '@powersync/service-sync-rules';
2
11
 
3
12
  import * as storage from '../storage/storage-index.js';
4
13
  import * as util from '../util/util-index.js';
@@ -11,17 +20,22 @@ import {
11
20
  logger as defaultLogger
12
21
  } from '@powersync/lib-services-framework';
13
22
  import { JSONBig } from '@powersync/service-jsonbig';
14
- import { BucketParameterQuerier } from '@powersync/service-sync-rules/src/BucketParameterQuerier.js';
23
+ import { BucketParameterQuerier, QuerierError } from '@powersync/service-sync-rules/src/BucketParameterQuerier.js';
15
24
  import { SyncContext } from './SyncContext.js';
16
25
  import { getIntersection, hasIntersection } from './util.js';
17
26
 
27
+ export interface VersionedSyncRules {
28
+ syncRules: SqlSyncRules;
29
+ version: number;
30
+ }
31
+
18
32
  export interface BucketChecksumStateOptions {
19
33
  syncContext: SyncContext;
20
34
  bucketStorage: BucketChecksumStateStorage;
21
- syncRules: SqlSyncRules;
22
- syncParams: RequestParameters;
35
+ syncRules: VersionedSyncRules;
36
+ tokenPayload: RequestJwtPayload;
37
+ syncRequest: util.StreamingSyncRequest;
23
38
  logger?: Logger;
24
- initialBucketPositions?: { name: string; after: util.InternalOpId }[];
25
39
  }
26
40
 
27
41
  type BucketSyncState = {
@@ -50,6 +64,17 @@ export class BucketChecksumState {
50
64
  */
51
65
  private lastChecksums: util.ChecksumMap | null = null;
52
66
  private lastWriteCheckpoint: bigint | null = null;
67
+ /**
68
+ * Once we've sent the first full checkpoint line including all {@link util.Checkpoint.streams} that the user is
69
+ * subscribed to, we keep an index of the stream names to their index in that array.
70
+ *
71
+ * This is used to compress the representation of buckets in `checkpoint` and `checkpoint_diff` lines: For buckets
72
+ * that are part of sync rules or default streams, we need to include the name of the defining sync rule or definition
73
+ * yielding that bucket (so that clients can track progress for default streams).
74
+ * But instead of sending the name for each bucket, we use the fact that it's part of the streams array and only send
75
+ * their index, reducing the size of those messages.
76
+ */
77
+ private streamNameToIndex: Map<string, number> | null = null;
53
78
 
54
79
  private readonly parameterState: BucketParameterState;
55
80
 
@@ -69,13 +94,14 @@ export class BucketChecksumState {
69
94
  options.syncContext,
70
95
  options.bucketStorage,
71
96
  options.syncRules,
72
- options.syncParams,
97
+ options.tokenPayload,
98
+ options.syncRequest,
73
99
  this.logger
74
100
  );
75
101
  this.bucketDataPositions = new Map();
76
102
 
77
- for (let { name, after: start } of options.initialBucketPositions ?? []) {
78
- this.bucketDataPositions.set(name, { start_op_id: start });
103
+ for (let { name, after: start } of options.syncRequest.buckets ?? []) {
104
+ this.bucketDataPositions.set(name, { start_op_id: BigInt(start) });
79
105
  }
80
106
  }
81
107
 
@@ -158,6 +184,7 @@ export class BucketChecksumState {
158
184
  // TODO: If updatedBuckets is present, we can use that to more efficiently calculate a diff,
159
185
  // and avoid any unnecessary loops through the entire list of buckets.
160
186
  const diff = util.checksumsDiff(this.lastChecksums, checksumMap);
187
+ const streamNameToIndex = this.streamNameToIndex!;
161
188
 
162
189
  if (
163
190
  this.lastWriteCheckpoint == writeCheckpoint &&
@@ -182,12 +209,12 @@ export class BucketChecksumState {
182
209
 
183
210
  const updatedBucketDescriptions = diff.updatedBuckets.map((e) => ({
184
211
  ...e,
185
- priority: bucketDescriptionMap.get(e.bucket)!.priority
212
+ ...this.parameterState.translateResolvedBucket(bucketDescriptionMap.get(e.bucket)!, streamNameToIndex)
186
213
  }));
187
214
  bucketsToFetch = [...generateBucketsToFetch].map((b) => {
188
215
  return {
189
- bucket: b,
190
- priority: bucketDescriptionMap.get(b)!.priority
216
+ priority: bucketDescriptionMap.get(b)!.priority,
217
+ bucket: b
191
218
  };
192
219
  });
193
220
 
@@ -220,15 +247,37 @@ export class BucketChecksumState {
220
247
  message += `buckets: ${allBuckets.length} ${limitedBuckets(allBuckets, 20)}`;
221
248
  this.logger.info(message, { checkpoint: base.checkpoint, user_id: user_id, buckets: allBuckets.length });
222
249
  };
223
- bucketsToFetch = allBuckets;
250
+ bucketsToFetch = allBuckets.map((b) => ({ bucket: b.bucket, priority: b.priority }));
251
+
252
+ const subscriptions: util.StreamDescription[] = [];
253
+ const streamNameToIndex = new Map<string, number>();
254
+ this.streamNameToIndex = streamNameToIndex;
255
+
256
+ for (const source of this.parameterState.syncRules.syncRules.bucketSources) {
257
+ if (this.parameterState.isSubscribedToStream(source)) {
258
+ streamNameToIndex.set(source.name, subscriptions.length);
259
+
260
+ subscriptions.push({
261
+ name: source.name,
262
+ is_default: source.subscribedToByDefault,
263
+ errors:
264
+ this.parameterState.streamErrors[source.name]?.map((e) => ({
265
+ subscription: e.subscription?.opaque_id ?? 'default',
266
+ message: e.message
267
+ })) ?? []
268
+ });
269
+ }
270
+ }
271
+
224
272
  checkpointLine = {
225
273
  checkpoint: {
226
274
  last_op_id: util.internalToExternalOpId(base.checkpoint),
227
275
  write_checkpoint: writeCheckpoint ? String(writeCheckpoint) : undefined,
228
276
  buckets: [...checksumMap.values()].map((e) => ({
229
277
  ...e,
230
- priority: bucketDescriptionMap.get(e.bucket)!.priority
231
- }))
278
+ ...this.parameterState.translateResolvedBucket(bucketDescriptionMap.get(e.bucket)!, streamNameToIndex)
279
+ })),
280
+ streams: subscriptions
232
281
  }
233
282
  } satisfies util.StreamingSyncCheckpoint;
234
283
  }
@@ -319,7 +368,7 @@ export interface CheckpointUpdate {
319
368
  /**
320
369
  * All buckets forming part of the checkpoint.
321
370
  */
322
- buckets: BucketDescription[];
371
+ buckets: ResolvedBucket[];
323
372
 
324
373
  /**
325
374
  * If present, a set of buckets that have been updated since the last checkpoint.
@@ -332,12 +381,22 @@ export interface CheckpointUpdate {
332
381
  export class BucketParameterState {
333
382
  private readonly context: SyncContext;
334
383
  public readonly bucketStorage: BucketChecksumStateStorage;
335
- public readonly syncRules: SqlSyncRules;
384
+ public readonly syncRules: VersionedSyncRules;
336
385
  public readonly syncParams: RequestParameters;
337
386
  private readonly querier: BucketParameterQuerier;
338
- private readonly staticBuckets: Map<string, BucketDescription>;
387
+ /**
388
+ * Static buckets. This map is guaranteed not to change during a request, since resolving static buckets can only
389
+ * take request parameters into account,
390
+ */
391
+ private readonly staticBuckets: Map<string, ResolvedBucket>;
392
+ private readonly includeDefaultStreams: boolean;
393
+ // Indexed by the client-side id
394
+ private readonly explicitStreamSubscriptions: util.RequestedStreamSubscription[];
395
+ // Indexed by descriptor name.
396
+ readonly streamErrors: Record<string, QuerierError[]>;
397
+ private readonly subscribedStreamNames: Set<string>;
339
398
  private readonly logger: Logger;
340
- private cachedDynamicBuckets: BucketDescription[] | null = null;
399
+ private cachedDynamicBuckets: ResolvedBucket[] | null = null;
341
400
  private cachedDynamicBucketSet: Set<string> | null = null;
342
401
 
343
402
  private readonly lookups: Set<string>;
@@ -345,19 +404,94 @@ export class BucketParameterState {
345
404
  constructor(
346
405
  context: SyncContext,
347
406
  bucketStorage: BucketChecksumStateStorage,
348
- syncRules: SqlSyncRules,
349
- syncParams: RequestParameters,
407
+ syncRules: VersionedSyncRules,
408
+ tokenPayload: RequestJwtPayload,
409
+ request: util.StreamingSyncRequest,
350
410
  logger: Logger
351
411
  ) {
352
412
  this.context = context;
353
413
  this.bucketStorage = bucketStorage;
354
414
  this.syncRules = syncRules;
355
- this.syncParams = syncParams;
415
+ this.syncParams = new RequestParameters(tokenPayload, request.parameters ?? {});
356
416
  this.logger = logger;
357
417
 
358
- this.querier = syncRules.getBucketParameterQuerier(this.syncParams);
359
- this.staticBuckets = new Map<string, BucketDescription>(this.querier.staticBuckets.map((b) => [b.bucket, b]));
418
+ const streamsByName: Record<string, RequestedStream[]> = {};
419
+ const subscriptions = request.streams;
420
+ const explicitStreamSubscriptions: util.RequestedStreamSubscription[] = subscriptions?.subscriptions ?? [];
421
+ if (subscriptions) {
422
+ for (let i = 0; i < explicitStreamSubscriptions.length; i++) {
423
+ const subscription = explicitStreamSubscriptions[i];
424
+
425
+ const syncRuleStream: RequestedStream = {
426
+ parameters: subscription.parameters ?? {},
427
+ opaque_id: i
428
+ };
429
+ if (Object.hasOwn(streamsByName, subscription.stream)) {
430
+ streamsByName[subscription.stream].push(syncRuleStream);
431
+ } else {
432
+ streamsByName[subscription.stream] = [syncRuleStream];
433
+ }
434
+ }
435
+ }
436
+ this.includeDefaultStreams = subscriptions?.include_defaults ?? true;
437
+ this.explicitStreamSubscriptions = explicitStreamSubscriptions;
438
+
439
+ const { querier, errors } = syncRules.syncRules.getBucketParameterQuerier({
440
+ globalParameters: this.syncParams,
441
+ hasDefaultStreams: this.includeDefaultStreams,
442
+ streams: streamsByName,
443
+ bucketIdTransformer: SqlSyncRules.versionedBucketIdTransformer(`${syncRules.version}`)
444
+ });
445
+ this.querier = querier;
446
+ this.streamErrors = Object.groupBy(errors, (e) => e.descriptor) as Record<string, QuerierError[]>;
447
+
448
+ this.staticBuckets = new Map<string, ResolvedBucket>(
449
+ mergeBuckets(this.querier.staticBuckets).map((b) => [b.bucket, b])
450
+ );
360
451
  this.lookups = new Set<string>(this.querier.parameterQueryLookups.map((l) => JSONBig.stringify(l.values)));
452
+ this.subscribedStreamNames = new Set(Object.keys(streamsByName));
453
+ }
454
+
455
+ /**
456
+ * Translates an internal sync-rules {@link ResolvedBucket} instance to the public
457
+ * {@link util.ClientBucketDescription}.
458
+ *
459
+ * @param lookupIndex A map from stream names to their index in {@link util.Checkpoint.streams}. These are used to
460
+ * reference default buckets by their stream index instead of duplicating the name on wire.
461
+ */
462
+ translateResolvedBucket(description: ResolvedBucket, lookupIndex: Map<string, number>): util.ClientBucketDescription {
463
+ // If the client is overriding the priority of any stream that yields this bucket, sync the bucket with that
464
+ // priority.
465
+ let priorityOverride: BucketPriority | null = null;
466
+ for (const reason of description.inclusion_reasons) {
467
+ if (reason != 'default') {
468
+ const requestedPriority = this.explicitStreamSubscriptions[reason.subscription]?.override_priority;
469
+ if (requestedPriority != null) {
470
+ if (priorityOverride == null) {
471
+ priorityOverride = requestedPriority as BucketPriority;
472
+ } else {
473
+ priorityOverride = Math.min(requestedPriority, priorityOverride) as BucketPriority;
474
+ }
475
+ }
476
+ }
477
+ }
478
+
479
+ return {
480
+ bucket: description.bucket,
481
+ priority: priorityOverride ?? description.priority,
482
+ subscriptions: description.inclusion_reasons.map((reason) => {
483
+ if (reason == 'default') {
484
+ const stream = description.definition;
485
+ return { default: lookupIndex.get(stream)! };
486
+ } else {
487
+ return { sub: reason.subscription };
488
+ }
489
+ })
490
+ };
491
+ }
492
+
493
+ isSubscribedToStream(desc: BucketSource): boolean {
494
+ return (desc.subscribedToByDefault && this.includeDefaultStreams) || this.subscribedStreamNames.has(desc.name);
361
495
  }
362
496
 
363
497
  async getCheckpointUpdate(checkpoint: storage.StorageCheckpointUpdate): Promise<CheckpointUpdate> {
@@ -391,19 +525,19 @@ export class BucketParameterState {
391
525
  * For static buckets, we can keep track of which buckets have been updated.
392
526
  */
393
527
  private async getCheckpointUpdateStatic(checkpoint: storage.StorageCheckpointUpdate): Promise<CheckpointUpdate> {
394
- const querier = this.querier;
528
+ const staticBuckets = [...this.staticBuckets.values()];
395
529
  const update = checkpoint.update;
396
530
 
397
531
  if (update.invalidateDataBuckets) {
398
532
  return {
399
- buckets: querier.staticBuckets,
533
+ buckets: staticBuckets,
400
534
  updatedBuckets: INVALIDATE_ALL_BUCKETS
401
535
  };
402
536
  }
403
537
 
404
538
  const updatedBuckets = new Set<string>(getIntersection(this.staticBuckets, update.updatedDataBuckets));
405
539
  return {
406
- buckets: querier.staticBuckets,
540
+ buckets: staticBuckets,
407
541
  updatedBuckets
408
542
  };
409
543
  }
@@ -414,7 +548,7 @@ export class BucketParameterState {
414
548
  private async getCheckpointUpdateDynamic(checkpoint: storage.StorageCheckpointUpdate): Promise<CheckpointUpdate> {
415
549
  const querier = this.querier;
416
550
  const storage = this.bucketStorage;
417
- const staticBuckets = querier.staticBuckets;
551
+ const staticBuckets = this.staticBuckets.values();
418
552
  const update = checkpoint.update;
419
553
 
420
554
  let hasParameterChange = false;
@@ -436,11 +570,11 @@ export class BucketParameterState {
436
570
  }
437
571
  }
438
572
 
439
- let dynamicBuckets: BucketDescription[];
573
+ let dynamicBuckets: ResolvedBucket[];
440
574
  if (hasParameterChange || this.cachedDynamicBuckets == null || this.cachedDynamicBucketSet == null) {
441
575
  dynamicBuckets = await querier.queryDynamicBucketDescriptions({
442
576
  getParameterSets(lookups) {
443
- return storage.getParameterSets(checkpoint.base.checkpoint, lookups);
577
+ return checkpoint.base.getParameterSets(lookups);
444
578
  }
445
579
  });
446
580
  this.cachedDynamicBuckets = dynamicBuckets;
@@ -458,7 +592,7 @@ export class BucketParameterState {
458
592
  }
459
593
  }
460
594
  }
461
- const allBuckets = [...staticBuckets, ...dynamicBuckets];
595
+ const allBuckets = [...staticBuckets, ...mergeBuckets(dynamicBuckets)];
462
596
 
463
597
  if (invalidateDataBuckets) {
464
598
  return {
@@ -501,7 +635,7 @@ export interface CheckpointLine {
501
635
  }
502
636
 
503
637
  // Use a more specific type to simplify testing
504
- export type BucketChecksumStateStorage = Pick<storage.SyncRulesBucketStorage, 'getChecksums' | 'getParameterSets'>;
638
+ export type BucketChecksumStateStorage = Pick<storage.SyncRulesBucketStorage, 'getChecksums'>;
505
639
 
506
640
  function limitedBuckets(buckets: string[] | { bucket: string }[], limit: number) {
507
641
  buckets = buckets.map((b) => {
@@ -517,3 +651,32 @@ function limitedBuckets(buckets: string[] | { bucket: string }[], limit: number)
517
651
  const limited = buckets.slice(0, limit);
518
652
  return `${JSON.stringify(limited)}...`;
519
653
  }
654
+
655
+ /**
656
+ * Resolves duplicate buckets in the given array, merging the inclusion reasons for duplicate.
657
+ *
658
+ * It's possible for duplicates to occur when a stream has multiple subscriptions, consider e.g.
659
+ *
660
+ * ```
661
+ * sync_streams:
662
+ * assets_by_category:
663
+ * query: select * from assets where category in (request.parameters() -> 'categories')
664
+ * ```
665
+ *
666
+ * Here, a client might subscribe once with `{"categories": [1]}` and once with `{"categories": [1, 2]}`. Since each
667
+ * subscription is evaluated independently, this would lead to three buckets, with a duplicate `assets_by_category[1]`
668
+ * bucket.
669
+ */
670
+ function mergeBuckets(buckets: ResolvedBucket[]): ResolvedBucket[] {
671
+ const byBucketId: Record<string, ResolvedBucket> = {};
672
+
673
+ for (const bucket of buckets) {
674
+ if (Object.hasOwn(byBucketId, bucket.bucket)) {
675
+ byBucketId[bucket.bucket].inclusion_reasons.push(...bucket.inclusion_reasons);
676
+ } else {
677
+ byBucketId[bucket.bucket] = structuredClone(bucket);
678
+ }
679
+ }
680
+
681
+ return Object.values(byBucketId);
682
+ }
@@ -2,6 +2,7 @@ import { MetricsEngine } from '../metrics/MetricsEngine.js';
2
2
 
3
3
  import { APIMetric } from '@powersync/service-types';
4
4
  import { SyncBucketData } from '../util/protocol-types.js';
5
+ import { ServiceAssertionError } from '@powersync/lib-services-framework';
5
6
 
6
7
  /**
7
8
  * Record sync stats per request stream.
@@ -9,9 +10,12 @@ import { SyncBucketData } from '../util/protocol-types.js';
9
10
  export class RequestTracker {
10
11
  operationsSynced = 0;
11
12
  dataSyncedBytes = 0;
13
+ dataSentBytes = 0;
12
14
  operationCounts: OperationCounts = { put: 0, remove: 0, move: 0, clear: 0 };
13
15
  largeBuckets: Record<string, number> = {};
14
16
 
17
+ private encoding: string | undefined = undefined;
18
+
15
19
  constructor(private metrics: MetricsEngine) {
16
20
  this.metrics = metrics;
17
21
  }
@@ -29,18 +33,39 @@ export class RequestTracker {
29
33
  this.metrics.getCounter(APIMetric.OPERATIONS_SYNCED).add(operations.total);
30
34
  }
31
35
 
32
- addDataSynced(bytes: number) {
36
+ setCompressed(encoding: string) {
37
+ this.encoding = encoding;
38
+ }
39
+
40
+ addPlaintextDataSynced(bytes: number) {
33
41
  this.dataSyncedBytes += bytes;
34
42
 
35
43
  this.metrics.getCounter(APIMetric.DATA_SYNCED_BYTES).add(bytes);
44
+
45
+ if (this.encoding == null) {
46
+ // This avoids having to create a separate stream just to track this
47
+ this.dataSentBytes += bytes;
48
+
49
+ this.metrics.getCounter(APIMetric.DATA_SENT_BYTES).add(bytes);
50
+ }
51
+ }
52
+
53
+ addCompressedDataSent(bytes: number) {
54
+ if (this.encoding == null) {
55
+ throw new ServiceAssertionError('No compression encoding set');
56
+ }
57
+ this.dataSentBytes += bytes;
58
+ this.metrics.getCounter(APIMetric.DATA_SENT_BYTES).add(bytes);
36
59
  }
37
60
 
38
61
  getLogMeta() {
39
62
  return {
40
63
  operations_synced: this.operationsSynced,
41
64
  data_synced_bytes: this.dataSyncedBytes,
65
+ data_sent_bytes: this.dataSentBytes,
42
66
  operation_counts: this.operationCounts,
43
- large_buckets: this.largeBuckets
67
+ large_buckets: this.largeBuckets,
68
+ encoding: this.encoding
44
69
  };
45
70
  }
46
71
  }