@powersync/service-module-mongodb-storage 0.18.2 → 0.19.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 (143) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.d.ts +3 -0
  3. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js +41 -0
  4. package/dist/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.js.map +1 -0
  5. package/dist/storage/MongoBucketStorage.d.ts +5 -1
  6. package/dist/storage/MongoBucketStorage.js +5 -1
  7. package/dist/storage/MongoBucketStorage.js.map +1 -1
  8. package/dist/storage/implementation/MongoBucketBatch.d.ts +3 -0
  9. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  10. package/dist/storage/implementation/MongoChecksums.d.ts +2 -0
  11. package/dist/storage/implementation/MongoChecksums.js +3 -2
  12. package/dist/storage/implementation/MongoChecksums.js.map +1 -1
  13. package/dist/storage/implementation/MongoCompactor.d.ts +15 -19
  14. package/dist/storage/implementation/MongoCompactor.js +68 -260
  15. package/dist/storage/implementation/MongoCompactor.js.map +1 -1
  16. package/dist/storage/implementation/MongoStorageProvider.js +26 -3
  17. package/dist/storage/implementation/MongoStorageProvider.js.map +1 -1
  18. package/dist/storage/implementation/MongoSyncBucketStorage.d.ts +12 -5
  19. package/dist/storage/implementation/MongoSyncBucketStorage.js +25 -14
  20. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  21. package/dist/storage/implementation/MongoWriteCheckpointAPI.d.ts +2 -1
  22. package/dist/storage/implementation/MongoWriteCheckpointAPI.js +146 -34
  23. package/dist/storage/implementation/MongoWriteCheckpointAPI.js.map +1 -1
  24. package/dist/storage/implementation/common/MongoSyncBucketStorageCheckpoint.d.ts +2 -0
  25. package/dist/storage/implementation/common/PersistedBatch.d.ts +6 -0
  26. package/dist/storage/implementation/common/PersistedBatch.js +15 -8
  27. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  28. package/dist/storage/implementation/models.d.ts +16 -0
  29. package/dist/storage/implementation/models.js.map +1 -1
  30. package/dist/storage/implementation/v1/MongoCompactorV1.d.ts +11 -2
  31. package/dist/storage/implementation/v1/MongoCompactorV1.js +250 -1
  32. package/dist/storage/implementation/v1/MongoCompactorV1.js.map +1 -1
  33. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js +20 -11
  34. package/dist/storage/implementation/v1/MongoSyncBucketStorageV1.js.map +1 -1
  35. package/dist/storage/implementation/v1/SingleBucketStoreV1.d.ts +9 -10
  36. package/dist/storage/implementation/v1/SingleBucketStoreV1.js +2 -8
  37. package/dist/storage/implementation/v1/SingleBucketStoreV1.js.map +1 -1
  38. package/dist/storage/implementation/v3/BucketDataContextV3.d.ts +13 -0
  39. package/dist/storage/implementation/v3/BucketDataContextV3.js +23 -0
  40. package/dist/storage/implementation/v3/BucketDataContextV3.js.map +1 -0
  41. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +3 -1
  42. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  43. package/dist/storage/implementation/v3/MongoChecksumsV3.d.ts +1 -0
  44. package/dist/storage/implementation/v3/MongoChecksumsV3.js +59 -103
  45. package/dist/storage/implementation/v3/MongoChecksumsV3.js.map +1 -1
  46. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +23 -2
  47. package/dist/storage/implementation/v3/MongoCompactorV3.js +354 -219
  48. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  49. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.d.ts +5 -0
  50. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js +12 -11
  51. package/dist/storage/implementation/v3/MongoStoppedSyncConfigCleanup.js.map +1 -1
  52. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.d.ts +5 -3
  53. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +109 -106
  54. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  55. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +5 -2
  56. package/dist/storage/implementation/v3/PersistedBatchV3.js +53 -15
  57. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  58. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.d.ts +2 -1
  59. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js +3 -0
  60. package/dist/storage/implementation/v3/VersionedPowerSyncMongoV3.js.map +1 -1
  61. package/dist/storage/implementation/v3/bucket-format.d.ts +10 -1
  62. package/dist/storage/implementation/v3/bucket-format.js +28 -9
  63. package/dist/storage/implementation/v3/bucket-format.js.map +1 -1
  64. package/dist/storage/implementation/v3/chunking.js +3 -1
  65. package/dist/storage/implementation/v3/chunking.js.map +1 -1
  66. package/dist/storage/implementation/v3/models.d.ts +38 -1
  67. package/dist/storage/implementation/v3/models.js.map +1 -1
  68. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +21 -0
  69. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +44 -0
  70. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -0
  71. package/dist/storage/implementation/v3/object-storage/ObjectStorage.d.ts +30 -0
  72. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js +12 -0
  73. package/dist/storage/implementation/v3/object-storage/ObjectStorage.js.map +1 -0
  74. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +70 -0
  75. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +146 -0
  76. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -0
  77. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.d.ts +42 -0
  78. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js +285 -0
  79. package/dist/storage/implementation/v3/object-storage/S3ObjectStorage.js.map +1 -0
  80. package/dist/types/types.d.ts +35 -0
  81. package/dist/types/types.js +35 -1
  82. package/dist/types/types.js.map +1 -1
  83. package/dist/utils/test-utils.js +3 -1
  84. package/dist/utils/test-utils.js.map +1 -1
  85. package/dist/utils/util.d.ts +8 -1
  86. package/dist/utils/util.js +125 -2
  87. package/dist/utils/util.js.map +1 -1
  88. package/package.json +10 -7
  89. package/src/migrations/db/migrations/1782950400000-checkpoint-requested-at-index.ts +55 -0
  90. package/src/storage/MongoBucketStorage.ts +10 -2
  91. package/src/storage/implementation/MongoBucketBatch.ts +4 -0
  92. package/src/storage/implementation/MongoChecksums.ts +5 -2
  93. package/src/storage/implementation/MongoCompactor.ts +90 -314
  94. package/src/storage/implementation/MongoStorageProvider.ts +32 -3
  95. package/src/storage/implementation/MongoSyncBucketStorage.ts +34 -24
  96. package/src/storage/implementation/MongoWriteCheckpointAPI.ts +164 -37
  97. package/src/storage/implementation/common/MongoSyncBucketStorageCheckpoint.ts +2 -0
  98. package/src/storage/implementation/common/PersistedBatch.ts +22 -10
  99. package/src/storage/implementation/models.ts +16 -0
  100. package/src/storage/implementation/v1/MongoCompactorV1.ts +292 -8
  101. package/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts +54 -40
  102. package/src/storage/implementation/v1/SingleBucketStoreV1.ts +18 -27
  103. package/src/storage/implementation/v3/BucketDataContextV3.ts +32 -0
  104. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +3 -1
  105. package/src/storage/implementation/v3/MongoChecksumsV3.ts +75 -105
  106. package/src/storage/implementation/v3/MongoCompactorV3.ts +456 -258
  107. package/src/storage/implementation/v3/MongoStoppedSyncConfigCleanup.ts +21 -19
  108. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +123 -126
  109. package/src/storage/implementation/v3/PersistedBatchV3.ts +69 -16
  110. package/src/storage/implementation/v3/VersionedPowerSyncMongoV3.ts +5 -0
  111. package/src/storage/implementation/v3/bucket-format.ts +36 -9
  112. package/src/storage/implementation/v3/chunking.ts +3 -1
  113. package/src/storage/implementation/v3/models.ts +40 -1
  114. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +54 -0
  115. package/src/storage/implementation/v3/object-storage/ObjectStorage.ts +29 -0
  116. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +171 -0
  117. package/src/storage/implementation/v3/object-storage/S3ObjectStorage.ts +343 -0
  118. package/src/types/types.ts +42 -1
  119. package/src/utils/test-utils.ts +3 -1
  120. package/src/utils/util.ts +182 -4
  121. package/test/src/__snapshots__/storage_sync.test.ts.snap +1 -583
  122. package/test/src/cleanup-stopped-sync-configs.test.ts +24 -1
  123. package/test/src/helpers/MemoryObjectStorage.ts +53 -0
  124. package/test/src/helpers/s3TestFactory.ts +69 -0
  125. package/test/src/object_storage_lifecycle.test.ts +59 -0
  126. package/test/src/setup.ts +6 -1
  127. package/test/src/storage.test.ts +216 -2
  128. package/test/src/storage_compacting.test.ts +176 -187
  129. package/test/src/storage_object_storage_inline_threshold.test.ts +116 -0
  130. package/test/src/storage_s3_checksums.test.ts +191 -0
  131. package/test/src/storage_s3_compaction_lifecycle.test.ts +417 -0
  132. package/test/src/storage_s3_reading.test.ts +526 -0
  133. package/test/src/storage_s3_writing.test.ts +135 -0
  134. package/test/src/storage_sync.test.ts +95 -5
  135. package/tsconfig.tsbuildinfo +1 -1
  136. package/dist/storage/implementation/common/SingleBucketStore.d.ts +0 -54
  137. package/dist/storage/implementation/common/SingleBucketStore.js +0 -3
  138. package/dist/storage/implementation/common/SingleBucketStore.js.map +0 -1
  139. package/dist/storage/implementation/v3/SingleBucketStoreV3.d.ts +0 -18
  140. package/dist/storage/implementation/v3/SingleBucketStoreV3.js +0 -84
  141. package/dist/storage/implementation/v3/SingleBucketStoreV3.js.map +0 -1
  142. package/src/storage/implementation/common/SingleBucketStore.ts +0 -63
  143. package/src/storage/implementation/v3/SingleBucketStoreV3.ts +0 -108
