@powersync/service-module-mongodb-storage 0.19.0 → 0.21.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 (176) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/storage/MongoBucketStorage.d.ts +31 -3
  3. package/dist/storage/MongoBucketStorage.js +365 -206
  4. package/dist/storage/MongoBucketStorage.js.map +1 -1
  5. package/dist/storage/implementation/MongoBucketBatch.d.ts +15 -4
  6. package/dist/storage/implementation/MongoBucketBatch.js +35 -9
  7. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  8. package/dist/storage/implementation/MongoCompactor.d.ts +19 -79
  9. package/dist/storage/implementation/MongoCompactor.js +27 -238
  10. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  11. package/dist/storage/implementation/MongoParameterCompactor.d.ts +83 -10
  12. package/dist/storage/implementation/MongoParameterCompactor.js +309 -96
  13. package/dist/storage/implementation/MongoParameterCompactor.js.map +1 -1
  14. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +9 -1
  15. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +29 -0
  16. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -1
  17. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js +4 -2
  18. package/dist/storage/implementation/MongoPersistedSyncConfigContent.js.map +1 -1
  19. package/dist/storage/implementation/MongoStorageProvider.js +3 -1
  20. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  21. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +27 -11
  22. package/dist/storage/implementation/MongoSyncBucketStorage.js +78 -31
  23. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  24. package/dist/storage/implementation/MongoSyncRulesLock.js +21 -9
  25. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  26. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +9 -7
  27. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +7 -28
  28. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  29. package/dist/storage/implementation/SyncRuleStateUpdate.d.ts +11 -5
  30. package/dist/storage/implementation/SyncRuleStateUpdate.js +14 -6
  31. package/dist/storage/implementation/SyncRuleStateUpdate.js.map +1 -1
  32. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +22 -2
  33. package/dist/storage/implementation/common/PersistedBatch.d.ts +27 -0
  34. package/dist/storage/implementation/common/PersistedBatch.js +43 -2
  35. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  36. package/dist/storage/implementation/db.d.ts +4 -1
  37. package/dist/storage/implementation/db.js +14 -1
  38. package/dist/storage/implementation/db.js.map +1 -1
  39. package/dist/storage/implementation/models.d.ts +35 -0
  40. package/dist/storage/implementation/models.js.map +1 -1
  41. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +6 -0
  42. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +80 -84
  43. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  44. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +36 -5
  45. package/dist/storage/implementation/v1/MongoCompactorV1.js +229 -8
  46. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  47. package/dist/storage/implementation/v1/MongoParameterCompactorV1.d.ts +16 -2
  48. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js +20 -9
  49. package/dist/storage/implementation/v1/MongoParameterCompactorV1.js.map +1 -1
  50. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.d.ts +18 -11
  51. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +65 -16
  52. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  53. package/dist/storage/implementation/v1/models.js +1 -0
  54. package/dist/storage/implementation/v1/models.js.map +1 -1
  55. package/dist/storage/implementation/v3/CompactionLease.d.ts +50 -0
  56. package/dist/storage/implementation/v3/CompactionLease.js +131 -0
  57. package/dist/storage/implementation/v3/CompactionLease.js.map +1 -0
  58. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +8 -1
  59. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +120 -39
  60. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  61. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +77 -25
  62. package/dist/storage/implementation/v3/MongoCompactorV3.js +585 -141
  63. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  64. package/dist/storage/implementation/v3/MongoParameterCompactorV3.d.ts +18 -0
  65. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js +23 -0
  66. package/dist/storage/implementation/v3/MongoParameterCompactorV3.js.map +1 -0
  67. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +2 -15
  68. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +60 -92
  69. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  70. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +19 -9
  71. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +91 -31
  72. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  73. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.d.ts +26 -0
  74. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js +82 -0
  75. package/dist/storage/implementation/v3/MongoWriteCheckpointAPIV3.js.map +1 -0
  76. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +2 -0
  77. package/dist/storage/implementation/v3/PersistedBatchV3.js +52 -11
  78. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  79. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +12 -2
  80. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +42 -3
  81. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  82. package/dist/storage/implementation/v3/bucket-format.js +2 -0
  83. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  84. package/dist/storage/implementation/v3/compact-utils.d.ts +107 -0
  85. package/dist/storage/implementation/v3/compact-utils.js +216 -0
  86. package/dist/storage/implementation/v3/compact-utils.js.map +1 -0
  87. package/dist/storage/implementation/v3/compaction-constants.d.ts +5 -0
  88. package/dist/storage/implementation/v3/compaction-constants.js +6 -0
  89. package/dist/storage/implementation/v3/compaction-constants.js.map +1 -0
  90. package/dist/storage/implementation/v3/models.d.ts +89 -5
  91. package/dist/storage/implementation/v3/models.js.map +1 -1
  92. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +7 -8
  93. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +8 -4
  94. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  95. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +14 -11
  96. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +3 -5
  97. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +10 -2
  98. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  99. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.d.ts +37 -0
  100. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js +167 -0
  101. package/dist/storage/implementation/v3/object-storage/ObjectStorageUsage.js.map +1 -0
  102. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +37 -12
  103. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +177 -23
  104. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -1
  105. package/dist/storage/implementation/v3/source-table-utils.d.ts +15 -7
  106. package/dist/storage/implementation/v3/source-table-utils.js +49 -57
  107. package/dist/storage/implementation/v3/source-table-utils.js.map +1 -1
  108. package/dist/storage/storage-index.d.ts +2 -1
  109. package/dist/storage/storage-index.js +1 -0
  110. package/dist/storage/storage-index.js.map +1 -1
  111. package/dist/types/types.d.ts +23 -1
  112. package/dist/types/types.js +16 -7
  113. package/dist/types/types.js.map +1 -1
  114. package/dist/utils/util.js +3 -1
  115. package/dist/utils/util.js.map +1 -1
  116. package/package.json +10 -9
  117. package/src/storage/MongoBucketStorage.ts +495 -268
  118. package/src/storage/implementation/MongoBucketBatch.ts +54 -14
  119. package/src/storage/implementation/MongoCompactor.ts +35 -346
  120. package/src/storage/implementation/MongoParameterCompactor.ts +388 -102
  121. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +34 -0
  122. package/src/storage/implementation/MongoPersistedSyncConfigContent.ts +4 -2
  123. package/src/storage/implementation/MongoStorageProvider.ts +3 -1
  124. package/src/storage/implementation/MongoSyncBucketStorage.ts +114 -40
  125. package/src/storage/implementation/MongoSyncRulesLock.ts +22 -9
  126. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +12 -44
  127. package/src/storage/implementation/SyncRuleStateUpdate.ts +13 -6
  128. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +24 -2
  129. package/src/storage/implementation/common/PersistedBatch.ts +54 -2
  130. package/src/storage/implementation/db.ts +21 -2
  131. package/src/storage/implementation/models.ts +38 -0
  132. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +107 -96
  133. package/src/storage/implementation/v1/MongoCompactorV1.ts +303 -19
  134. package/src/storage/implementation/v1/MongoParameterCompactorV1.ts +25 -9
  135. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +87 -27
  136. package/src/storage/implementation/v1/models.ts +1 -0
  137. package/src/storage/implementation/v3/CompactionLease.ts +152 -0
  138. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +159 -42
  139. package/src/storage/implementation/v3/MongoCompactorV3.ts +744 -190
  140. package/src/storage/implementation/v3/MongoParameterCompactorV3.ts +34 -0
  141. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +100 -109
  142. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +111 -41
  143. package/src/storage/implementation/v3/MongoWriteCheckpointAPIV3.ts +111 -0
  144. package/src/storage/implementation/v3/PersistedBatchV3.ts +57 -12
  145. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +55 -4
  146. package/src/storage/implementation/v3/bucket-format.ts +2 -0
  147. package/src/storage/implementation/v3/compact-utils.ts +320 -0
  148. package/src/storage/implementation/v3/compaction-constants.ts +5 -0
  149. package/src/storage/implementation/v3/models.ts +96 -3
  150. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +16 -8
  151. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +20 -5
  152. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +15 -4
  153. package/src/storage/implementation/v3/object-storage/ObjectStorageUsage.ts +230 -0
  154. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +257 -27
  155. package/src/storage/implementation/v3/source-table-utils.ts +82 -76
  156. package/src/storage/storage-index.ts +2 -0
  157. package/src/types/types.ts +24 -7
  158. package/src/utils/util.ts +3 -1
  159. package/test/src/__snapshots__/storage.test.ts.snap +25 -0
  160. package/test/src/__snapshots__/storage_sync.test.ts.snap +605 -16
  161. package/test/src/cleanup-stopped-sync-configs.test.ts +40 -13
  162. package/test/src/compact-utils.test.ts +226 -0
  163. package/test/src/helpers/MemoryObjectStorage.ts +14 -6
  164. package/test/src/object_storage_usage.test.ts +255 -0
  165. package/test/src/parameter_compacting_v1.test.ts +120 -0
  166. package/test/src/parameter_compacting_v3.test.ts +192 -0
  167. package/test/src/parameter_compaction_fence.test.ts +276 -0
  168. package/test/src/storage.test.ts +273 -22
  169. package/test/src/storage_compacting.test.ts +714 -244
  170. package/test/src/storage_s3_checksums.test.ts +3 -4
  171. package/test/src/storage_s3_compaction_lifecycle.test.ts +138 -12
  172. package/test/src/storage_s3_reading.test.ts +243 -1
  173. package/test/src/storage_s3_writing.test.ts +1 -0
  174. package/test/src/storage_sync.test.ts +365 -41
  175. package/test/src/util.ts +1 -1
  176. package/tsconfig.tsbuildinfo +1 -1
