@powersync/service-core 1.11.3 → 1.12.1

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 (114) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/api/RouteAPI.d.ts +0 -4
  3. package/dist/auth/CachedKeyCollector.js +2 -7
  4. package/dist/auth/CachedKeyCollector.js.map +1 -1
  5. package/dist/auth/CompoundKeyCollector.js.map +1 -1
  6. package/dist/auth/KeyCollector.d.ts +2 -2
  7. package/dist/auth/KeyStore.js +32 -14
  8. package/dist/auth/KeyStore.js.map +1 -1
  9. package/dist/auth/RemoteJWKSCollector.d.ts +1 -0
  10. package/dist/auth/RemoteJWKSCollector.js +39 -16
  11. package/dist/auth/RemoteJWKSCollector.js.map +1 -1
  12. package/dist/auth/auth-index.d.ts +1 -0
  13. package/dist/auth/auth-index.js +1 -0
  14. package/dist/auth/auth-index.js.map +1 -1
  15. package/dist/auth/utils.d.ts +6 -0
  16. package/dist/auth/utils.js +97 -0
  17. package/dist/auth/utils.js.map +1 -0
  18. package/dist/entry/commands/compact-action.js +4 -1
  19. package/dist/entry/commands/compact-action.js.map +1 -1
  20. package/dist/entry/commands/migrate-action.js +4 -1
  21. package/dist/entry/commands/migrate-action.js.map +1 -1
  22. package/dist/entry/commands/test-connection-action.js +4 -1
  23. package/dist/entry/commands/test-connection-action.js.map +1 -1
  24. package/dist/routes/RouterEngine.d.ts +2 -0
  25. package/dist/routes/RouterEngine.js +15 -10
  26. package/dist/routes/RouterEngine.js.map +1 -1
  27. package/dist/routes/auth.d.ts +5 -16
  28. package/dist/routes/auth.js +6 -4
  29. package/dist/routes/auth.js.map +1 -1
  30. package/dist/routes/configure-fastify.d.ts +3 -21
  31. package/dist/routes/configure-fastify.js +3 -6
  32. package/dist/routes/configure-fastify.js.map +1 -1
  33. package/dist/routes/configure-rsocket.js +28 -14
  34. package/dist/routes/configure-rsocket.js.map +1 -1
  35. package/dist/routes/endpoints/admin.js.map +1 -1
  36. package/dist/routes/endpoints/checkpointing.d.ts +4 -28
  37. package/dist/routes/endpoints/checkpointing.js.map +1 -1
  38. package/dist/routes/endpoints/route-endpoints-index.d.ts +1 -0
  39. package/dist/routes/endpoints/route-endpoints-index.js +1 -0
  40. package/dist/routes/endpoints/route-endpoints-index.js.map +1 -1
  41. package/dist/routes/endpoints/socket-route.js +22 -8
  42. package/dist/routes/endpoints/socket-route.js.map +1 -1
  43. package/dist/routes/endpoints/sync-rules.js +6 -6
  44. package/dist/routes/endpoints/sync-rules.js.map +1 -1
  45. package/dist/routes/endpoints/sync-stream.d.ts +2 -14
  46. package/dist/routes/endpoints/sync-stream.js +28 -9
  47. package/dist/routes/endpoints/sync-stream.js.map +1 -1
  48. package/dist/routes/route-register.js +10 -6
  49. package/dist/routes/route-register.js.map +1 -1
  50. package/dist/routes/router.d.ts +8 -7
  51. package/dist/routes/router.js.map +1 -1
  52. package/dist/runner/teardown.js +4 -1
  53. package/dist/runner/teardown.js.map +1 -1
  54. package/dist/sync/BucketChecksumState.d.ts +40 -18
  55. package/dist/sync/BucketChecksumState.js +122 -74
  56. package/dist/sync/BucketChecksumState.js.map +1 -1
  57. package/dist/sync/RequestTracker.d.ts +22 -1
  58. package/dist/sync/RequestTracker.js +51 -2
  59. package/dist/sync/RequestTracker.js.map +1 -1
  60. package/dist/sync/sync.d.ts +3 -5
  61. package/dist/sync/sync.js +49 -34
  62. package/dist/sync/sync.js.map +1 -1
  63. package/dist/system/ServiceContext.d.ts +19 -4
  64. package/dist/system/ServiceContext.js +20 -8
  65. package/dist/system/ServiceContext.js.map +1 -1
  66. package/dist/util/config/collectors/config-collector.js +4 -33
  67. package/dist/util/config/collectors/config-collector.js.map +1 -1
  68. package/dist/util/config/collectors/impl/yaml-env.d.ts +7 -0
  69. package/dist/util/config/collectors/impl/yaml-env.js +59 -0
  70. package/dist/util/config/collectors/impl/yaml-env.js.map +1 -0
  71. package/dist/util/config/compound-config-collector.js +18 -1
  72. package/dist/util/config/compound-config-collector.js.map +1 -1
  73. package/dist/util/config/types.d.ts +11 -0
  74. package/dist/util/protocol-types.d.ts +9 -9
  75. package/dist/util/protocol-types.js.map +1 -1
  76. package/dist/util/utils.d.ts +1 -1
  77. package/package.json +6 -7
  78. package/src/api/RouteAPI.ts +0 -5
  79. package/src/auth/CachedKeyCollector.ts +4 -6
  80. package/src/auth/CompoundKeyCollector.ts +2 -1
  81. package/src/auth/KeyCollector.ts +2 -2
  82. package/src/auth/KeyStore.ts +45 -20
  83. package/src/auth/RemoteJWKSCollector.ts +39 -16
  84. package/src/auth/auth-index.ts +1 -0
  85. package/src/auth/utils.ts +102 -0
  86. package/src/entry/commands/compact-action.ts +4 -1
  87. package/src/entry/commands/migrate-action.ts +4 -1
  88. package/src/entry/commands/test-connection-action.ts +4 -1
  89. package/src/routes/RouterEngine.ts +21 -11
  90. package/src/routes/auth.ts +7 -6
  91. package/src/routes/configure-fastify.ts +6 -8
  92. package/src/routes/configure-rsocket.ts +33 -18
  93. package/src/routes/endpoints/admin.ts +5 -5
  94. package/src/routes/endpoints/checkpointing.ts +2 -2
  95. package/src/routes/endpoints/route-endpoints-index.ts +1 -0
  96. package/src/routes/endpoints/socket-route.ts +27 -11
  97. package/src/routes/endpoints/sync-rules.ts +10 -10
  98. package/src/routes/endpoints/sync-stream.ts +34 -11
  99. package/src/routes/route-register.ts +10 -7
  100. package/src/routes/router.ts +11 -4
  101. package/src/runner/teardown.ts +5 -1
  102. package/src/sync/BucketChecksumState.ts +162 -77
  103. package/src/sync/RequestTracker.ts +70 -3
  104. package/src/sync/sync.ts +72 -49
  105. package/src/system/ServiceContext.ts +31 -12
  106. package/src/util/config/collectors/config-collector.ts +4 -40
  107. package/src/util/config/collectors/impl/yaml-env.ts +67 -0
  108. package/src/util/config/compound-config-collector.ts +22 -5
  109. package/src/util/config/types.ts +13 -0
  110. package/src/util/protocol-types.ts +15 -10
  111. package/test/src/auth.test.ts +29 -11
  112. package/test/src/config.test.ts +72 -0
  113. package/test/src/sync/BucketChecksumState.test.ts +32 -18
  114. package/tsconfig.tsbuildinfo +1 -1