@@ -0,0 +1,69 @@
1
+ import { ObjectStorage } from '@module/storage/implementation/v3/object-storage/ObjectStorage.js';
2
+ import { S3ObjectStorage } from '@module/storage/implementation/v3/object-storage/S3ObjectStorage.js';
3
+ import { mongoTestStorageFactoryGenerator } from '@module/utils/test-utils.js';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { MemoryObjectStorage } from './MemoryObjectStorage.js';
6
+
7
+ const minioStorages: S3ObjectStorage[] = [];
8
+
9
+ export interface S3TestFactoryOptions {
10
+ url: string;
11
+ isCI: boolean;
12
+ inlineThresholdBytes?: number;
13
+ }
14
+
15
+ function createTestStorageSuite(options: S3TestFactoryOptions, objectStorage: ObjectStorage) {
16
+ return {
17
+ objectStorage,
18
+ factoryGen: mongoTestStorageFactoryGenerator({
19
+ url: options.url,
20
+ isCI: options.isCI,
21
+ objectStorage,
22
+ inlineThresholdBytes: options.inlineThresholdBytes ?? 0
23
+ })
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Creates an ObjectStorage instance for S3 tests.
29
+ * Set MINIO_ENDPOINT to switch all S3 tests from MemoryObjectStorage
30
+ * to a real MinIO/S3 endpoint.
31
+ * MINIO_ENDPOINT=http://localhost:9000
32
+ */
33
+ export function createS3TestStorageSuite(options: S3TestFactoryOptions) {
34
+ const minioEndpoint = process.env.MINIO_ENDPOINT;
35
+ let objectStorage: ObjectStorage;
36
+ if (minioEndpoint) {
37
+ const s3 = new S3ObjectStorage({
38
+ bucket: 'powersync-s3-test',
39
+ region: 'us-east-1',
40
+ prefix: `test-${process.pid}-${randomUUID()}`,
41
+ endpoint: minioEndpoint,
42
+ forcePathStyle: true,
43
+ accessKeyId: process.env.MINIO_ACCESS_KEY ?? 'minioadmin',
44
+ secretAccessKey: process.env.MINIO_SECRET_KEY ?? 'minioadmin'
45
+ });
46
+ minioStorages.push(s3);
47
+ objectStorage = s3;
48
+ } else {
49
+ objectStorage = new MemoryObjectStorage();
50
+ }
51
+
52
+ return createTestStorageSuite(options, objectStorage);
53
+ }
54
+
55
+ /** Remove all objects created by MinIO-backed suites in the current test. */
56
+ export async function cleanupS3TestStorage(): Promise<void> {
57
+ for (const storage of minioStorages.splice(0)) {
58
+ await storage.deletePrefix('bucket-data/');
59
+ }
60
+ }
61
+
62
+ /** Creates an explicitly memory-backed suite for tests that inspect stored objects. */
63
+ export function createMemoryS3TestStorageSuite(options: S3TestFactoryOptions) {
64
+ const objectStorage = new MemoryObjectStorage();
65
+ return {
66
+ ...createTestStorageSuite(options, objectStorage),
67
+ objectStorage
68
+ };
69
+ }
@@ -0,0 +1,59 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { describe, expect, test } from 'vitest';
3
+ import { ObjectStorageLifecycle } from '../../src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js';
4
+ import { MemoryObjectStorage } from './helpers/MemoryObjectStorage.js';
5
+
6
+ describe('ObjectStorageLifecycle', () => {
7
+ test('escapes bucket names as path segments', () => {
8
+ const lifecycle = new ObjectStorageLifecycle(null as any, 12, null as any);
9
+
10
+ const path = lifecycle.allocatePath('definition', 'bucket/with spaces?and%escapes', 1n, 2n);
11
+
12
+ expect(path).toMatch(/^bucket-data\/12\/definition\/bucket%2Fwith%20spaces%3Fand%25escapes\/1-2-[0-9a-f-]+\.bson$/);
13
+ });
14
+
15
+ test('hashes bucket path segments longer than 128 bytes', () => {
16
+ const lifecycle = new ObjectStorageLifecycle(null as any, 12, null as any);
17
+ const bucket = '💾/long bucket parameter/'.repeat(20);
18
+ const hash = createHash('sha256').update(bucket).digest('base64url');
19
+
20
+ const path = lifecycle.allocatePath('definition', bucket, 1n, 2n);
21
+
22
+ expect(path).toMatch(new RegExp(`^bucket-data/12/definition/sha256-${hash}/1-2-[0-9a-f-]+\\.bson$`));
23
+ });
24
+
25
+ test('deletePrefix removes only objects under the exact prefix', async () => {
26
+ const objectStorage = new MemoryObjectStorage();
27
+ const lifecycle = new ObjectStorageLifecycle(null as any, 1, objectStorage);
28
+ for (const path of [
29
+ 'bucket-data/1/definition/object-1.bson',
30
+ 'bucket-data/12/definition/object-2.bson',
31
+ 'bucket-data/1-other/definition/object-3.bson'
32
+ ]) {
33
+ await objectStorage.put(path, new Uint8Array(), {
34
+ contentType: 'application/bson',
35
+ contentEncoding: null
36
+ });
37
+ }
38
+
39
+ await expect(lifecycle.deletePrefix(lifecycle.streamPrefix())).resolves.toEqual({ objectCount: 1 });
40
+ expect([...objectStorage.store.keys()]).toEqual([
41
+ 'bucket-data/12/definition/object-2.bson',
42
+ 'bucket-data/1-other/definition/object-3.bson'
43
+ ]);
44
+ });
45
+
46
+ test('definition prefixes do not match ids with the same string prefix', async () => {
47
+ const objectStorage = new MemoryObjectStorage();
48
+ const lifecycle = new ObjectStorageLifecycle(null as any, 7, objectStorage);
49
+ for (const path of ['bucket-data/7/1/object-1.bson', 'bucket-data/7/12/object-2.bson']) {
50
+ await objectStorage.put(path, new Uint8Array(), {
51
+ contentType: 'application/bson',
52
+ contentEncoding: null
53
+ });
54
+ }
55
+
56
+ await lifecycle.deletePrefix(lifecycle.definitionPrefix('1'));
57
+ expect([...objectStorage.store.keys()]).toEqual(['bucket-data/7/12/object-2.bson']);
58
+ });
59
+ });
package/test/src/setup.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { container } from '@powersync/lib-services-framework';
2
2
  import { METRICS_HELPER } from '@powersync/service-core-tests';
3
- import { beforeAll, beforeEach } from 'vitest';
3
+ import { afterEach, beforeAll, beforeEach } from 'vitest';
4
+ import { cleanupS3TestStorage } from './helpers/s3TestFactory.js';
4
5
 
5
6
  beforeAll(async () => {
6
7
  // Executes for every test file
@@ -10,3 +11,7 @@ beforeAll(async () => {
10
11
  beforeEach(async () => {
11
12
  METRICS_HELPER.resetMetrics();
12
13
  });
14
+
15
+ afterEach(async () => {
16
+ await cleanupS3TestStorage();
17
+ });
@@ -1,6 +1,8 @@
1
1
  import { mongoTestStorageFactoryGenerator } from '@module/utils/test-utils.js';
2
- import { register } from '@powersync/service-core-tests';
3
- import { describe } from 'vitest';
2
+ import { mongo } from '@powersync/lib-service-mongodb';
3
+ import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
4
+ import { register, test_utils } from '@powersync/service-core-tests';
5
+ import { describe, expect, test } from 'vitest';
4
6
  import { env } from './env.js';
5
7
  import { INITIALIZED_MONGO_STORAGE_FACTORY, TEST_STORAGE_VERSIONS } from './util.js';
6
8
 
@@ -17,6 +19,218 @@ for (let storageVersion of TEST_STORAGE_VERSIONS) {
17
19
 
18
20
  describe(`Mongo Sync Bucket Storage - Checkpoints - v${storageVersion}`, () =>
19
21
  register.registerDataStorageCheckpointTests({ ...INITIALIZED_MONGO_STORAGE_FACTORY, storageVersion }));
22
+
23
+ describe(`Mongo Sync Bucket Storage - write checkpoint metadata - v${storageVersion}`, () => {
24
+ test('uses checkpoint_requested_at as the client-requested checkpoint marker', async () => {
25
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
26
+ const syncRules = await factory.updateSyncRules(
27
+ updateSyncRulesFromYaml(
28
+ `
29
+ bucket_definitions:
30
+ global:
31
+ data: []
32
+ `,
33
+ { storageVersion }
34
+ )
35
+ );
36
+ const bucketStorage = factory.getInstance(syncRules);
37
+
38
+ // user1 has no existing row, so this covers updateMany with upsert.
39
+ // The initial request stores checkpoint id 42 at source head 5/0.
40
+ const requestedResult = await bucketStorage.createManagedWriteCheckpoints([
41
+ { user_id: 'user1', heads: { '1': '5/0' }, checkpoint_request_id: 42n }
42
+ ]);
43
+ expect(requestedResult.writeCheckpoints.get('user1')).toEqual(42n);
44
+ expect(requestedResult.shouldAdvance).toBe(true);
45
+
46
+ const requested = await factory.db.write_checkpoints.findOne({ user_id: 'user1' });
47
+ expect(requested?.client_id).toEqual(42n);
48
+ expect(requested?.lsns).toEqual({ '1': '5/0' });
49
+ expect(requested?.processed_at_lsn).toBeNull();
50
+ expect(requested?.checkpoint_requested_at).toBeInstanceOf(Date);
51
+
52
+ // Request id 41 is lower than the stored id 42, so the entire stale
53
+ // request is ignored, including its newer 6/0 source head.
54
+ await bucketStorage.createManagedWriteCheckpoints([
55
+ { user_id: 'user1', heads: { '1': '6/0' }, checkpoint_request_id: 41n }
56
+ ]);
57
+ const stale = await factory.db.write_checkpoints.findOne({ user_id: 'user1' });
58
+ expect(stale?.checkpoint_requested_at).toEqual(requested?.checkpoint_requested_at);
59
+
60
+ const expiredRequestedAt = new Date('2024-01-01T00:00:00.000Z');
61
+ await factory.db.write_checkpoints.updateOne(
62
+ { user_id: 'user1' },
63
+ { $set: { checkpoint_requested_at: expiredRequestedAt } }
64
+ );
65
+ // Although the previous incoming request was 41, the stored id is still
66
+ // 42. This is therefore an equal-id retry of the original request.
67
+ await bucketStorage.createManagedWriteCheckpoints([
68
+ { user_id: 'user1', heads: { '1': '6/0' }, checkpoint_request_id: 42n }
69
+ ]);
70
+ const retried = await factory.db.write_checkpoints.findOne({ user_id: 'user1' });
71
+ // Retrying the current id refreshes its retention timestamp without
72
+ // replacing the original source head or resetting its processed state.
73
+ expect(retried?.checkpoint_requested_at).toBeInstanceOf(Date);
74
+ expect(retried!.checkpoint_requested_at!.getTime()).toBeGreaterThan(expiredRequestedAt.getTime());
75
+ expect(retried?.lsns).toEqual({ '1': '5/0' });
76
+ expect(retried?.processed_at_lsn).toBeNull();
77
+
78
+ await factory.db.write_checkpoints.updateOne(
79
+ { user_id: 'user1' },
80
+ { $set: { checkpoint_requested_at: expiredRequestedAt } }
81
+ );
82
+ await bucketStorage.createManagedWriteCheckpoints([
83
+ { user_id: 'user1', heads: { '1': '6/0' }, checkpoint_request_id: 43n }
84
+ ]);
85
+ const advanced = await factory.db.write_checkpoints.findOne({ user_id: 'user1' });
86
+ // A greater id refreshes retention and advances the stored checkpoint.
87
+ expect(advanced?.checkpoint_requested_at).toBeInstanceOf(Date);
88
+ expect(advanced!.checkpoint_requested_at!.getTime()).toBeGreaterThan(expiredRequestedAt.getTime());
89
+ expect(advanced?.client_id).toEqual(43n);
90
+ expect(advanced?.lsns).toEqual({ '1': '6/0' });
91
+
92
+ await bucketStorage.createManagedWriteCheckpoints([{ user_id: 'user1', heads: { '1': '7/0' } }]);
93
+ const generated = await factory.db.write_checkpoints.findOne({ user_id: 'user1' });
94
+ // Generated checkpoints unset the field, keeping the document out of the
95
+ // partial checkpoint_requested_at index.
96
+ expect(generated).not.toBeNull();
97
+ expect(generated?.checkpoint_requested_at).toBeUndefined();
98
+
99
+ await bucketStorage.createManagedWriteCheckpoints([
100
+ { user_id: 'user2', heads: { '1': '8/0' }, checkpoint_request_id: 50n }
101
+ ]);
102
+ await factory.db.write_checkpoints.updateOne(
103
+ { user_id: 'user2' },
104
+ { $set: { checkpoint_requested_at: new Date('2024-01-01T00:00:00.000Z') } }
105
+ );
106
+ await bucketStorage.compact({
107
+ compactBuckets: [],
108
+ deleteCheckpointRequestsBefore: new Date('2024-02-01T00:00:00.000Z')
109
+ });
110
+ // The request is expired but still pending, so compaction must retain it.
111
+ await expect(factory.db.write_checkpoints.findOne({ user_id: 'user2' })).resolves.not.toBeNull();
112
+
113
+ // Advancing replication to the request head sets processed_at_lsn.
114
+ await using managedWriter = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
115
+ await managedWriter.markAllSnapshotDone('1/1');
116
+ await managedWriter.keepalive('8/0');
117
+
118
+ await bucketStorage.compact({
119
+ compactBuckets: [],
120
+ deleteCheckpointRequestsBefore: new Date('2024-02-01T00:00:00.000Z')
121
+ });
122
+ // The request is now both expired and processed, so it can be removed.
123
+ await expect(factory.db.write_checkpoints.findOne({ user_id: 'user2' })).resolves.toBeNull();
124
+
125
+ bucketStorage.setWriteCheckpointMode(storage.WriteCheckpointMode.CUSTOM);
126
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
127
+ await writer.markAllSnapshotDone('1/1');
128
+ const customCheckpointRequestedAt = new Date('2024-01-01T00:00:00.000Z');
129
+ writer.addCustomWriteCheckpoint({
130
+ user_id: 'custom1',
131
+ checkpoint: 51n,
132
+ checkpoint_requested_at: customCheckpointRequestedAt
133
+ });
134
+ await writer.flush();
135
+ const customRequested = await factory.db.custom_write_checkpoints.findOne({ user_id: 'custom1' });
136
+ expect(customRequested?.checkpoint_requested_at).toEqual(customCheckpointRequestedAt);
137
+
138
+ writer.addCustomWriteCheckpoint({
139
+ user_id: 'custom1',
140
+ checkpoint: 52n
141
+ });
142
+ await writer.flush();
143
+ const customGenerated = await factory.db.custom_write_checkpoints.findOne({ user_id: 'custom1' });
144
+ expect(customGenerated).not.toBeNull();
145
+ expect(customGenerated?.checkpoint_requested_at).toBeUndefined();
146
+ });
147
+
148
+ /**
149
+ * It's extremely rare (but technically possible) to have duplicate write checkpoint records
150
+ * for a full user_id. This is normally not an issue for checkpoints requested from `write-checkpoint2.json`,
151
+ * but it can be for requests in the `sync/checkpoint-request` flow.
152
+ * That flow seeds the client with the current latest state (if available). We need to ensure that the client
153
+ * does NOT start at a lower bound if there are duplicate records.
154
+ */
155
+ test('updates duplicate managed rows on supplied checkpoint requests', async () => {
156
+ await using factory = await INITIALIZED_MONGO_STORAGE_FACTORY.factory();
157
+ const syncRules = await factory.updateSyncRules(
158
+ updateSyncRulesFromYaml(
159
+ `
160
+ bucket_definitions:
161
+ global:
162
+ data: []
163
+ `,
164
+ { storageVersion }
165
+ )
166
+ );
167
+ const bucketStorage = factory.getInstance(syncRules);
168
+
169
+ // Replicate an extremely rare possibility where there are multiple records
170
+ await factory.db.write_checkpoints.insertMany([
171
+ {
172
+ _id: new mongo.ObjectId('000000000000000000000001'),
173
+ user_id: 'user1',
174
+ client_id: 50n,
175
+ lsns: { '1': '50/0' },
176
+ processed_at_lsn: '50/0'
177
+ },
178
+ {
179
+ _id: new mongo.ObjectId('000000000000000000000002'),
180
+ user_id: 'user1',
181
+ client_id: 42n,
182
+ lsns: { '1': '42/0' },
183
+ processed_at_lsn: null,
184
+ checkpoint_requested_at: new Date('2024-01-01T00:00:00.000Z')
185
+ }
186
+ ]);
187
+
188
+ // Simulate a client without a sequence state trying to seed its state.
189
+ const seeded = await bucketStorage.createManagedWriteCheckpoints([
190
+ { user_id: 'user1', heads: { '1': '1/0' }, checkpoint_request_id: 1n }
191
+ ]);
192
+
193
+ // The result should be the current max state.
194
+ const seededCheckpoint = seeded.writeCheckpoints.get('user1')!;
195
+ expect(seededCheckpoint).toEqual(50n);
196
+ expect(seeded.shouldAdvance).toBe(false);
197
+
198
+ // Simulate a client using the above state for its next checkpoint request.
199
+ const nextCheckpoint = seededCheckpoint + 1n;
200
+ const incremented = await bucketStorage.createManagedWriteCheckpoints([
201
+ { user_id: 'user1', heads: { '1': `${nextCheckpoint}/0` }, checkpoint_request_id: nextCheckpoint }
202
+ ]);
203
+
204
+ // This should be accepted by the service.
205
+ expect(incremented.writeCheckpoints.get('user1')).toEqual(nextCheckpoint);
206
+ expect(incremented.shouldAdvance).toBe(true);
207
+
208
+ // The service should now reconcile all duplicates to the latest state.
209
+ // Duplicates can now also automatically be deleted when they expire.
210
+ const docs = await factory.db.write_checkpoints.find({ user_id: 'user1' }, { sort: { _id: 1 } }).toArray();
211
+ expect(
212
+ docs.map((doc) => ({
213
+ client_id: doc.client_id,
214
+ lsn: doc.lsns['1'],
215
+ processed_at_lsn: doc.processed_at_lsn,
216
+ is_checkpoint_request: doc.checkpoint_requested_at != null
217
+ }))
218
+ ).toEqual([
219
+ {
220
+ client_id: nextCheckpoint,
221
+ lsn: '51/0',
222
+ processed_at_lsn: null,
223
+ is_checkpoint_request: true
224
+ },
225
+ {
226
+ client_id: nextCheckpoint,
227
+ lsn: '51/0',
228
+ processed_at_lsn: null,
229
+ is_checkpoint_request: true
230
+ }
231
+ ]);
232
+ });
233
+ });
20
234
  }
21
235
 
22
236
  describe('Sync Bucket Validation', register.registerBucketValidationTests);