@powersync/service-module-mongodb-storage 0.17.0 → 0.18.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 (185) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +34 -16
  3. package/dist/storage/MongoBucketStorage.js +219 -70
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +42 -23
  6. package/dist/storage/implementation/MongoBucketBatch.js +61 -44
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoChecksums.d.ts +10 -9
  9. package/dist/storage/implementation/MongoChecksums.js +3 -111
  10. package/dist/storage/implementation/MongoChecksums.js.map +1 -1
  11. package/dist/storage/implementation/MongoCompactor.d.ts +2 -0
  12. package/dist/storage/implementation/MongoCompactor.js +18 -9
  13. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  14. package/dist/storage/implementation/MongoHydrationState.d.ts +11 -0
  15. package/dist/storage/implementation/MongoHydrationState.js +49 -0
  16. package/dist/storage/implementation/MongoHydrationState.js.map +1 -0
  17. package/dist/storage/implementation/MongoParameterCompactor.d.ts +6 -5
  18. package/dist/storage/implementation/MongoParameterCompactor.js +24 -1
  19. package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
  20. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +13 -0
  21. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +64 -0
  22. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -0
  23. package/dist/storage/implementation/MongoPersistedReplicationStream.d.ts +36 -0
  24. package/dist/storage/implementation/MongoPersistedReplicationStream.js +90 -0
  25. package/dist/storage/implementation/MongoPersistedReplicationStream.js.map +1 -0
  26. package/dist/storage/implementation/MongoPersistedSyncConfigContent.d.ts +25 -0
  27. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +123 -0
  28. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -0
  29. package/dist/storage/implementation/MongoStorageProvider.js +5 -2
  30. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  31. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +41 -23
  32. package/dist/storage/implementation/MongoSyncBucketStorage.js +51 -43
  33. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  34. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +1 -1
  35. package/dist/storage/implementation/MongoSyncRulesLock.js +3 -3
  36. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  37. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +6 -0
  38. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.js +2 -0
  39. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.js.map +1 -0
  40. package/dist/storage/implementation/common/PersistedBatch.d.ts +1 -2
  41. package/dist/storage/implementation/common/PersistedBatch.js +5 -0
  42. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  43. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js +1 -1
  44. package/dist/storage/implementation/common/VersionedPowerSyncMongoBase.js.map +1 -1
  45. package/dist/storage/implementation/createMongoSyncBucketStorage.d.ts +2 -2
  46. package/dist/storage/implementation/createMongoSyncBucketStorage.js +4 -4
  47. package/dist/storage/implementation/createMongoSyncBucketStorage.js.map +1 -1
  48. package/dist/storage/implementation/db.js +9 -14
  49. package/dist/storage/implementation/db.js.map +1 -1
  50. package/dist/storage/implementation/models.d.ts +4 -13
  51. package/dist/storage/implementation/models.js.map +1 -1
  52. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +9 -1
  53. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +42 -26
  54. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  55. package/dist/storage/implementation/v1/MongoChecksumsV1.js +114 -3
  56. package/dist/storage/implementation/v1/MongoChecksumsV1.js.map +1 -1
  57. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +15 -17
  58. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +47 -45
  59. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  60. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.d.ts +2 -1
  61. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js +3 -0
  62. package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js.map +1 -1
  63. package/dist/storage/implementation/v1/models.d.ts +10 -1
  64. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +8 -4
  65. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +286 -259
  66. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  67. package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +27 -7
  68. package/dist/storage/implementation/v3/MongoChecksumsV3.js +202 -18
  69. package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
  70. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +19 -2
  71. package/dist/storage/implementation/v3/MongoCompactorV3.js +511 -22
  72. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  73. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +62 -0
  74. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +350 -0
  75. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -0
  76. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +40 -28
  77. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +232 -116
  78. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  79. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +1 -1
  80. package/dist/storage/implementation/v3/PersistedBatchV3.js +43 -20
  81. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  82. package/dist/storage/implementation/v3/SingleBucketStoreV3.js +52 -16
  83. package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +1 -1
  84. package/dist/storage/implementation/v3/SourceRecordStoreV3.d.ts +3 -3
  85. package/dist/storage/implementation/v3/SourceRecordStoreV3.js +13 -11
  86. package/dist/storage/implementation/v3/SourceRecordStoreV3.js.map +1 -1
  87. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +13 -12
  88. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +29 -27
  89. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  90. package/dist/storage/implementation/v3/bucket-format.d.ts +4 -0
  91. package/dist/storage/implementation/v3/bucket-format.js +58 -0
  92. package/dist/storage/implementation/v3/bucket-format.js.map +1 -0
  93. package/dist/storage/implementation/v3/chunking.d.ts +10 -0
  94. package/dist/storage/implementation/v3/chunking.js +28 -0
  95. package/dist/storage/implementation/v3/chunking.js.map +1 -0
  96. package/dist/storage/implementation/v3/models.d.ts +64 -21
  97. package/dist/storage/implementation/v3/models.js +11 -28
  98. package/dist/storage/implementation/v3/models.js.map +1 -1
  99. package/dist/storage/implementation/v3/source-table-utils.d.ts +55 -0
  100. package/dist/storage/implementation/v3/source-table-utils.js +214 -0
  101. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -0
  102. package/dist/storage/storage-index.d.ts +4 -4
  103. package/dist/storage/storage-index.js +4 -4
  104. package/dist/storage/storage-index.js.map +1 -1
  105. package/dist/types/types.d.ts +2 -2
  106. package/dist/utils/test-utils.d.ts +1 -2
  107. package/dist/utils/test-utils.js +5 -1
  108. package/dist/utils/test-utils.js.map +1 -1
  109. package/dist/utils/util.d.ts +1 -1
  110. package/dist/utils/util.js +3 -2
  111. package/dist/utils/util.js.map +1 -1
  112. package/package.json +9 -19
  113. package/src/storage/MongoBucketStorage.ts +334 -87
  114. package/src/storage/implementation/MongoBucketBatch.ts +86 -58
  115. package/src/storage/implementation/MongoChecksums.ts +6 -138
  116. package/src/storage/implementation/MongoCompactor.ts +19 -9
  117. package/src/storage/implementation/MongoHydrationState.ts +61 -0
  118. package/src/storage/implementation/MongoParameterCompactor.ts +24 -5
  119. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +88 -0
  120. package/src/storage/implementation/MongoPersistedReplicationStream.ts +118 -0
  121. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +154 -0
  122. package/src/storage/implementation/MongoStorageProvider.ts +5 -2
  123. package/src/storage/implementation/MongoSyncBucketStorage.ts +70 -56
  124. package/src/storage/implementation/MongoSyncRulesLock.ts +7 -4
  125. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +7 -0
  126. package/src/storage/implementation/common/PersistedBatch.ts +7 -3
  127. package/src/storage/implementation/common/VersionedPowerSyncMongoBase.ts +1 -1
  128. package/src/storage/implementation/createMongoSyncBucketStorage.ts +21 -7
  129. package/src/storage/implementation/db.ts +11 -19
  130. package/src/storage/implementation/models.ts +4 -14
  131. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +55 -34
  132. package/src/storage/implementation/v1/MongoChecksumsV1.ts +137 -4
  133. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +70 -64
  134. package/src/storage/implementation/v1/VersionedPowerSyncMongoV1.ts +11 -1
  135. package/src/storage/implementation/v1/models.ts +11 -1
  136. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +358 -314
  137. package/src/storage/implementation/v3/MongoChecksumsV3.ts +262 -26
  138. package/src/storage/implementation/v3/MongoCompactorV3.ts +646 -29
  139. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +480 -0
  140. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +315 -157
  141. package/src/storage/implementation/v3/PersistedBatchV3.ts +62 -36
  142. package/src/storage/implementation/v3/SingleBucketStoreV3.ts +59 -19
  143. package/src/storage/implementation/v3/SourceRecordStoreV3.ts +12 -12
  144. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +41 -36
  145. package/src/storage/implementation/v3/bucket-format.ts +70 -0
  146. package/src/storage/implementation/v3/chunking.ts +38 -0
  147. package/src/storage/implementation/v3/models.ts +93 -55
  148. package/src/storage/implementation/v3/source-table-utils.ts +392 -0
  149. package/src/storage/storage-index.ts +12 -4
  150. package/src/utils/test-utils.ts +6 -3
  151. package/src/utils/util.ts +3 -2
  152. package/test/src/__snapshots__/storage.test.ts.snap +0 -75
  153. package/test/src/__snapshots__/storage_sync.test.ts.snap +48 -48
  154. package/test/src/cleanup-stopped-sync-configs.test.ts +648 -0
  155. package/test/src/storage.test.ts +11 -11
  156. package/test/src/storage_compacting.test.ts +1956 -6
  157. package/test/src/storage_sync.test.ts +1077 -68
  158. package/test/src/storeCurrentData.test.ts +17 -7
  159. package/test/src/util.ts +2 -1
  160. package/tsconfig.json +1 -4
  161. package/tsconfig.tsbuildinfo +1 -1
  162. package/dist/storage/implementation/BucketDefinitionMapping.d.ts +0 -15
  163. package/dist/storage/implementation/BucketDefinitionMapping.js +0 -58
  164. package/dist/storage/implementation/BucketDefinitionMapping.js.map +0 -1
  165. package/dist/storage/implementation/MongoPersistedSyncRules.d.ts +0 -14
  166. package/dist/storage/implementation/MongoPersistedSyncRules.js +0 -67
  167. package/dist/storage/implementation/MongoPersistedSyncRules.js.map +0 -1
  168. package/dist/storage/implementation/MongoPersistedSyncRulesContent.d.ts +0 -29
  169. package/dist/storage/implementation/MongoPersistedSyncRulesContent.js +0 -76
  170. package/dist/storage/implementation/MongoPersistedSyncRulesContent.js.map +0 -1
  171. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.d.ts +0 -13
  172. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js +0 -2
  173. package/dist/storage/implementation/common/MongoSyncBucketStorageContext.js.map +0 -1
  174. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +0 -9
  175. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +0 -18
  176. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +0 -1
  177. package/dist/storage/implementation/v3/MongoParameterLookupV3.d.ts +0 -4
  178. package/dist/storage/implementation/v3/MongoParameterLookupV3.js +0 -9
  179. package/dist/storage/implementation/v3/MongoParameterLookupV3.js.map +0 -1
  180. package/src/storage/implementation/BucketDefinitionMapping.ts +0 -75
  181. package/src/storage/implementation/MongoPersistedSyncRules.ts +0 -82
  182. package/src/storage/implementation/MongoPersistedSyncRulesContent.ts +0 -101
  183. package/src/storage/implementation/common/MongoSyncBucketStorageContext.ts +0 -15
  184. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +0 -24
  185. package/src/storage/implementation/v3/MongoParameterLookupV3.ts +0 -11