@@ -3,10 +3,15 @@ import { BucketDescription, RequestParameters, SqlSyncRules } from '@powersync/s
3
3
  import * as storage from '../storage/storage-index.js';
4
4
  import * as util from '../util/util-index.js';
5
5
 
6
- import { ErrorCode, logger, ServiceAssertionError, ServiceError } from '@powersync/lib-services-framework';
6
+ import {
7
+ ErrorCode,
8
+ Logger,
9
+ ServiceAssertionError,
10
+ ServiceError,
11
+ logger as defaultLogger
12
+ } from '@powersync/lib-services-framework';
7
13
  import { JSONBig } from '@powersync/service-jsonbig';
8
14
  import { BucketParameterQuerier } from '@powersync/service-sync-rules/src/BucketParameterQuerier.js';
9
- import { BucketSyncState } from './sync.js';
10
15
  import { SyncContext } from './SyncContext.js';
11
16
  import { getIntersection, hasIntersection } from './util.js';
12
17
 
@@ -15,9 +20,14 @@ export interface BucketChecksumStateOptions {
15
20
  bucketStorage: BucketChecksumStateStorage;
16
21
  syncRules: SqlSyncRules;
17
22
  syncParams: RequestParameters;
23
+ logger?: Logger;
18
24
  initialBucketPositions?: { name: string; after: util.InternalOpId }[];
19
25
  }