@@ -16,6 +16,7 @@ import { MongoPersistedSyncConfigContentV3 } from '../../src/storage/implementat
16
16
  import { BucketDataDoc, BucketKey } from '../../src/storage/implementation/common/BucketDataDoc.js';
17
17
  import { MongoSyncBucketStorage } from '../../src/storage/implementation/createMongoSyncBucketStorage.js';
18
18
  import { getMongoStorageConfig } from '../../src/storage/implementation/models.js';
19
+ import { loadBucketDataDocumentV1, serializeBucketDataV1 } from '../../src/storage/implementation/v1/models.js';
19
20
  import { SourceRecordStoreV3 } from '../../src/storage/implementation/v3/SourceRecordStoreV3.js';
20
21
  import type { VersionedPowerSyncMongoV3 } from '../../src/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
21
22
  import { serializeBucketData } from '../../src/storage/implementation/v3/bucket-format.js';
@@ -114,11 +115,65 @@ function bucketDefinitionId(bucket: string) {
114
115
  return parseInt(match[1], 16);
115
116
  }
116
117
 
118
+ test('V1 bucket data retains an optional persisted subkey', () => {
119
+ const sourceTable = new bson.ObjectId();
120
+ const document = serializeBucketDataV1({
121
+ bucketKey: {
122
+ replicationStreamId: 1,
123
+ definitionId: '0',
124
+ bucket: 'global[]'
125
+ },
126
+ o: 1n,
127
+ op: 'PUT',
128
+ source_table: sourceTable,
129
+ source_key: 'source-key',
130
+ subkey: 'persisted-subkey',
131
+ table: 'items',
132
+ row_id: 'item-1',
133
+ checksum: 1n,
134
+ data: '{"id":"item-1"}'
135
+ });
136
+
137
+ expect(loadBucketDataDocumentV1(document).subkey).toBe('persisted-subkey');
138
+
139
+ const { subkey: _subkey, ...legacyDocument } = document;
140
+ expect(loadBucketDataDocumentV1(legacyDocument).subkey).toBeUndefined();
141
+ });
142
+
117
143
  function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, storageVersion: number) {
118
144
  register.registerSyncTests(storageConfig.factory, {
119
145
  storageVersion,
120
146
  tableIdStrings: storageConfig.tableIdStrings
121
147
  });
148
+
149
+ test('updates source metadata on an existing resolved table', async () => {
150
+ await using factory = await storageConfig.factory();
151
+ const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(MINIMAL_SYNC_RULES, { storageVersion }));
152
+ const bucketStorage = factory.getInstance(syncRules);
153
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
154
+ const source = sourceDescriptor('test');
155
+
156
+ const initial = await writer.resolveTables({ connection_id: 1, source });
157
+ expect(initial.tables[0].sourceMetadata).toBeNull();
158
+
159
+ const sourceMetadata = { captureTableObjectId: 42 };
160
+ const updated = await writer.resolveTables({
161
+ connection_id: 1,
162
+ source,
163
+ reconcileSourceTables: ({ candidates }) => ({
164
+ compatibleTables: candidates.map((candidate) => candidate.withSourceMetadata(sourceMetadata)),
165
+ incompatibleTables: [],
166
+ newTableValues: { sourceMetadata }
167
+ })
168
+ });
169
+
170
+ expect(updated.tables.map((table) => table.id.toString())).toEqual(
171
+ initial.tables.map((table) => table.id.toString())
172
+ );
173
+ expect(updated.tables[0].sourceMetadata).toEqual(sourceMetadata);
174
+ expect((await writer.getSourceTableStatus(updated.tables[0]))?.sourceMetadata).toEqual(sourceMetadata);
175
+ });
176
+
122
177
  // The split of returned results can vary depending on storage drivers