@@ -12,6 +12,7 @@ import {
12
12
  ServiceError
13
13
  } from '@powersync/lib-services-framework';
14
14
  import {
15
+ BucketDefinitionMapping,
15
16
  BucketStorageMarkRecordUnavailable,
16
17
  deserializeBson,
17
18
  InternalOpId,
@@ -23,12 +24,12 @@ import {
23
24
  } from '@powersync/service-core';
24
25
  import * as timers from 'node:timers/promises';
25
26
  import { mongoTableId } from '../../utils/util.js';
26
- import { BucketDefinitionMapping } from './BucketDefinitionMapping.js';
27
27
  import { PersistedBatch } from './common/PersistedBatch.js';
28
28
  import { LoadedSourceRecord, SourceRecordStore } from './common/SourceRecordStore.js';
29
29
  import type { VersionedPowerSyncMongo } from './db.js';
30
30
  import { MAX_ROW_SIZE } from './MongoBucketBatchShared.js';
31
31
  import { MongoIdSequence } from './MongoIdSequence.js';
32
+ import { MongoParsedSyncConfigSet } from './MongoParsedSyncConfigSet.js';
32
33
  import { batchCreateCustomWriteCheckpoints } from './MongoWriteCheckpointAPI.js';
33
34
  import { OperationBatch, RecordOperation } from './OperationBatch.js';
34
35
 
@@ -41,15 +42,25 @@ const replicationMutex = new utils.Mutex();
41
42
 
42
43
  export interface MongoBucketBatchOptions {
43
44
  db: VersionedPowerSyncMongo;
44
- syncRules: HydratedSyncConfig;
45
- groupId: number;
46
- slotName: string;
47
- syncConfigId?: bson.ObjectId | null;
48
- lastCheckpointLsn: string | null;
49
- keepaliveOp: InternalOpId | null;
45
+ /**
46
+ * The parsed sync config set for this batch.
47
+ *
48
+ * The batch derives both the hydrated sync rules and the bucket definition mapping from
49
+ * this single set, so they always come from the same parse. Do not add separate
50
+ * syncRules/mapping options - pairing values from different parses is exactly the bug
51
+ * this shape prevents.
52
+ */
53
+ parsedSyncConfig: MongoParsedSyncConfigSet;
54
+ replicationStreamId: number;
55
+ replicationStreamName: string;
56
+ syncConfigIds?: bson.ObjectId[];
57
+ /**
58
+ * Seeds the in-memory persisted-op tracking for v1 storage. Not used by v3 storage, which
59
+ * tracks the persisted-op head durably on the replication stream document instead.
60
+ */
61
+ keepaliveOp?: InternalOpId | null;
50
62
  resumeFromLsn: string | null;
51
63
  storeCurrentData: boolean;
52
- mapping: BucketDefinitionMapping;
53
64
  /**
54
65
  * Set to true for initial replication.
55
66
  */
@@ -70,13 +81,13 @@ export abstract class MongoBucketBatch
70
81
  protected logger: Logger;
71
82
 
72
83
  private readonly client: mongo.MongoClient;
73
- public readonly db: VersionedPowerSyncMongo;
84
+ readonly db: VersionedPowerSyncMongo;
74
85
  public readonly session: mongo.ClientSession;
75
86
  protected readonly sync_rules: HydratedSyncConfig;
76
87
 
77
- protected readonly group_id: number;
88
+ protected readonly replicationStreamId: number;
78
89
 
79
- private readonly slot_name: string;
90
+ private readonly replicationStreamName: string;
80
91
  /**
81
92
  * Source-level setting for whether raw row data should be stored in current_data.
82
93
  *
@@ -98,26 +109,15 @@ export abstract class MongoBucketBatch
98
109
 
99
110
  private tracer: PerformanceTracer<'storage' | 'evaluate'>;
100
111
 
101
- /**
102
- * Last LSN received associated with a checkpoint.
103
- *
104
- * This could be either:
105
- * 1. A commit LSN.
106
- * 2. A keepalive message LSN.
107
- */
108
- protected last_checkpoint_lsn: string | null = null;
109
-
110
- protected persisted_op: InternalOpId | null = null;
111
-
112
112
  /**
113
113
  * Last written op, if any. This may not reflect a consistent checkpoint.
114
114
  */
115
115
  public last_flushed_op: InternalOpId | null = null;
116
116
 
117
117
  /**
118
- * lastCheckpointLsn is the last consistent commit.
118
+ * LSN to resume replication from.
119
119
  *
120
- * While that is generally a "safe" point to resume from, there are cases where we may want to resume from a different point:
120
+ * This is typically the last commit LSN, but there are cases where it differs:
121
121
  * 1. After an initial snapshot, we don't have a consistent commit yet, but need to resume from the snapshot LSN.
122
122
  * 2. If "no_checkpoint_before_lsn" is set far in advance, it may take a while to reach that point. We
123
123
  * may want to resume at incremental points before that.
@@ -132,34 +132,28 @@ export abstract class MongoBucketBatch
132
132
  this.options = options;
133
133
  this.client = options.db.client;
134
134
  this.db = options.db;
135
- this.group_id = options.groupId;
136
- this.last_checkpoint_lsn = options.lastCheckpointLsn;
135
+ this.replicationStreamId = options.replicationStreamId;
137
136
  this.resumeFromLsn = options.resumeFromLsn;
138
137
  this.session = this.client.startSession();
139
- this.slot_name = options.slotName;
140
- this.sync_rules = options.syncRules;
138
+ this.replicationStreamName = options.replicationStreamName;
139
+ this.sync_rules = options.parsedSyncConfig.hydratedSyncConfig;
141
140
  this.storeCurrentData = options.storeCurrentData;
142
- this.mapping = options.mapping;
141
+ this.mapping = options.parsedSyncConfig.mapping;
143
142
  this.skipExistingRows = options.skipExistingRows;
144
143
  this.markRecordUnavailable = options.markRecordUnavailable;
145
144
  this.hooks = options.hooks;
146
145
  this.batch = new OperationBatch();
147
146
 
148
- this.persisted_op = options.keepaliveOp ?? null;
149
147
  this.tracer = options.tracer ?? new PerformanceTracer('MongoDB storage');
150
148
  }
151
149
 
152
150
  addCustomWriteCheckpoint(checkpoint: storage.BatchedCustomWriteCheckpointOptions): void {
153
151
  this.write_checkpoint_batch.push({
154
152
  ...checkpoint,
155
- sync_rules_id: this.group_id
153
+ sync_rules_id: this.replicationStreamId
156
154
  });
157
155
  }
158
156
 
159
- get lastCheckpointLsn() {
160
- return this.last_checkpoint_lsn;
161
- }
162
-
163
157
  abstract resolveTables(options: storage.ResolveTablesOptions): Promise<storage.ResolveTablesResult>;
164
158
 
165
159
  protected abstract createPersistedBatch(writtenSize: number): PersistedBatch;
@@ -230,7 +224,7 @@ export abstract class MongoBucketBatch
230
224
  throw new ReplicationAssertionError('Unexpected last_op == null');
231
225
  }
232
226
 
233
- this.persisted_op = last_op;
227
+ this.recordPersistedOp(last_op);
234
228
  this.last_flushed_op = last_op;
235
229
  await this.hooks?.afterBatchFlush?.(this);
236
230
  return { flushed_op: last_op };
@@ -462,7 +456,7 @@ export abstract class MongoBucketBatch
462
456
  {
463
457
  level: errors.ErrorSeverity.WARNING,
464
458
  metadata: {
465
- replication_slot: this.slot_name,
459
+ replication_slot: this.replicationStreamName,
466
460
  table: record.sourceTable.qualifiedName
467
461
  }
468
462
  }
@@ -521,7 +515,7 @@ export abstract class MongoBucketBatch
521
515
  {
522
516
  level: errors.ErrorSeverity.WARNING,
523
517
  metadata: {
524
- replication_slot: this.slot_name,
518
+ replication_slot: this.replicationStreamName,
525
519
  table: record.sourceTable.qualifiedName
526
520
  }
527
521
  }
@@ -556,7 +550,7 @@ export abstract class MongoBucketBatch
556
550
  {
557
551
  level: errors.ErrorSeverity.WARNING,
558
552
  metadata: {
559
- replication_slot: this.slot_name,
553
+ replication_slot: this.replicationStreamName,
560
554
  table: record.sourceTable.qualifiedName
561
555
  }
562
556
  }
@@ -691,7 +685,7 @@ export abstract class MongoBucketBatch
691
685
 
692
686
  await this.db.sync_rules.updateOne(
693
687
  {
694
- _id: this.group_id
688
+ _id: this.replicationStreamId
695
689
  },
696
690
  {
697
691
  $set: {
@@ -700,12 +694,41 @@ export abstract class MongoBucketBatch
700
694
  },
701
695
  { session }
702
696
  );
697
+
698
+ // Allow subclasses to persist additional flush-time state in the same transaction
699
+ // (e.g. v3 advances the stream-level last_persisted_op).
700
+ await this.onReplicationTransactionFlush(session, opSeq.last());
703
701
  // We don't notify checkpoint here - we don't make any checkpoint updates directly
704
702
  });
705
703
  }
706
704
 
705
+ /**
706
+ * Hook called inside the replication flush transaction, after ops have been persisted.
707
+ *
708
+ * The base implementation does nothing; v3 storage overrides this to `$max` the stream-level
709
+ * `last_persisted_op` durably within the same transaction.
710
+ */
711
+ protected async onReplicationTransactionFlush(_session: mongo.ClientSession, _lastOp: InternalOpId): Promise<void> {
712
+ // No-op by default (v1 behaviour unchanged).
713
+ }
714
+
715
+ /**
716
+ * Called after a replication transaction has successfully committed, with the last persisted op id.
717
+ *
718
+ * v1 storage tracks this in memory to fold into the next checkpoint. v3 storage does not need it:
719
+ * the stream-level `last_persisted_op` is already advanced durably by
720
+ * {@link onReplicationTransactionFlush} within the same transaction, and checkpoints read it
721
+ * from the document.
722
+ *
723
+ * Keep calls to this adjacent to {@link withReplicationTransaction} usage - both must observe
724
+ * every path that persists ops.
725
+ */
726
+ protected recordPersistedOp(_lastOp: InternalOpId): void {
727
+ // No-op by default.
728
+ }
729
+
707
730
  async [Symbol.asyncDispose]() {
708
- if (this.batch != null || this.write_checkpoint_batch.length > 0) {
731
+ if (this.batch?.hasData() || this.write_checkpoint_batch.length > 0) {
709
732
  // We don't error here, since:
710
733
  // 1. In error states, this is expected (we can't distinguish between disposing after success or error).
711
734
  // 2. SuppressedError is messy to deal with.
@@ -722,19 +745,24 @@ export abstract class MongoBucketBatch
722
745
  async save(record: storage.SaveOptions): Promise<storage.FlushedResult | null> {
723
746
  const { after, before, sourceTable, tag } = record;
724
747
  const storeCurrentData = this.storeCurrentData && sourceTable.storeCurrentData;
725
- for (const event of this.getTableEvents(sourceTable)) {
726
- this.iterateListeners((cb) =>
727
- cb.replicationEvent?.({
728
- batch: this,
729
- table: sourceTable,
730
- data: {
731
- op: tag,
732
- after: after && utils.isCompleteRow(storeCurrentData, after) ? after : undefined,
733
- before: before && utils.isCompleteRow(storeCurrentData, before) ? before : undefined
734
- },
735
- event
736
- })
737
- );
748
+ // syncEvent is the per-table designation from resolveTables. With v3 storage, multiple
749
+ // SourceTables can exist for the same ref, with a row change saved once per table -
750
+ // only the designated event carrier may fire events, so each event fires once per row.
751
+ if (sourceTable.syncEvent) {
752
+ for (const event of this.getTableEvents(sourceTable)) {
753
+ this.iterateListeners((cb) =>
754
+ cb.replicationEvent?.({
755
+ batch: this,
756
+ table: sourceTable,
757
+ data: {
758
+ op: tag,
759
+ after: after && utils.isCompleteRow(storeCurrentData, after) ? after : undefined,
760
+ before: before && utils.isCompleteRow(storeCurrentData, before) ? before : undefined
761
+ },
762
+ event
763
+ })
764
+ );
765
+ }
738
766
  }
739
767
 
740
768
  /**
@@ -767,7 +795,7 @@ export abstract class MongoBucketBatch
767
795
 
768
796
  await this.withTransaction(async () => {
769
797
  for (let table of sourceTables) {
770
- await this.db.commonSourceTables(this.group_id).deleteOne({ _id: mongoTableId(table.id) });
798
+ await this.db.commonSourceTables(this.replicationStreamId).deleteOne({ _id: mongoTableId(table.id) });
771
799
  }
772
800
  });
773
801
 
@@ -784,7 +812,7 @@ export abstract class MongoBucketBatch
784
812
  }
785
813
 
786
814
  if (last_op) {
787
- this.persisted_op = last_op;
815
+ this.recordPersistedOp(last_op);
788
816
  return {
789
817
  flushed_op: last_op
790
818
  };
@@ -853,7 +881,7 @@ export abstract class MongoBucketBatch
853
881
  copy.snapshotStatus = snapshotStatus;
854
882
 
855
883
  await this.withTransaction(async () => {
856
- await this.db.commonSourceTables(this.group_id).updateOne(
884
+ await this.db.commonSourceTables(this.replicationStreamId).updateOne(
857
885
  { _id: mongoTableId(table.id) },
858
886
  {
859
887
  $set: {
@@ -879,7 +907,7 @@ export abstract class MongoBucketBatch
879
907
 
880
908
  await this.db.sync_rules.updateOne(
881
909
  {
882
- _id: this.group_id
910
+ _id: this.replicationStreamId
883
911
  },
884
912
  {
885
913
  $set: {
@@ -7,19 +7,16 @@ import {
7
7
  ChecksumMap,
8
8
  FetchPartialBucketChecksum,
9
9
  InternalOpId,
10
- isPartialChecksum,
11
10
  PartialChecksum,
12
11
  PartialChecksumMap,
13
12
  PartialOrFullChecksum
14
13
  } from '@powersync/service-core';
15
14
  import type { VersionedPowerSyncMongo } from './db.js';
16
15
 
17
- import * as lib_mongo from '@powersync/lib-service-mongodb';
18
16
  import { BucketDefinitionId } from '@powersync/service-sync-rules';
19
- import { BucketDefinitionMapping } from './BucketDefinitionMapping.js';
20
- import { BucketDataDocumentBase, StorageConfig } from './models.js';
17
+ import { StorageConfig } from './models.js';
21
18
 
22
- export interface FetchPartialBucketChecksumV3 {
19
+ export interface FetchPartialBucketChecksumByDefinition {
23
20
  bucket: string;
24
21
  definitionId: BucketDefinitionId;
25
22
  start?: InternalOpId;
@@ -47,15 +44,14 @@ export interface MongoChecksumOptions {
47
44
  operationBatchLimit?: number;
48
45
 
49
46
  storageConfig: StorageConfig;
50
- mapping?: BucketDefinitionMapping;
51
47
  }
52
48
 
53
49
  const DEFAULT_BUCKET_BATCH_LIMIT = 200;
54
- const DEFAULT_OPERATION_BATCH_LIMIT = 50_000;
50
+ export const DEFAULT_OPERATION_BATCH_LIMIT = 50_000;
55
51
 
56
52
  export abstract class MongoChecksums {
57
53
  private _cache: ChecksumCache | undefined;
58
- private readonly storageConfig: StorageConfig;
54
+ protected readonly storageConfig: StorageConfig;
59
55
 
60
56
  constructor(
61
57
  protected readonly db: VersionedPowerSyncMongo,
@@ -178,138 +174,10 @@ export abstract class MongoChecksums {
178
174
  protected abstract fetchPreStates(
179
175
  batch: FetchPartialBucketChecksum[]
180
176
  ): Promise<Map<string, { opId: InternalOpId; checksum: BucketChecksum }>>;
181
-
182
- protected async computePartialChecksumsForCollection<
183
- TRequest extends FetchPartialBucketChecksumByBucket,
184
- TBucketDataDocument extends BucketDataDocumentBase
185
- >(
186
- batch: TRequest[],
187
- collection: lib_mongo.mongo.Collection<TBucketDataDocument>,
188
- createFilter: (request: TRequest) => any
189
- ): Promise<PartialChecksumMap> {
190
- const batchLimit = this.options?.operationBatchLimit ?? DEFAULT_OPERATION_BATCH_LIMIT;
191
-
192
- // Map requests by bucket. We adjust this as we get partial results.
193
- let requests = new Map<string, TRequest>();
194
- for (let request of batch) {
195
- requests.set(request.bucket, request);
196
- }
197
-
198
- const partialChecksums = new Map<string, PartialOrFullChecksum>();
199
-
200
- while (requests.size > 0) {
201
- const filters = Array.from(requests.values(), createFilter);
202
-
203
- // Historically, checksum may be stored as 'int' or 'double'.
204
- // More recently, this should be a 'long'.
205
- // $toLong ensures that we always sum it as a long, avoiding inaccuracies in the calculations.
206
- const checksumLong = this.storageConfig.longChecksums ? '$checksum' : { $toLong: '$checksum' };
207
-
208
- // Aggregate over a max of `batchLimit` operations at a time.
209
- // Let's say we have 3 buckets (A, B, C), each with 10 operations, and our batch limit is 12.
210
- // Then we'll do three batches:
211
- // 1. Query: A[1-end], B[1-end], C[1-end]
212
- // Returns: A[1-10], B[1-2]
213
- // 2. Query: B[3-end], C[1-end]
214
- // Returns: B[3-10], C[1-4]
215
- // 3. Query: C[5-end]
216
- // Returns: C[5-10]
217
- const aggregate = await collection
218
- .aggregate(
219
- [
220
- {
221
- $match: {
222
- $or: filters
223
- }
224
- },
225
- // sort and limit _before_ grouping
226
- { $sort: { _id: 1 } },
227
- { $limit: batchLimit },
228
- {
229
- $group: {
230
- _id: '$_id.b',
231
- checksum_total: { $sum: checksumLong },
232
- count: { $sum: 1 },
233
- has_clear_op: {
234
- $max: {
235
- $cond: [{ $eq: ['$op', 'CLEAR'] }, 1, 0]
236
- }
237
- },
238
- last_op: { $max: '$_id.o' }
239
- }
240
- },
241
- // Sort the aggregated results (100 max, so should be fast).
242
- // This is important to identify which buckets we have partial data for.
243
- { $sort: { _id: 1 } }
244
- ],
245
- { session: undefined, readConcern: 'snapshot', maxTimeMS: lib_mongo.MONGO_CHECKSUM_TIMEOUT_MS }
246
- )
247
- .toArray()
248
- .catch((e) => {
249
- throw lib_mongo.mapQueryError(e, 'while reading checksums');
250
- });
251
-
252
- let batchCount = 0;
253
- let limitReached = false;
254
- for (let doc of aggregate) {
255
- const bucket = doc._id;
256
- const checksum = checksumFromAggregate(doc);
257
-
258
- const existing = partialChecksums.get(bucket);
259
- if (existing != null) {
260
- partialChecksums.set(bucket, addPartialChecksums(bucket, existing, checksum));
261
- } else {
262
- partialChecksums.set(bucket, checksum);
263
- }
264
-
265
- batchCount += doc.count;
266
- if (batchCount == batchLimit) {
267
- // Limit reached. Request more in the next batch.
268
- // Note that this only affects the _last_ bucket in a batch.
269
- limitReached = true;
270
- const req = requests.get(bucket);
271
- requests.set(bucket, {
272
- ...req!,
273
- start: doc.last_op
274
- });
275
- } else {
276
- // All done for this bucket
277
- requests.delete(bucket);
278
- }
279
- }
280
- if (!limitReached) {
281
- break;
282
- }
283
- }
284
-
285
- return new Map<string, PartialOrFullChecksum>(
286
- batch.map((request) => {
287
- const bucket = request.bucket;
288
- // Could be null if we got no data
289
- let partialChecksum = partialChecksums.get(bucket);
290
- if (partialChecksum == null) {
291
- partialChecksum = {
292
- bucket,
293
- partialCount: 0,
294
- partialChecksum: 0
295
- };
296
- }
297
- if (request.start == null && isPartialChecksum(partialChecksum)) {
298
- partialChecksum = {
299
- bucket,
300
- count: partialChecksum.partialCount,
301
- checksum: partialChecksum.partialChecksum
302
- };
303
- }
304
-
305
- return [bucket, partialChecksum];
306
- })
307
- );
308
- }
309
177
  }
310
178
 
311
179
  export function emptyChecksumForRequest(
312
- request: Pick<FetchPartialBucketChecksum | FetchPartialBucketChecksumV3, 'bucket' | 'start'>
180
+ request: Pick<FetchPartialBucketChecksum | FetchPartialBucketChecksumByDefinition, 'bucket' | 'start'>
313
181
  ): PartialOrFullChecksum {
314
182
  if (request.start == null) {
315
183
  return { bucket: request.bucket, count: 0, checksum: 0 };
@@ -320,7 +188,7 @@ export function emptyChecksumForRequest(
320
188
  /**
321
189
  * Convert output of the $group stage into a checksum.
322
190
  */
323
- function checksumFromAggregate(doc: bson.Document): PartialOrFullChecksum {
191
+ export function checksumFromAggregate(doc: bson.Document): PartialOrFullChecksum {
324
192
  const partialChecksum = Number(BigInt(doc.checksum_total) & 0xffffffffn) & 0xffffffff;
325
193
  const bucket = doc._id;
326
194
 
@@ -63,6 +63,7 @@ export interface MongoCompactOptions extends storage.CompactOptions {}
63
63
  const DEFAULT_CLEAR_BATCH_LIMIT = 5000;
64
64
  const DEFAULT_MOVE_BATCH_LIMIT = 2000;
65
65
  const DEFAULT_MOVE_BATCH_QUERY_LIMIT = 10_000;
66
+ const DEFAULT_MOVE_BATCH_BYTE_LIMIT = 16 * 1024 * 1024;
66
67
  const DEFAULT_MIN_BUCKET_CHANGES = 10;
67
68
  const DEFAULT_MIN_CHANGE_RATIO = 0.1;
68
69
  const DIRTY_BUCKET_SCAN_BATCH_SIZE = 2_000;
@@ -83,6 +84,7 @@ export abstract class MongoCompactor {
83
84
  protected readonly idLimitBytes: number;
84
85
  protected readonly moveBatchLimit: number;
85
86
  protected readonly moveBatchQueryLimit: number;
87
+ protected readonly moveBatchByteLimit: number;
86
88
  protected readonly clearBatchLimit: number;
87
89
  protected readonly minBucketChanges: number;
88
90
  protected readonly minChangeRatio: number;
@@ -98,11 +100,15 @@ export abstract class MongoCompactor {
98
100
  protected readonly db: VersionedPowerSyncMongo,
99
101
  options: MongoCompactOptions
100
102
  ) {
101
- this.group_id = storage.group_id;
103
+ this.group_id = storage.replicationStreamId;
102
104
  this.idLimitBytes = (options.memoryLimitMB ?? DEFAULT_MEMORY_LIMIT_MB) * 1024 * 1024;
103
105
  this.moveBatchLimit = options.moveBatchLimit ?? DEFAULT_MOVE_BATCH_LIMIT;
104
106
  this.moveBatchQueryLimit = options.moveBatchQueryLimit ?? DEFAULT_MOVE_BATCH_QUERY_LIMIT;
107
+ this.moveBatchByteLimit = options.moveBatchByteLimit ?? DEFAULT_MOVE_BATCH_BYTE_LIMIT;
105
108
  this.clearBatchLimit = options.clearBatchLimit ?? DEFAULT_CLEAR_BATCH_LIMIT;
109
+ if (this.clearBatchLimit < 2) {
110
+ throw new ReplicationAssertionError('clearBatchLimit must be >= 2');
111
+ }
106
112
  this.minBucketChanges = options.minBucketChanges ?? DEFAULT_MIN_BUCKET_CHANGES;
107
113
  this.minChangeRatio = options.minChangeRatio ?? DEFAULT_MIN_CHANGE_RATIO;
108
114
  this.maxOpId = options.maxOpId ?? 0n;
@@ -133,6 +139,7 @@ export abstract class MongoCompactor {
133
139
  */
134
140
  async populateChecksums(options: { minBucketChanges: number }): Promise<PopulateChecksumCacheResults> {
135
141
  let count = 0;
142
+ // Paginate through dirty buckets in batches until no more buckets meet the criteria.
136
143
  while (true) {
137
144
  this.signal?.throwIfAborted();
138
145
  const buckets = await this.dirtyBucketBatchForChecksums(options);
@@ -172,6 +179,7 @@ export abstract class MongoCompactor {
172
179
  },
173
180
  getDefinitionId: (state: TCollectionBucketState) => BucketDefinitionId | null
174
181
  ): AsyncGenerator<DirtyBucket[]> {
182
+ // Paginate through the bucket state collection using cursor-based scanning.
175
183
  while (true) {
176
184
  // To avoid timeouts from too many buckets not meeting the minBucketChanges criteria, use an aggregation pipeline
177
185
  // to scan a fixed batch of buckets at a time, but only return buckets that meet the criteria.
@@ -304,6 +312,7 @@ export abstract class MongoCompactor {
304
312
  */
305
313
  protected async compactSingleBucketRetried(bucket: string, definitionId: BucketDefinitionId | null = null) {
306
314
  let retryCount = 0;
315
+ // Retry with exponential backoff up to 3 times on MongoDB errors.
307
316
  while (true) {
308
317
  try {
309
318
  await this.compactSingleBucket(bucket, definitionId);
@@ -343,6 +352,7 @@ export abstract class MongoCompactor {
343
352
  // Upper bound is adjusted for each batch.
344
353
  let upperBound = bucketContext.maxId;
345
354
 
355
+ // Paginate through bucket data in batches to avoid cursor timeouts.
346
356
  while (true) {
347
357
  this.signal?.throwIfAborted();
348
358
 
@@ -353,11 +363,7 @@ export abstract class MongoCompactor {
353
363
  _id: {
354
364
  $gte: lowerBound,
355
365
  $lt: upperBound
356
- },
357
- // Workaround for a clustered collection bug where the $lt operator may include upperBound.
358
- // Technically only needed for storage V3.
359
- // https://jira.mongodb.org/browse/SERVER-121822
360
- '_id.o': { $lt: upperBound.o }
366
+ }
361
367
  }
362
368
  },
363
369
  { $sort: { _id: -1 } },
@@ -488,13 +494,13 @@ export abstract class MongoCompactor {
488
494
  await this.flush(bucketContext);
489
495
  }
490
496
 
491
- protected updateBucketChecksums(state: CurrentBucketState) {
497
+ protected collectBucketStateUpdates(state: CurrentBucketState): mongo.AnyBulkWriteOperation<BucketStateDocumentBase> {
492
498
  if (state.opCount < 0) {
493
499
  throw new ServiceAssertionError(
494
500
  `Invalid opCount: ${state.opCount} checksum ${state.checksum} opsSincePut: ${state.opsSincePut} maxOpId: ${this.maxOpId}`
495
501
  );
496
502
  }
497
- this.bucketStateUpdates.push({
503
+ return {
498
504
  updateOne: {
499
505
  filter: this.bucketStateFilter(state.bucket, state.definitionId),
500
506
  update: {
@@ -517,7 +523,11 @@ export abstract class MongoCompactor {
517
523
  // We don't create new ones here, to avoid issues with the unique index on bucket_updates.
518
524
  upsert: false
519
525
  }
520
- });
526
+ };
527
+ }
528
+
529
+ protected updateBucketChecksums(state: CurrentBucketState) {
530
+ this.bucketStateUpdates.push(this.collectBucketStateUpdates(state));
521
531
  }
522
532
 
523
533
  protected async flush(col: SingleBucketStore) {
@@ -0,0 +1,61 @@
1
+ import { ServiceAssertionError } from '@powersync/lib-services-framework';
2
+ import { SyncConfigWithRequiredMapping } from '@powersync/service-core';
3
+ import {
4
+ BucketDataScope,
5
+ BucketDataSource,
6
+ HydrationState,
7
+ ParameterIndexLookupCreator,
8
+ ParameterLookupScope
9
+ } from '@powersync/service-sync-rules';
10
+
11
+ export class MongoHydrationState implements HydrationState {
12
+ private bucketDataSourceSyncConfig = new WeakMap<BucketDataSource, SyncConfigWithRequiredMapping>();
13
+ private parameterIndexLookupSyncConfig = new WeakMap<ParameterIndexLookupCreator, SyncConfigWithRequiredMapping>();
14
+
15
+ constructor(
16
+ readonly syncConfigs: SyncConfigWithRequiredMapping[],
17
+ private readonly replicationStreamId: number
18
+ ) {
19
+ for (let syncConfig of syncConfigs) {
20
+ for (let source of syncConfig.syncConfig.config.bucketDataSources) {
21
+ this.bucketDataSourceSyncConfig.set(source, syncConfig);
22
+ }
23
+ for (let source of syncConfig.syncConfig.config.bucketParameterLookupSources) {
24
+ this.parameterIndexLookupSyncConfig.set(source, syncConfig);
25
+ }
26
+ }
27
+ }
28
+
29
+ getBucketSourceScope(source: BucketDataSource): BucketDataScope {
30
+ const syncConfig = this.bucketDataSourceSyncConfig.get(source);
31
+ if (syncConfig == null) {
32
+ throw new ServiceAssertionError(`No sync config found for bucket data source ${source.uniqueName}`);
33
+ }
34
+ const mapping = syncConfig.mapping;
35
+ const defId = mapping.bucketSourceId(source);
36
+ // Currently uniqueName is constant for a BucketDataSource within a replication stream. This may change in the
37
+ // future, in which case we need to reconsider the bucketPrefix here.
38
+ // However, the definition may change without changing the name, so we include the defId in the bucketPrefix.
39
+ // defId is not unique across replication streams, so we include the replicationStreamId as well.
40
+ return {
41
+ bucketPrefix: `${source.uniqueName}.${this.replicationStreamId.toString(16)}.${defId}`,
42
+ source
43
+ };
44
+ }
45
+
46
+ getParameterIndexLookupScope(source: ParameterIndexLookupCreator): ParameterLookupScope {
47
+ const syncConfig = this.parameterIndexLookupSyncConfig.get(source);
48
+ if (syncConfig == null) {
49
+ throw new ServiceAssertionError(
50
+ `No sync config found for parameter index lookup source ${source.sourceId.lookupName}#${source.sourceId.queryId}`
51
+ );
52
+ }
53
+ const mapping = syncConfig.mapping;
54
+ const defId = mapping.parameterLookupId(source);
55
+ return {
56
+ lookupName: defId,
57
+ queryId: '',
58
+ source
59
+ };
60
+ }
61
+ }