20
26
 
27
+ type BucketSyncState = {
28
+ start_op_id: util.InternalOpId;
29
+ };
30
+
21
31
  /**
22
32
  * Represents the state of the checksums and data for a specific connection.
23
33
  *
@@ -30,8 +40,10 @@ export class BucketChecksumState {
30
40
  /**
31
41
  * Bucket state of bucket id -> op_id.
32
42
  * This starts with the state from the client. May contain buckets that the user do not have access to (anymore).
43
+ *
44
+ * This is always updated in-place.
33
45
  */
34
- public bucketDataPositions = new Map<string, BucketSyncState>();
46
+ public readonly bucketDataPositions = new Map<string, BucketSyncState>();
35
47
 
36
48
  /**
37
49
  * Last checksums sent to the client. We keep this to calculate checkpoint diffs.
@@ -47,14 +59,18 @@ export class BucketChecksumState {
47
59
  */
48
60
  private pendingBucketDownloads = new Set<string>();
49
61
 
62
+ private readonly logger: Logger;
63
+
50
64
  constructor(options: BucketChecksumStateOptions) {
51
65
  this.context = options.syncContext;
52
66
  this.bucketStorage = options.bucketStorage;
67
+ this.logger = options.logger ?? defaultLogger;
53
68
  this.parameterState = new BucketParameterState(
54
69
  options.syncContext,
55
70
  options.bucketStorage,
56
71
  options.syncRules,
57
- options.syncParams
72
+ options.syncParams,
73
+ this.logger
58
74
  );
59
75
  this.bucketDataPositions = new Map();
60
76
 
@@ -63,30 +79,33 @@ export class BucketChecksumState {
63
79
  }
64
80
  }
65
81
 