123
178
  test('large batch (2)', async () => {
124
179
  // Test syncing a batch of data that is small in count,
@@ -504,6 +559,7 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
504
559
  const source = sourceDescriptor('memberships', { objectId: 'memberships-relation' });
505
560
  const dataOnlyTableId = new bson.ObjectId('6544e3899293153fa7b38348');
506
561
  const addedParameterTableId = new bson.ObjectId('6544e3899293153fa7b38349');
562
+ const addedEventTableId = new bson.ObjectId('6544e3899293153fa7b3834a');
507
563
 
508
564
  const dataOnly = await writer.resolveTables({
509
565
  connection_id: 1,
@@ -561,15 +617,13 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
561
617
  const eventOnly = await writer.resolveTables({
562
618
  connection_id: 1,
563
619
  source,
564
- idGenerator: () => {
565
- throw new Error('resolve should reuse existing v3 source table');
566
- },
620
+ idGenerator: () => addedEventTableId,
567
621
  parsedSyncConfig: eventOnlyRules
568
622
  });
569
623
 
570
- // Event-only table can re-use any existing table.
624
+ // A newly-added event gets its own source table so that its existing rows are snapshotted.
571
625
  expect(eventOnly.tables).toHaveLength(1);
572
- expect([dataOnlyTableId.toString(), addedParameterTableId.toString()]).toContain(eventOnly.tables[0].id.toString());
626
+ expect(eventOnly.tables[0].id).toEqual(addedEventTableId);
573
627
  expect(eventOnly.dropTables.map((table) => table.id)).toEqual([]);
574
628
  expect(eventOnly.tables[0].bucketDataSources).toHaveLength(0);
575
629
  expect(eventOnly.tables[0].parameterLookupSources).toHaveLength(0);
@@ -578,13 +632,12 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
578
632
  expect(eventOnly.tables[0].syncEvent).toBe(true);
579
633
  });
580
634
 
581
- test.runIf(storageVersion >= 3)(
582
- 'resolveTables designates a single event carrier across split source tables',
583
- async () => {
584
- // When memberships are split over multiple SourceTables for the same ref, a row change
585
- // is saved once per table. Only one table may fire events, otherwise the same event
586
- // would fire once per table for every row change.
587
- const dataOnlyEventYaml = `
635
+ test.runIf(storageVersion >= 3)('resolveTables assigns an event definition to one split source table', async () => {
636
+ // When memberships are split over multiple SourceTables for the same ref, a row change
637
+ // is saved once per table. Each event-definition id must belong to only one of those tables,
638
+ // otherwise that definition would be evaluated more than once for the same row change.
639
+ // Other tables may independently own and evaluate different event definitions.
640
+ const dataOnlyEventYaml = `
588
641
  bucket_definitions:
589
642
  by_owner:
590
643
  parameters:
@@ -597,7 +650,7 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
597
650
  payloads:
598
651
  - SELECT id, owner_id FROM memberships
599
652
  `;
600
- const fullEventYaml = `
653
+ const fullEventYaml = `
601
654
  bucket_definitions:
602
655
  by_owner:
603
656
  parameters:
@@ -611,42 +664,201 @@ function registerSyncStorageTests(storageConfig: storage.TestStorageConfig, stor
611
664
  - SELECT id, owner_id FROM memberships
612
665
  `;
613
666
 
667
+ await using factory = await storageConfig.factory();
668
+ const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(dataOnlyEventYaml, { storageVersion }));
669
+ const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
670
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
671
+ const dataOnlyRules = parsedSyncConfigSetFor(dataOnlyEventYaml, storageVersion);
672
+ const fullRules = parsedSyncConfigSetFor(fullEventYaml, storageVersion);
673
+ const source = sourceDescriptor('memberships', { objectId: 'memberships-relation' });
674
+
675
+ const initial = await writer.resolveTables({
676
+ connection_id: 1,
677
+ source,
678
+ idGenerator: objectIdGenerator('6544e3899293153fa7b3834b'),
679
+ parsedSyncConfig: dataOnlyRules
680
+ });
681
+ expect(initial.tables).toHaveLength(1);
682
+ expect(initial.tables[0].syncEvent).toBe(true);
683
+
684
+ // Adding the table-based parameter lookup creates a second SourceTable for the same ref.
685
+ const split = await writer.resolveTables({
686
+ connection_id: 1,
687
+ source,
688
+ idGenerator: objectIdGenerator('6544e3899293153fa7b3834c'),
689
+ parsedSyncConfig: fullRules
690
+ });
691
+ expect(split.tables).toHaveLength(2);
692
+ // Both tables match the event by ref, but this event id belongs to only one of them.
693
+ const eventOwners = split.tables.filter((table) => table.syncEvent);
694
+ expect(eventOwners).toHaveLength(1);
695
+
696
+ // getSourceTableStatus rehydrates the persisted event memberships rather than recomputing
697
+ // them from the ref, so refreshing the other table does not assign this event to it.
698
+ const nonOwner = split.tables.find((table) => !table.syncEvent)!;
699
+ const refreshed = await writer.getSourceTableStatus(nonOwner);
700
+ expect(refreshed!.syncEvent).toBe(false);
701
+ });
702
+
703
+ test.runIf(storageVersion >= 3)(
704
+ 'reuses an unchanged event definition without resnapshotting or duplicate evaluation',
705
+ async () => {
706
+ const firstYaml = `
707
+ config:
708
+ edition: 3
709
+
710
+ streams:
711
+ by_owner:
712
+ query: SELECT * FROM todos WHERE owner_id = subscription.parameter('owner_id')
713
+
714
+ event_definitions:
715
+ write_checkpoints:
716
+ payloads:
717
+ - SELECT user_id, checkpoint FROM checkpoints WHERE active = true AND checkpoint > 0
718
+ `;
719
+ const secondYaml = `
720
+ config:
721
+ edition: 3
722
+
723
+ streams:
724
+ by_owner:
725
+ query: SELECT * FROM todos WHERE owner_id = subscription.parameter('owner_id')
726
+ by_project:
727
+ query: SELECT * FROM todos WHERE project_id = subscription.parameter('project_id')
728
+
729
+ event_definitions:
730
+ write_checkpoints:
731
+ payloads:
732
+ - select "user_id", "checkpoint" from "checkpoints" where "active" = true and "checkpoint" > 0
733
+ `;
734
+
614
735
  await using factory = await storageConfig.factory();
615
- const syncRules = await factory.updateSyncRules(updateSyncRulesFromYaml(dataOnlyEventYaml, { storageVersion }));
616
- const bucketStorage = factory.getInstance(syncRules) as MongoSyncBucketStorage;
617
- await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
618
- const dataOnlyRules = parsedSyncConfigSetFor(dataOnlyEventYaml, storageVersion);
619
- const fullRules = parsedSyncConfigSetFor(fullEventYaml, storageVersion);
620
- const source = sourceDescriptor('memberships', { objectId: 'memberships-relation' });
736
+ const emittedEvents: { name: string; eventId: string | undefined }[] = [];
737
+ const disposeListener = factory.registerListener({
738
+ replicationEvent: ({ event, event_id }) => emittedEvents.push({ name: event.name, eventId: event_id })
739
+ });
740
+ try {
741
+ const first = await factory.updateSyncRules(updateSyncRulesFromYaml(firstYaml, { storageVersion }));
742
+ const firstStorage = factory.getInstance(first) as MongoSyncBucketStorage;
743
+ await using firstWriter = await firstStorage.createWriter(test_utils.BATCH_OPTIONS);
744
+ const source = sourceDescriptor('checkpoints', { objectId: 'checkpoints-relation' });
745
+ const firstResolved = await firstWriter.resolveTables({
746
+ connection_id: 1,
747
+ source,
748
+ idGenerator: objectIdGenerator('6544e3899293153fa7b38351')
749
+ });
750
+ const eventId = [...firstResolved.tables[0].eventDefinitionIds!][0];
751
+ expect(eventId).toBeDefined();
752
+ await firstWriter.markTableSnapshotDone(firstResolved.tables, '1/1');
753
+ await firstWriter.markAllSnapshotDone('1/1');
754
+ await firstWriter.commit('1/1');
755
+
756
+ const second = await factory.updateSyncRules(updateSyncRulesFromYaml(secondYaml, { storageVersion }));
757
+ expect(second.replicationStreamId).toBe(first.replicationStreamId);
758
+ const secondStorage = factory.getInstance(second) as MongoSyncBucketStorage;
759
+ await using secondWriter = await secondStorage.createWriter(test_utils.BATCH_OPTIONS);
760
+ const resolved = await secondWriter.resolveTables({
761
+ connection_id: 1,
762
+ source,
763
+ idGenerator: () => {
764
+ throw new Error('unchanged event definition should reuse the snapshotted source table');
765
+ }
766
+ });
767
+
768
+ expect(resolved.tables).toHaveLength(1);
769
+ expect(resolved.tables[0].id).toEqual(firstResolved.tables[0].id);
770
+ expect(resolved.tables[0].snapshotComplete).toBe(true);
771
+ expect([...resolved.tables[0].eventDefinitionIds!]).toEqual([eventId]);
772
+
773
+ emittedEvents.length = 0;
774
+ await secondWriter.save({
775
+ sourceTable: resolved.tables[0],
776
+ tag: storage.SaveOperationTag.INSERT,
777
+ after: { id: 'checkpoint-1', user_id: 'user-1', checkpoint: 1n, active: 1 },
778
+ afterReplicaId: test_utils.rid('checkpoint-1')
779
+ });
780
+ expect(emittedEvents).toEqual([{ name: 'write_checkpoints', eventId }]);
781
+ } finally {
782
+ disposeListener();
783
+ }
784
+ }
785
+ );
621
786
 
622
- const initial = await writer.resolveTables({
787
+ test.runIf(storageVersion >= 3)('creates snapshot work for a changed event definition', async () => {
788
+ const yaml = (active: boolean) => `
789
+ config:
790
+ edition: 3
791
+
792
+ streams:
793
+ by_owner:
794
+ query: SELECT * FROM todos WHERE owner_id = subscription.parameter('owner_id')
795
+
796
+ event_definitions:
797
+ write_checkpoints:
798
+ payloads:
799
+ - SELECT user_id, checkpoint FROM checkpoints WHERE active = ${active}
800
+ `;
801
+
802
+ await using factory = await storageConfig.factory();
803
+ const emittedEvents: { name: string; eventId: string | undefined }[] = [];
804
+ const disposeListener = factory.registerListener({
805
+ replicationEvent: ({ event, event_id }) => emittedEvents.push({ name: event.name, eventId: event_id })
806
+ });
807
+ try {
808
+ const first = await factory.updateSyncRules(updateSyncRulesFromYaml(yaml(true), { storageVersion }));
809
+ const firstStorage = factory.getInstance(first) as MongoSyncBucketStorage;
810
+ await using firstWriter = await firstStorage.createWriter(test_utils.BATCH_OPTIONS);
811
+ const source = sourceDescriptor('checkpoints', { objectId: 'checkpoints-relation' });
812
+ const firstResolved = await firstWriter.resolveTables({
623
813
  connection_id: 1,
624
814
  source,
625
- idGenerator: objectIdGenerator('6544e3899293153fa7b3834b'),
626
- parsedSyncConfig: dataOnlyRules
815
+ idGenerator: objectIdGenerator('6544e3899293153fa7b38352')
627
816
  });
628
- expect(initial.tables).toHaveLength(1);
629
- expect(initial.tables[0].syncEvent).toBe(true);
817
+ const oldEventId = [...firstResolved.tables[0].eventDefinitionIds!][0];
818
+ await firstWriter.markTableSnapshotDone(firstResolved.tables, '1/1');
819
+ await firstWriter.markAllSnapshotDone('1/1');
820
+ await firstWriter.commit('1/1');
630
821
 
631
- // Adding the table-based parameter lookup creates a second SourceTable for the same ref.
632
- const split = await writer.resolveTables({
822
+ const second = await factory.updateSyncRules(updateSyncRulesFromYaml(yaml(false), { storageVersion }));
823
+ expect(second.replicationStreamId).toBe(first.replicationStreamId);
824
+ const secondStorage = factory.getInstance(second) as MongoSyncBucketStorage;
825
+ await using secondWriter = await secondStorage.createWriter(test_utils.BATCH_OPTIONS);
826
+ const resolved = await secondWriter.resolveTables({
633
827
  connection_id: 1,
634
828
  source,
635
- idGenerator: objectIdGenerator('6544e3899293153fa7b3834c'),
636
- parsedSyncConfig: fullRules
829
+ idGenerator: objectIdGenerator('6544e3899293153fa7b38353')
637
830
  });
638
- expect(split.tables).toHaveLength(2);
639
- // Both tables match the event by ref, but only one may carry it.
640
- const carriers = split.tables.filter((table) => table.syncEvent);
641
- expect(carriers).toHaveLength(1);
642
-
643
- // getSourceTableStatus preserves the carrier designation rather than recomputing it
644
- // from the ref, so refreshing a non-carrier table does not make it fire events.
645
- const nonCarrier = split.tables.find((table) => !table.syncEvent)!;
646
- const refreshed = await writer.getSourceTableStatus(nonCarrier);
647
- expect(refreshed!.syncEvent).toBe(false);
831
+
832
+ expect(resolved.tables).toHaveLength(2);
833
+ const oldTable = resolved.tables.find((table) => table.eventDefinitionIds?.has(oldEventId))!;
834
+ const newTable = resolved.tables.find((table) => !table.eventDefinitionIds?.has(oldEventId))!;
835
+ const newEventId = [...newTable.eventDefinitionIds!][0];
836
+ expect(newEventId).toBeDefined();
837
+ expect(newEventId).not.toBe(oldEventId);
838
+ expect(oldTable.snapshotComplete).toBe(true);
839
+ expect(newTable.snapshotComplete).toBe(false);
840
+ expect(newTable.bucketDataSources).toEqual([]);
841
+ expect(newTable.parameterLookupSources).toEqual([]);
842
+
843
+ await expect(secondWriter.markSnapshotDone('2/1')).rejects.toThrow(
844
+ 'Cannot mark snapshot done while source tables still require snapshotting'
845
+ );
846
+
847
+ emittedEvents.length = 0;
848
+ await secondWriter.save({
849
+ sourceTable: newTable,
850
+ tag: storage.SaveOperationTag.INSERT,
851
+ after: { id: 'checkpoint-2', user_id: 'user-2', checkpoint: 2n, active: 0 },
852
+ afterReplicaId: test_utils.rid('checkpoint-2')
853
+ });
854
+ expect(emittedEvents).toEqual([{ name: 'write_checkpoints', eventId: newEventId }]);
855
+
856
+ await secondWriter.markTableSnapshotDone([newTable], '2/1');
857
+ await secondWriter.markSnapshotDone('2/1');
858
+ } finally {
859
+ disposeListener();
648
860
  }
649
- );
861
+ });
650
862
 
651
863
  test.runIf(storageVersion >= 3)('uses v3 mongodb model shapes', async () => {
652
864
  await using factory = await storageConfig.factory();
@@ -1130,6 +1342,100 @@ streams:
1130
1342
  }
1131
1343
  );
1132
1344
 
1345
+ test.runIf(storageVersion >= 3)(
1346
+ 'restart creates a replacement stream instead of appending to the active stream',
1347
+ async () => {
1348
+ await using factory = await storageConfig.factory();
1349
+
1350
+ const active = await factory.updateSyncRules(
1351
+ updateSyncRulesFromYaml(
1352
+ `
1353
+ config:
1354
+ edition: 3
1355
+
1356
+ streams:
1357
+ by_owner:
1358
+ query: SELECT * FROM todos WHERE owner_id = subscription.parameter('owner_id')
1359
+ `,
1360
+ { storageVersion }
1361
+ )
1362
+ );
1363
+ const activeStorage = factory.getInstance(active) as MongoSyncBucketStorage;
1364
+ await using activeWriter = await activeStorage.createWriter(test_utils.BATCH_OPTIONS);
1365
+ await activeWriter.markAllSnapshotDone('1/1');
1366
+ await activeWriter.commit('1/1');
1367
+
1368
+ await factory.restartReplication(active.replicationStreamId);
1369
+
1370
+ const replicatingStreams = await factory.getReplicatingReplicationStreams();
1371
+ expect(replicatingStreams).toHaveLength(1);
1372
+ expect(replicatingStreams[0].replicationStreamId).not.toEqual(active.replicationStreamId);
1373
+ expect(replicatingStreams[0].state).toBe(storage.SyncRuleState.PROCESSING);
1374
+
1375
+ const oldStream = (await (factory as MongoBucketStorage).db.sync_rules.findOne({
1376
+ _id: active.replicationStreamId
1377
+ })) as ReplicationStreamDocumentV3;
1378
+ expect(oldStream.state).toBe(storage.SyncRuleState.ERRORED);
1379
+ expect(oldStream.sync_configs.map((config) => config.state)).toEqual([storage.SyncRuleState.ERRORED]);
1380
+ }
1381
+ );
1382
+
1383
+ test.runIf(storageVersion >= 3)('restart errors an active stream with an embedded deploying config', async () => {
1384
+ await using factory = await storageConfig.factory();
1385
+
1386
+ const active = await factory.updateSyncRules(
1387
+ updateSyncRulesFromYaml(
1388
+ `
1389
+ config:
1390
+ edition: 3
1391
+
1392
+ streams:
1393
+ by_owner:
1394
+ query: SELECT * FROM todos WHERE owner_id = subscription.parameter('owner_id')
1395
+ `,
1396
+ { storageVersion }
1397
+ )
1398
+ );
1399
+ const activeStorage = factory.getInstance(active) as MongoSyncBucketStorage;
1400
+ await using activeWriter = await activeStorage.createWriter(test_utils.BATCH_OPTIONS);
1401
+ await activeWriter.markAllSnapshotDone('1/1');
1402
+ await activeWriter.commit('1/1');
1403
+
1404
+ const deploying = await factory.updateSyncRules(
1405
+ updateSyncRulesFromYaml(
1406
+ `
1407
+ config:
1408
+ edition: 3
1409
+
1410
+ streams:
1411
+ by_project:
1412
+ query: SELECT * FROM todos WHERE project_id = subscription.parameter('project_id')
1413
+ `,
1414
+ { storageVersion }
1415
+ )
1416
+ );
1417
+ expect(deploying.replicationStreamId).toEqual(active.replicationStreamId);
1418
+
1419
+ await factory.restartReplication(active.replicationStreamId);
1420
+
1421
+ const replicatingStreams = await factory.getReplicatingReplicationStreams();
1422
+ expect(replicatingStreams).toHaveLength(1);
1423
+ expect(replicatingStreams[0].replicationStreamId).not.toEqual(active.replicationStreamId);
1424
+
1425
+ const oldStream = (await (factory as MongoBucketStorage).db.sync_rules.findOne({
1426
+ _id: active.replicationStreamId
1427
+ })) as ReplicationStreamDocumentV3;
1428
+ expect(oldStream.state).toBe(storage.SyncRuleState.ERRORED);
1429
+ expect(oldStream.sync_configs.map((config) => config.state)).toEqual([
1430
+ storage.SyncRuleState.ERRORED,
1431
+ storage.SyncRuleState.STOP
1432
+ ]);
1433
+ const oldActive = await factory.getActiveSyncConfig();
1434
+ expect(oldActive).not.toBeNull();
1435
+ expect(oldActive!.replicationStream.syncConfigContent).toHaveLength(1);
1436
+ await expect(oldActive!.storage.getCheckpoint()).resolves.toBeDefined();
1437
+ });
1438
+
1133
1439
  test.runIf(storageVersion >= 3)(
1134
1440
  'appended compatible config adopts the stream checkpoint instead of regressing to 0',
1135
1441
  async () => {
@@ -1595,6 +1901,22 @@ streams:
1595
1901
  expect(metricsAfter.replication_size_bytes).toBeGreaterThan(metricsBefore.replication_size_bytes);
1596
1902
  });
1597
1903
 
1904
+ test.runIf(storageVersion >= 3)('storage metrics include the sync config version label', async () => {
1905
+ await using factory = await storageConfig.factory();
1906
+ const syncRules = await factory.updateSyncRules(
1907
+ updateSyncRulesFromYaml(MINIMAL_SYNC_RULES, { storageVersion, version_label: 'metrics-test' })
1908
+ );
1909
+
1910
+ const metrics = await factory.getStorageMetrics();
1911
+
1912
+ expect(metrics.sync_config_metrics).toContainEqual(
1913
+ expect.objectContaining({
1914
+ sync_config_id: syncRules.syncConfigContent[0].syncConfigId,
1915
+ version_label: 'metrics-test'
1916
+ })
1917
+ );
1918
+ });
1919
+
1598
1920
  test.runIf(storageVersion >= 3)(
1599
1921
  'loads parameter checkpoint changes across all v3 parameter index collections',
1600
1922
  async () => {
@@ -1647,7 +1969,7 @@ streams:
1647
1969
  }
1648
1970
  );
1649
1971
 
1650
- test.runIf(storageVersion == 3)('cleans pending deletes only for tracked v3 source tables', async () => {
1972
+ test.runIf(storageVersion >= 3)('cleans pending deletes only for tracked v3 source tables', async () => {
1651
1973
  await using factory = await storageConfig.factory();
1652
1974
  const syncRules = await factory.updateSyncRules(
1653
1975
  updateSyncRulesFromYaml(
@@ -1680,6 +2002,7 @@ streams:
1680
2002
  snapshot_status: undefined,
1681
2003
  bucket_data_source_ids: [],
1682
2004
  parameter_lookup_source_ids: [],
2005
+ event_definition_ids: [],
1683
2006
  latest_pending_delete: 9n
1684
2007
  },
1685
2008
  {
@@ -1693,6 +2016,7 @@ streams:
1693
2016
  snapshot_status: undefined,
1694
2017
  bucket_data_source_ids: [],
1695
2018
  parameter_lookup_source_ids: [],
2019
+ event_definition_ids: [],
1696
2020
  latest_pending_delete: 12n
1697
2021
  }
1698
2022
  ]);
@@ -1781,7 +2105,7 @@ describe('sync - mongodb', () => {
1781
2105
  describe(`storage v${storageVersion}`, () => {
1782
2106
  registerSyncStorageTests(INITIALIZED_MONGO_STORAGE_FACTORY, storageVersion);
1783
2107
 
1784
- describe.runIf(storageVersion == 3)('V3 read filtering boundaries', () => {
2108
+ describe.runIf(storageVersion >= 3)('V3 read filtering boundaries', () => {
1785
2109
  async function setupFilteringTest() {
1786
2110
  await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
1787
2111
  const syncRules = await factory.updateSyncRules(
package/test/src/util.ts CHANGED
@@ -13,4 +13,4 @@ export const INITIALIZED_MONGO_REPORT_STORAGE_FACTORY = mongoTestReportStorageFa
13
13
  isCI: env.CI
14
14
  });
15
15
 
16
- export const TEST_STORAGE_VERSIONS = SUPPORTED_STORAGE_VERSIONS;
16
+ export const TEST_STORAGE_VERSIONS = SUPPORTED_STORAGE_VERSIONS.filter((v) => v != 3);