82
+ /**
83
+ * Build a new checkpoint line for an underlying storage checkpoint update if any buckets have changed.
84
+ *
85
+ * This call is idempotent - no internal state is updated directly when this is called.
86
+ *
87
+ * When the checkpoint line is sent to the client, call `CheckpointLine.advance()` to update the internal state.
88
+ * A line may be safely discarded (not sent to the client) if `advance()` is not called.
89
+ *
90
+ * @param next The updated checkpoint
91
+ * @returns A {@link CheckpointLine} if any of the buckets watched by this connected was updated, or otherwise `null`.
92
+ */
66
93
  async buildNextCheckpointLine(next: storage.StorageCheckpointUpdate): Promise<CheckpointLine | null> {
67
94
  const { writeCheckpoint, base } = next;
68
- const user_id = this.parameterState.syncParams.user_id;
95
+ const user_id = this.parameterState.syncParams.userId;
69
96
 
70
97
  const storage = this.bucketStorage;
71
98
 
72
99
  const update = await this.parameterState.getCheckpointUpdate(next);
73
- if (update == null && this.lastWriteCheckpoint == writeCheckpoint) {
74
- return null;
75
- }
76
100
  const { buckets: allBuckets, updatedBuckets } = update;
77
101
 
78
- let dataBucketsNew = new Map<string, BucketSyncState>();
79
- for (let bucket of allBuckets) {
80
- dataBucketsNew.set(bucket.bucket, {
81
- description: bucket,
82
- start_op_id: this.bucketDataPositions.get(bucket.bucket)?.start_op_id ?? 0n
83
- });
84
- }
85
- this.bucketDataPositions = dataBucketsNew;
86
- if (dataBucketsNew.size > this.context.maxBuckets) {
102
+ /** Set of all buckets in this checkpoint. */
103
+ const bucketDescriptionMap = new Map(allBuckets.map((b) => [b.bucket, b]));
104
+
105
+ if (bucketDescriptionMap.size > this.context.maxBuckets) {
87
106
  throw new ServiceError(
88
107
  ErrorCode.PSYNC_S2305,
89
- `Too many buckets: ${dataBucketsNew.size} (limit of ${this.context.maxBuckets})`
108
+ `Too many buckets: ${bucketDescriptionMap.size} (limit of ${this.context.maxBuckets})`
90
109
  );
91
110
  }
92
111
 
@@ -100,7 +119,7 @@ export class BucketChecksumState {
100
119
  let checksumLookups: string[] = [];
101
120
 
102
121
  let newChecksums = new Map<string, util.BucketChecksum>();
103
- for (let bucket of dataBucketsNew.keys()) {
122
+ for (let bucket of bucketDescriptionMap.keys()) {
104
123
  if (!updatedBuckets.has(bucket)) {
105
124
  const existing = this.lastChecksums.get(bucket);
106
125
  if (existing == null) {
@@ -123,7 +142,7 @@ export class BucketChecksumState {
123
142
  checksumMap = newChecksums;
124
143
  } else {
125
144
  // Re-check all buckets
126
- const bucketList = [...dataBucketsNew.keys()];
145
+ const bucketList = [...bucketDescriptionMap.keys()];
127
146
  checksumMap = await storage.getChecksums(base.checkpoint, bucketList);
128
147
  }
129
148
 
@@ -132,6 +151,9 @@ export class BucketChecksumState {
132
151
 
133
152
  let checkpointLine: util.StreamingSyncCheckpointDiff | util.StreamingSyncCheckpoint;
134
153
 
154
+ // Log function that is deferred until the checkpoint line is sent to the client.
155
+ let deferredLog: () => void;
156
+
135
157
  if (this.lastChecksums) {
136
158
  // TODO: If updatedBuckets is present, we can use that to more efficiently calculate a diff,
137
159
  // and avoid any unnecessary loops through the entire list of buckets.
@@ -160,27 +182,29 @@ export class BucketChecksumState {
160
182
 
161
183
  const updatedBucketDescriptions = diff.updatedBuckets.map((e) => ({
162
184
  ...e,
163
- priority: this.bucketDataPositions.get(e.bucket)!.description!.priority
185
+ priority: bucketDescriptionMap.get(e.bucket)!.priority
164
186
  }));
165
187
  bucketsToFetch = [...generateBucketsToFetch].map((b) => {
166
188
  return {
167
189
  bucket: b,
168
- priority: this.bucketDataPositions.get(b)!.description!.priority
190
+ priority: bucketDescriptionMap.get(b)!.priority
169
191
  };
170
192
  });
171
193
 
172
- let message = `Updated checkpoint: ${base.checkpoint} | `;
173
- message += `write: ${writeCheckpoint} | `;
174
- message += `buckets: ${allBuckets.length} | `;
175
- message += `updated: ${limitedBuckets(diff.updatedBuckets, 20)} | `;
176
- message += `removed: ${limitedBuckets(diff.removedBuckets, 20)}`;
177
- logger.info(message, {
178
- checkpoint: base.checkpoint,
179
- user_id: user_id,
180
- buckets: allBuckets.length,
181
- updated: diff.updatedBuckets.length,
182
- removed: diff.removedBuckets.length
183
- });
194
+ deferredLog = () => {
195
+ let message = `Updated checkpoint: ${base.checkpoint} | `;
196
+ message += `write: ${writeCheckpoint} | `;
197
+ message += `buckets: ${allBuckets.length} | `;
198
+ message += `updated: ${limitedBuckets(diff.updatedBuckets, 20)} | `;
199
+ message += `removed: ${limitedBuckets(diff.removedBuckets, 20)}`;
200
+ this.logger.info(message, {
201
+ checkpoint: base.checkpoint,
202
+ user_id: user_id,
203
+ buckets: allBuckets.length,
204
+ updated: diff.updatedBuckets.length,
205
+ removed: diff.removedBuckets.length
206
+ });
207
+ };
184
208
 
185
209
  checkpointLine = {
186
210
  checkpoint_diff: {
@@ -191,9 +215,11 @@ export class BucketChecksumState {
191
215
  }
192
216
  } satisfies util.StreamingSyncCheckpointDiff;
193
217
  } else {
194
- let message = `New checkpoint: ${base.checkpoint} | write: ${writeCheckpoint} | `;
195
- message += `buckets: ${allBuckets.length} ${limitedBuckets(allBuckets, 20)}`;
196
- logger.info(message, { checkpoint: base.checkpoint, user_id: user_id, buckets: allBuckets.length });
218
+ deferredLog = () => {
219
+ let message = `New checkpoint: ${base.checkpoint} | write: ${writeCheckpoint} | `;
220
+ message += `buckets: ${allBuckets.length} ${limitedBuckets(allBuckets, 20)}`;
221
+ this.logger.info(message, { checkpoint: base.checkpoint, user_id: user_id, buckets: allBuckets.length });
222
+ };
197
223
  bucketsToFetch = allBuckets;
198
224
  checkpointLine = {
199
225
  checkpoint: {
@@ -201,53 +227,89 @@ export class BucketChecksumState {
201
227
  write_checkpoint: writeCheckpoint ? String(writeCheckpoint) : undefined,
202
228
  buckets: [...checksumMap.values()].map((e) => ({
203
229
  ...e,
204
- priority: this.bucketDataPositions.get(e.bucket)!.description!.priority
230
+ priority: bucketDescriptionMap.get(e.bucket)!.priority
205
231
  }))
206
232
  }
207
233
  } satisfies util.StreamingSyncCheckpoint;
208
234
  }
209
235
 
210
- this.lastChecksums = checksumMap;
211
- this.lastWriteCheckpoint = writeCheckpoint;
212
- this.pendingBucketDownloads = new Set(bucketsToFetch.map((b) => b.bucket));
236
+ const pendingBucketDownloads = new Set(bucketsToFetch.map((b) => b.bucket));
237
+
238
+ let hasAdvanced = false;
213
239
 
214
240
  return {
215
241
  checkpointLine,
216
- bucketsToFetch
217
- };
218
- }
242
+ bucketsToFetch,
243
+ advance: () => {
244
+ hasAdvanced = true;
245
+ // bucketDataPositions must be updated in-place - it represents the current state of
246
+ // the connection, not of the checkpoint line.
247
+ // The following could happen:
248
+ // 1. A = buildCheckpointLine()
249
+ // 2. A.advance()
250
+ // 3. B = buildCheckpointLine()
251
+ // 4. A.updateBucketPosition()
252
+ // 5. B.advance()
253
+ // In that case, it is important that the updated bucket position for A takes effect
254
+ // for checkpoint B.
255
+ let bucketsToRemove: string[] = [];
256
+ for (let bucket of this.bucketDataPositions.keys()) {
257
+ if (!bucketDescriptionMap.has(bucket)) {
258
+ bucketsToRemove.push(bucket);
259
+ }
260
+ }
261
+ for (let bucket of bucketsToRemove) {
262
+ this.bucketDataPositions.delete(bucket);
263
+ }
264
+ for (let bucket of allBuckets) {
265
+ if (!this.bucketDataPositions.has(bucket.bucket)) {
266
+ // Bucket the client hasn't seen before - initialize with 0.
267
+ this.bucketDataPositions.set(bucket.bucket, { start_op_id: 0n });
268
+ }
269
+ // If the bucket position is already present, we keep the current position.
270
+ }
219
271
 
220
- /**
221
- * Get bucket positions to sync, given the list of buckets.
222
- *
223
- * @param bucketsToFetch List of buckets to fetch, typically from buildNextCheckpointLine, or a subset of that
224
- * @returns
225
- */
226
- getFilteredBucketPositions(bucketsToFetch: BucketDescription[]): Map<string, util.InternalOpId> {
227
- const filtered = new Map<string, util.InternalOpId>();
228
- for (let bucket of bucketsToFetch) {
229
- const state = this.bucketDataPositions.get(bucket.bucket);
230
- if (state) {
231
- filtered.set(bucket.bucket, state.start_op_id);
232
- }
233
- }
234
- return filtered;
235
- }
272
+ this.lastChecksums = checksumMap;
273
+ this.lastWriteCheckpoint = writeCheckpoint;
274
+ this.pendingBucketDownloads = pendingBucketDownloads;
275
+ deferredLog();
276
+ },
236
277
 
237
- /**
238
- * Update the position of bucket data the client has.
239
- *
240
- * @param bucket the bucket name
241
- * @param nextAfter sync operations >= this value in the next batch
242
- */
243
- updateBucketPosition(options: { bucket: string; nextAfter: util.InternalOpId; hasMore: boolean }) {
244
- const state = this.bucketDataPositions.get(options.bucket);
245
- if (state) {
246
- state.start_op_id = options.nextAfter;
247
- }
248
- if (!options.hasMore) {
249
- this.pendingBucketDownloads.delete(options.bucket);
250
- }
278
+ getFilteredBucketPositions: (buckets?: BucketDescription[]): Map<string, util.InternalOpId> => {
279
+ if (!hasAdvanced) {
280
+ throw new ServiceAssertionError('Call line.advance() before getFilteredBucketPositions()');
281
+ }
282
+ buckets ??= bucketsToFetch;
283
+ const filtered = new Map<string, util.InternalOpId>();
284
+
285
+ for (let bucket of buckets) {
286
+ const state = this.bucketDataPositions.get(bucket.bucket);
287
+ if (state) {
288
+ filtered.set(bucket.bucket, state.start_op_id);
289
+ }
290
+ }
291
+ return filtered;
292
+ },
293
+
294
+ updateBucketPosition: (options: { bucket: string; nextAfter: util.InternalOpId; hasMore: boolean }) => {
295
+ if (!hasAdvanced) {
296
+ throw new ServiceAssertionError('Call line.advance() before updateBucketPosition()');
297
+ }
298
+ const state = this.bucketDataPositions.get(options.bucket);
299
+ if (state) {
300
+ state.start_op_id = options.nextAfter;
301
+ } else {
302
+ // If we hit this, another checkpoint has removed the bucket in the meantime, meaning
303
+ // line.advance() has been called on it. In that case we don't need the bucket state anymore.
304
+ // It is generally not expected to happen, but we still cover the case.
305
+ }
306
+ if (!options.hasMore) {
307
+ // This specifically updates the per-checkpoint line. Completing a download for one line,
308
+ // does not remove it from the next line, since it could have new updates there.
309
+ pendingBucketDownloads.delete(options.bucket);
310
+ }
311
+ }
312
+ };
251
313
  }
252
314
  }
253
315
 
@@ -274,6 +336,7 @@ export class BucketParameterState {
274
336
  public readonly syncParams: RequestParameters;
275
337
  private readonly querier: BucketParameterQuerier;
276
338
  private readonly staticBuckets: Map<string, BucketDescription>;
339
+ private readonly logger: Logger;
277
340
  private cachedDynamicBuckets: BucketDescription[] | null = null;
278
341
  private cachedDynamicBucketSet: Set<string> | null = null;
279
342
 
@@ -283,12 +346,14 @@ export class BucketParameterState {
283
346
  context: SyncContext,
284
347
  bucketStorage: BucketChecksumStateStorage,
285
348
  syncRules: SqlSyncRules,
286
- syncParams: RequestParameters
349
+ syncParams: RequestParameters,
350
+ logger: Logger
287
351
  ) {
288
352
  this.context = context;
289
353
  this.bucketStorage = bucketStorage;
290
354
  this.syncRules = syncRules;
291
355
  this.syncParams = syncParams;
356
+ this.logger = logger;
292
357
 
293
358
  this.querier = syncRules.getBucketParameterQuerier(this.syncParams);
294
359
  this.staticBuckets = new Map<string, BucketDescription>(this.querier.staticBuckets.map((b) => [b.bucket, b]));
@@ -311,9 +376,9 @@ export class BucketParameterState {
311
376
  ErrorCode.PSYNC_S2305,
312
377
  `Too many parameter query results: ${update.buckets.length} (limit of ${this.context.maxParameterQueryResults})`
313
378
  );
314
- logger.error(error.message, {
379
+ this.logger.error(error.message, {
315
380
  checkpoint: checkpoint,
316
- user_id: this.syncParams.user_id,
381
+ user_id: this.syncParams.userId,
317
382
  buckets: update.buckets.length
318
383
  });
319
384
 
@@ -413,6 +478,26 @@ export class BucketParameterState {
413
478
  export interface CheckpointLine {
414
479
  checkpointLine: util.StreamingSyncCheckpointDiff | util.StreamingSyncCheckpoint;
415
480
  bucketsToFetch: BucketDescription[];
481
+
482
+ /**
483
+ * Call when a checkpoint line is being sent to a client, to update the internal state.
484
+ */
485
+ advance: () => void;
486
+
487
+ /**
488
+ * Get bucket positions to sync, given the list of buckets.
489
+ *
490
+ * @param bucketsToFetch List of buckets to fetch - either this.bucketsToFetch, or a subset of it. Defaults to this.bucketsToFetch.
491
+ */
492
+ getFilteredBucketPositions(bucketsToFetch?: BucketDescription[]): Map<string, util.InternalOpId>;
493
+
494
+ /**
495
+ * Update the position of bucket data the client has, after it was sent to the client.
496
+ *
497
+ * @param bucket the bucket name
498
+ * @param nextAfter sync operations >= this value in the next batch
499
+ */
500
+ updateBucketPosition(options: { bucket: string; nextAfter: util.InternalOpId; hasMore: boolean }): void;
416
501
  }
417
502
 
418
503
  // Use a more specific type to simplify testing
@@ -1,6 +1,7 @@
1
1
  import { MetricsEngine } from '../metrics/MetricsEngine.js';
2
2
 
3
3
  import { APIMetric } from '@powersync/service-types';
4
+ import { SyncBucketData } from '../util/protocol-types.js';
4
5
 
5
6
  /**
6
7
  * Record sync stats per request stream.
@@ -8,15 +9,24 @@ import { APIMetric } from '@powersync/service-types';
8
9
  export class RequestTracker {
9
10
  operationsSynced = 0;
10
11
  dataSyncedBytes = 0;
12
+ operationCounts: OperationCounts = { put: 0, remove: 0, move: 0, clear: 0 };
13
+ largeBuckets: Record<string, number> = {};
11
14
 
12
15
  constructor(private metrics: MetricsEngine) {
13
16
  this.metrics = metrics;
14
17
  }
15
18
 
16
- addOperationsSynced(operations: number) {
17
- this.operationsSynced += operations;
19
+ addOperationsSynced(operations: OperationsSentStats) {
20
+ this.operationsSynced += operations.total;
21
+ this.operationCounts.put += operations.operations.put;
22
+ this.operationCounts.remove += operations.operations.remove;
23
+ this.operationCounts.move += operations.operations.move;
24
+ this.operationCounts.clear += operations.operations.clear;
25
+ if (operations.total > 100 || operations.bucket in this.largeBuckets) {
26
+ this.largeBuckets[operations.bucket] = (this.largeBuckets[operations.bucket] ?? 0) + operations.total;
27
+ }
18
28
 
19
- this.metrics.getCounter(APIMetric.OPERATIONS_SYNCED).add(operations);
29
+ this.metrics.getCounter(APIMetric.OPERATIONS_SYNCED).add(operations.total);
20
30
  }
21
31
 
22
32
  addDataSynced(bytes: number) {
@@ -24,4 +34,61 @@ export class RequestTracker {
24
34
 
25
35
  this.metrics.getCounter(APIMetric.DATA_SYNCED_BYTES).add(bytes);
26
36
  }
37
+
38
+ getLogMeta() {
39
+ return {
40
+ operations_synced: this.operationsSynced,
41
+ data_synced_bytes: this.dataSyncedBytes,
42
+ operation_counts: this.operationCounts,
43
+ large_buckets: this.largeBuckets
44
+ };
45
+ }
46
+ }
47
+
48
+ export interface OperationCounts {
49
+ put: number;
50
+ remove: number;
51
+ move: number;
52
+ clear: number;
53
+ }
54
+
55
+ export interface OperationsSentStats {
56
+ bucket: string;
57
+ operations: OperationCounts;
58
+ total: number;
59
+ }
60
+
61
+ export function statsForBatch(batch: SyncBucketData): OperationsSentStats {
62
+ let put = 0;
63
+ let remove = 0;
64
+ let move = 0;
65
+ let clear = 0;
66
+
67
+ for (const entry of batch.data) {
68
+ switch (entry.op) {
69
+ case 'PUT':
70
+ put++;
71
+ break;
72
+ case 'REMOVE':
73
+ remove++;
74
+ break;
75
+ case 'MOVE':
76
+ move++;
77
+ break;
78
+ case 'CLEAR':
79
+ clear++;
80
+ break;
81
+ }
82
+ }
83
+
84
+ return {
85
+ bucket: batch.bucket,
86
+ operations: {
87
+ put,
88
+ remove,
89
+ move,
90
+ clear
91
+ },
92
+ total: put + remove + move + clear
93
+ };
27
94
  }