@powersync/service-module-mongodb-storage 0.23.2 → 0.24.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 (99) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +201 -67
  3. package/dist/storage/MongoBucketStorage.d.ts +1 -0
  4. package/dist/storage/MongoBucketStorage.js +3 -0
  5. package/dist/storage/MongoBucketStorage.js.map +1 -1
  6. package/dist/storage/implementation/MongoBucketBatch.d.ts +60 -39
  7. package/dist/storage/implementation/MongoBucketBatch.js +123 -296
  8. package/dist/storage/implementation/MongoBucketBatch.js.map +1 -1
  9. package/dist/storage/implementation/MongoOpIdAllocator.d.ts +6 -1
  10. package/dist/storage/implementation/MongoOpIdAllocator.js +12 -5
  11. package/dist/storage/implementation/MongoOpIdAllocator.js.map +1 -1
  12. package/dist/storage/implementation/MongoParsedSyncConfigSet.d.ts +7 -0
  13. package/dist/storage/implementation/MongoParsedSyncConfigSet.js +28 -2
  14. package/dist/storage/implementation/MongoParsedSyncConfigSet.js.map +1 -1
  15. package/dist/storage/implementation/MongoReplicationOptions.d.ts +27 -0
  16. package/dist/storage/implementation/MongoReplicationOptions.js +26 -0
  17. package/dist/storage/implementation/MongoReplicationOptions.js.map +1 -0
  18. package/dist/storage/implementation/MongoSyncBucketStorage.js +2 -3
  19. package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
  20. package/dist/storage/implementation/MongoSyncRulesLock.d.ts +15 -2
  21. package/dist/storage/implementation/MongoSyncRulesLock.js +36 -14
  22. package/dist/storage/implementation/MongoSyncRulesLock.js.map +1 -1
  23. package/dist/storage/implementation/common/BatchOperationBuffer.d.ts +79 -0
  24. package/dist/storage/implementation/common/BatchOperationBuffer.js +286 -0
  25. package/dist/storage/implementation/common/BatchOperationBuffer.js.map +1 -0
  26. package/dist/storage/implementation/common/PersistedBatch.d.ts +10 -103
  27. package/dist/storage/implementation/common/PersistedBatch.js +1 -291
  28. package/dist/storage/implementation/common/PersistedBatch.js.map +1 -1
  29. package/dist/storage/implementation/common/PreparedPublication.d.ts +43 -0
  30. package/dist/storage/implementation/common/PreparedPublication.js +2 -0
  31. package/dist/storage/implementation/common/PreparedPublication.js.map +1 -0
  32. package/dist/storage/implementation/common/SourceRecordStore.d.ts +0 -2
  33. package/dist/storage/implementation/v1/MongoBucketBatchV1.d.ts +17 -13
  34. package/dist/storage/implementation/v1/MongoBucketBatchV1.js +259 -18
  35. package/dist/storage/implementation/v1/MongoBucketBatchV1.js.map +1 -1
  36. package/dist/storage/implementation/v1/PersistedBatchV1.d.ts +21 -15
  37. package/dist/storage/implementation/v1/PersistedBatchV1.js +58 -24
  38. package/dist/storage/implementation/v1/PersistedBatchV1.js.map +1 -1
  39. package/dist/storage/implementation/v1/SourceRecordStoreV1.d.ts +0 -2
  40. package/dist/storage/implementation/v1/SourceRecordStoreV1.js +0 -3
  41. package/dist/storage/implementation/v1/SourceRecordStoreV1.js.map +1 -1
  42. package/dist/storage/implementation/v3/MongoBucketBatchV3.d.ts +25 -14
  43. package/dist/storage/implementation/v3/MongoBucketBatchV3.js +350 -23
  44. package/dist/storage/implementation/v3/MongoBucketBatchV3.js.map +1 -1
  45. package/dist/storage/implementation/v3/MongoCompactorV3.d.ts +1 -0
  46. package/dist/storage/implementation/v3/MongoCompactorV3.js +10 -4
  47. package/dist/storage/implementation/v3/MongoCompactorV3.js.map +1 -1
  48. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js +39 -28
  49. package/dist/storage/implementation/v3/MongoSyncBucketStorageV3.js.map +1 -1
  50. package/dist/storage/implementation/v3/PersistedBatchV3.d.ts +46 -24
  51. package/dist/storage/implementation/v3/PersistedBatchV3.js +159 -83
  52. package/dist/storage/implementation/v3/PersistedBatchV3.js.map +1 -1
  53. package/dist/storage/implementation/v3/SourceRecordStoreV3.d.ts +12 -0
  54. package/dist/storage/implementation/v3/SourceRecordStoreV3.js +41 -13
  55. package/dist/storage/implementation/v3/SourceRecordStoreV3.js.map +1 -1
  56. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.d.ts +4 -0
  57. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js +4 -0
  58. package/dist/storage/implementation/v3/object-storage/BucketDataObjectStorage.js.map +1 -1
  59. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.d.ts +27 -2
  60. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js +61 -5
  61. package/dist/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js.map +1 -1
  62. package/package.json +9 -10
  63. package/src/storage/MongoBucketStorage.ts +4 -0
  64. package/src/storage/implementation/MongoBucketBatch.ts +185 -392
  65. package/src/storage/implementation/MongoOpIdAllocator.ts +12 -5
  66. package/src/storage/implementation/MongoParsedSyncConfigSet.ts +40 -1
  67. package/src/storage/implementation/MongoReplicationOptions.ts +28 -0
  68. package/src/storage/implementation/MongoSyncBucketStorage.ts +2 -3
  69. package/src/storage/implementation/MongoSyncRulesLock.ts +39 -17
  70. package/src/storage/implementation/common/BatchOperationBuffer.ts +353 -0
  71. package/src/storage/implementation/common/PersistedBatch.ts +12 -390
  72. package/src/storage/implementation/common/PreparedPublication.ts +49 -0
  73. package/src/storage/implementation/common/SourceRecordStore.ts +0 -2
  74. package/src/storage/implementation/v1/MongoBucketBatchV1.ts +339 -27
  75. package/src/storage/implementation/v1/PersistedBatchV1.ts +74 -32
  76. package/src/storage/implementation/v1/SourceRecordStoreV1.ts +0 -5
  77. package/src/storage/implementation/v3/MongoBucketBatchV3.ts +430 -32
  78. package/src/storage/implementation/v3/MongoCompactorV3.ts +13 -4
  79. package/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts +43 -29
  80. package/src/storage/implementation/v3/PersistedBatchV3.ts +209 -108
  81. package/src/storage/implementation/v3/SourceRecordStoreV3.ts +45 -15
  82. package/src/storage/implementation/v3/object-storage/BucketDataObjectStorage.ts +9 -0
  83. package/src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.ts +79 -5
  84. package/test/src/checkpoint_cleanup.test.ts +75 -0
  85. package/test/src/concurrent_writers.test.ts +127 -53
  86. package/test/src/helpers/PublicationCommandMonitor.ts +119 -0
  87. package/test/src/helpers/concurrentWriter.mjs +2 -2
  88. package/test/src/invalid_sync_config.test.ts +9 -0
  89. package/test/src/object_storage_lifecycle.test.ts +72 -2
  90. package/test/src/prepared_publication.test.ts +388 -0
  91. package/test/src/replication_preparation.test.ts +829 -0
  92. package/test/src/replication_publication_baseline.test.ts +203 -0
  93. package/test/src/replication_publication_batching.test.ts +212 -0
  94. package/test/src/replication_uploads.test.ts +393 -0
  95. package/test/src/source_table_config.test.ts +4 -4
  96. package/test/src/storage_object_storage_inline_threshold.test.ts +1 -1
  97. package/test/src/storage_s3_compaction_lifecycle.test.ts +93 -1
  98. package/test/src/storage_sync.test.ts +83 -2
  99. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,393 @@
1
+ import { mongoTestStorageFactoryGenerator } from '@module/utils/test-utils.js';
2
+ import { mongo } from '@powersync/lib-service-mongodb';
3
+ import { logger } from '@powersync/lib-services-framework';
4
+ import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
5
+ import { getTestStorage, test_utils } from '@powersync/service-core-tests';
6
+ import * as bson from 'bson';
7
+ import { describe, expect, test, vi } from 'vitest';
8
+ import { MongoWriteBatch } from '../../src/storage/implementation/MongoWriteBatch.js';
9
+ import { MongoBucketBatchV3 } from '../../src/storage/implementation/v3/MongoBucketBatchV3.js';
10
+ import { MongoSyncBucketStorageV3 } from '../../src/storage/implementation/v3/MongoSyncBucketStorageV3.js';
11
+ import {
12
+ OBJECT_STORAGE_PUBLICATION_SAFETY_MARGIN_MS,
13
+ ObjectStorageLifecycle
14
+ } from '../../src/storage/implementation/v3/object-storage/ObjectStorageLifecycle.js';
15
+ import { ObjectStorageUsage } from '../../src/storage/implementation/v3/object-storage/ObjectStorageUsage.js';
16
+ import { PersistedBatchV3 } from '../../src/storage/implementation/v3/PersistedBatchV3.js';
17
+ import { env } from './env.js';
18
+ import { MemoryObjectStorage } from './helpers/MemoryObjectStorage.js';
19
+ import { PublicationCommandMonitor } from './helpers/PublicationCommandMonitor.js';
20
+
21
+ const CONFIG = `
22
+ config:
23
+ edition: 3
24
+ streams:
25
+ items:
26
+ query: SELECT * FROM items
27
+ `;
28
+
29
+ async function open(version: number, options: Partial<storage.CreateWriterOptions> = {}) {
30
+ const objectStorage = new MemoryObjectStorage();
31
+ const factoryGen = mongoTestStorageFactoryGenerator({
32
+ url: env.MONGO_TEST_URL,
33
+ isCI: env.CI,
34
+ monitorCommands: true,
35
+ objectStorage,
36
+ inlineThresholdBytes: 0
37
+ });
38
+ const factory = await factoryGen.factory();
39
+ const stream = await factory.updateSyncRules(updateSyncRulesFromYaml(CONFIG, { storageVersion: version }));
40
+ const bucketStorage = (await getTestStorage(factory, stream)) as MongoSyncBucketStorageV3;
41
+ const writer = (await bucketStorage.createWriter({ ...test_utils.BATCH_OPTIONS, ...options })) as MongoBucketBatchV3;
42
+ const table = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 1);
43
+ await writer.markAllSnapshotDone('1/1');
44
+ const db = bucketStorage.db;
45
+ const markers = db.pendingObjectStorageDeletes(stream.replicationStreamId);
46
+ // The generic test factory clears stream data but retains old outbox
47
+ // collections; reset this test's outbox as well when stream IDs are reused.
48
+ await markers.deleteMany({});
49
+ const lifecycle = new ObjectStorageLifecycle(db, stream.replicationStreamId, objectStorage);
50
+ const data = db.bucketData(
51
+ stream.replicationStreamId,
52
+ stream.syncConfigContent[0].mapping.allBucketDefinitionIds()[0]
53
+ );
54
+ const records = db.sourceRecords(stream.replicationStreamId, table.id as bson.ObjectId);
55
+ await writer.save({
56
+ sourceTable: table,
57
+ tag: storage.SaveOperationTag.INSERT,
58
+ after: { id: 'A', description: 'offloaded' },
59
+ afterReplicaId: test_utils.rid('A')
60
+ });
61
+ return { factory, stream, bucketStorage, writer, table, db, objectStorage, markers, lifecycle, data, records };
62
+ }
63
+
64
+ describe.each([3, 4])('replication uploads v%s', (version) => {
65
+ test('registers once with majority acknowledgement before PUT and reuses uploads on transaction retry', async () => {
66
+ const context = await open(version);
67
+ await using factory = context.factory;
68
+ await using writer = context.writer;
69
+ const { markers, objectStorage, db } = context;
70
+ using monitor = new PublicationCommandMonitor(factory.db.client);
71
+ using registered = vi.spyOn(ObjectStorageLifecycle.prototype, 'prepareUploads');
72
+ const put = objectStorage.put.bind(objectStorage);
73
+ using uploading = vi.spyOn(objectStorage, 'put').mockImplementation(async (...args) => {
74
+ expect(monitor.activeReplicationAttempts).toHaveLength(0);
75
+ expect(await markers.findOne({ path: args[0] })).toMatchObject({ path: args[0], delete_after: expect.any(Date) });
76
+ await put(...args);
77
+ });
78
+ const registrations: { majority: boolean; transactional: boolean }[] = [];
79
+ const command = (event: mongo.CommandStartedEvent) => {
80
+ if (event.commandName === 'insert' && event.command.insert === markers.collectionName) {
81
+ registrations.push({
82
+ majority: event.command.writeConcern?.w === 'majority',
83
+ transactional: event.command.autocommit === false
84
+ });
85
+ }
86
+ };
87
+ db.client.on('commandStarted', command);
88
+ using _listener = { [Symbol.dispose]: () => db.client.off('commandStarted', command) };
89
+ const publish = PersistedBatchV3.prototype.publish;
90
+ using publishing = vi.spyOn(PersistedBatchV3.prototype, 'publish').mockImplementationOnce(async function (
91
+ this: PersistedBatchV3,
92
+ ...args
93
+ ) {
94
+ await publish.apply(this, args);
95
+ throw new mongo.MongoServerError({
96
+ code: 112,
97
+ message: 'publication conflict',
98
+ errorLabels: ['TransientTransactionError']
99
+ });
100
+ });
101
+ await writer.commit('1/2');
102
+ expect(publishing).toHaveBeenCalledTimes(2);
103
+ expect(uploading).toHaveBeenCalledTimes(1);
104
+ expect(registered).toHaveBeenCalledTimes(1);
105
+ expect(registrations).toEqual([{ majority: true, transactional: false }]);
106
+ expect(await markers.countDocuments()).toBe(0);
107
+ expect(await context.data.countDocuments()).toBe(1);
108
+ const [usage] = await ObjectStorageUsage.readAllDefinitionUsage(db);
109
+ expect(usage.active_bytes).toBe(BigInt([...objectStorage.store.values()][0].data.byteLength));
110
+ // The first commit also activates the config in its own fenced transaction.
111
+ expect(monitor.replicationAttempts.map((attempt) => attempt.outcome)).toEqual([
112
+ 'aborted',
113
+ 'committed',
114
+ 'committed'
115
+ ]);
116
+ });
117
+
118
+ test('does not PUT after ambiguous marker registration', async () => {
119
+ const context = await open(version);
120
+ await using _factory = context.factory;
121
+ await using writer = context.writer;
122
+ const prepare = ObjectStorageLifecycle.prototype.prepareUploads;
123
+ const failure = new Error('lost marker registration response');
124
+ using _registration = vi
125
+ .spyOn(ObjectStorageLifecycle.prototype, 'prepareUploads')
126
+ .mockImplementationOnce(async function (this: ObjectStorageLifecycle, ...args) {
127
+ await prepare.apply(this, args);
128
+ throw failure;
129
+ });
130
+ using put = vi.spyOn(context.objectStorage, 'put');
131
+ await expect(writer.commit('1/2')).rejects.toThrow(failure);
132
+ expect(put).not.toHaveBeenCalled();
133
+ expect(await context.markers.countDocuments()).toBe(1);
134
+ expect(await context.records.countDocuments()).toBe(0);
135
+ expect((await context.bucketStorage.getCheckpoint()).checkpoint).toBe(0n);
136
+ // A later explicit retry reevaluates the retained input using a fresh path.
137
+ await writer.commit('1/2');
138
+ expect(put).toHaveBeenCalledTimes(1);
139
+ expect(await context.markers.countDocuments()).toBe(1);
140
+ expect(await context.records.countDocuments()).toBe(1);
141
+ });
142
+
143
+ test('lost PUT responses retain orphan markers, and cleanup retries deletion failures', async () => {
144
+ const context = await open(version);
145
+ await using _factory = context.factory;
146
+ await using writer = context.writer;
147
+ const put = context.objectStorage.put.bind(context.objectStorage);
148
+ using _put = vi.spyOn(context.objectStorage, 'put').mockImplementationOnce(async (...args) => {
149
+ await put(...args);
150
+ throw new Error('lost PUT response');
151
+ });
152
+ await expect(writer.commit('1/2')).rejects.toThrow('lost PUT response');
153
+ expect(context.objectStorage.store.size).toBe(1);
154
+ expect(await context.markers.countDocuments()).toBe(1);
155
+ expect(await context.data.countDocuments()).toBe(0);
156
+ expect(await ObjectStorageUsage.readAllDefinitionUsage(context.db)).toEqual([]);
157
+ await context.lifecycle.cleanup(logger);
158
+ expect(context.objectStorage.store.size).toBe(1);
159
+ await context.markers.updateMany({}, { $set: { delete_after: new Date(0) } });
160
+ using deleting = vi.spyOn(context.objectStorage, 'delete').mockRejectedValueOnce(new Error('DELETE failed'));
161
+ await context.lifecycle.cleanup(logger);
162
+ expect(await context.markers.countDocuments()).toBe(1);
163
+ expect(context.objectStorage.store.size).toBe(1);
164
+ await context.lifecycle.cleanup(logger);
165
+ expect(deleting).toHaveBeenCalledTimes(2);
166
+ expect(await context.markers.countDocuments()).toBe(0);
167
+ expect(context.objectStorage.store.size).toBe(0);
168
+ });
169
+
170
+ test('rejects an uploaded generation whose assigned ID does not advance the persisted head', async () => {
171
+ const context = await open(version);
172
+ await using _factory = context.factory;
173
+ await using writer = context.writer;
174
+ const put = context.objectStorage.put.bind(context.objectStorage);
175
+ using uploads = vi.spyOn(context.objectStorage, 'put').mockImplementationOnce(async (...args) => {
176
+ await put(...args);
177
+ await context.db.sync_rules.updateOne(
178
+ { _id: context.stream.replicationStreamId },
179
+ { $set: { last_persisted_op: 10n } },
180
+ { writeConcern: { w: 'majority' } }
181
+ );
182
+ });
183
+ await expect(writer.commit('1/2')).rejects.toThrow('does not advance persisted stream head');
184
+ expect(uploads).toHaveBeenCalledTimes(1);
185
+ const [abandoned] = uploads.mock.calls.map(([path]) => path);
186
+ expect(await context.markers.find({}).toArray()).toMatchObject([{ path: abandoned }]);
187
+ expect(await context.data.find({}).toArray()).toEqual([]);
188
+ expect(writer.last_flushed_op).toBeNull();
189
+ expect((await context.bucketStorage.getCheckpoint()).checkpoint).toBe(0n);
190
+ expect(await context.db.sync_rules.findOne({ _id: context.stream.replicationStreamId })).toMatchObject({
191
+ last_persisted_op: 10n
192
+ });
193
+ await context.markers.updateMany({}, { $set: { delete_after: new Date(0) } });
194
+ await context.lifecycle.cleanup(logger);
195
+ expect(context.objectStorage.store.size).toBe(0);
196
+ expect(await context.markers.countDocuments()).toBe(0);
197
+ expect(await ObjectStorageUsage.readAllDefinitionUsage(context.db)).toEqual([]);
198
+ });
199
+
200
+ test('stopped-stream termination clears abandoned replication objects and their markers', async () => {
201
+ const context = await open(version);
202
+ await using factory = context.factory;
203
+ await using writer = context.writer;
204
+ const put = context.objectStorage.put.bind(context.objectStorage);
205
+ using _put = vi.spyOn(context.objectStorage, 'put').mockImplementationOnce(async (...args) => {
206
+ await put(...args);
207
+ throw new Error('lost PUT response');
208
+ });
209
+ await expect(writer.flush()).rejects.toThrow('lost PUT response');
210
+ expect(context.objectStorage.store.size).toBe(1);
211
+ await factory.db.sync_rules.updateOne(
212
+ { _id: context.stream.replicationStreamId },
213
+ { $set: { state: storage.SyncRuleState.STOP, 'sync_configs.$[].state': storage.SyncRuleState.STOP } }
214
+ );
215
+ // Supported termination runs after the replication job has stopped/drained
216
+ // and released its lease. It removes the entire prefix, including orphans.
217
+ await test_utils.releaseTestStorageLease(factory, context.stream.replicationStreamId);
218
+ await context.bucketStorage.terminate();
219
+ expect(context.objectStorage.store.size).toBe(0);
220
+ expect(await context.markers.countDocuments()).toBe(0);
221
+ expect(await factory.db.sync_rules.findOne({ _id: context.stream.replicationStreamId })).toMatchObject({
222
+ state: storage.SyncRuleState.TERMINATED
223
+ });
224
+ });
225
+
226
+ test.each([false, true])(
227
+ 'missing markers roll back references, usage and progress and stop the lease (fallback: %s)',
228
+ async (fallback) => {
229
+ const context = await open(version);
230
+ await using factory = context.factory;
231
+ await using writer = context.writer;
232
+ const createWriteBatch = factory.db.createWriteBatch.bind(factory.db);
233
+ using _bulk = vi
234
+ .spyOn(factory.db, 'createWriteBatch')
235
+ .mockImplementation((session, options) =>
236
+ fallback
237
+ ? new MongoWriteBatch(factory.db.client, false, session, options)
238
+ : createWriteBatch(session, options)
239
+ );
240
+ const put = context.objectStorage.put.bind(context.objectStorage);
241
+ using _put = vi.spyOn(context.objectStorage, 'put').mockImplementationOnce(async (...args) => {
242
+ await put(...args);
243
+ await context.markers.deleteMany({});
244
+ });
245
+ await expect(writer.commit('1/2')).rejects.toThrow('Missing object storage publication markers');
246
+ expect(await context.data.countDocuments()).toBe(0);
247
+ expect(await context.records.countDocuments()).toBe(0);
248
+ expect(await ObjectStorageUsage.readAllDefinitionUsage(context.db)).toEqual([]);
249
+ expect(writer.last_flushed_op).toBeNull();
250
+ expect((await context.bucketStorage.getCheckpoint()).checkpoint).toBe(0n);
251
+ await expect(writer.commit('1/2')).rejects.toThrow('restart replication from persisted progress');
252
+ expect(await context.data.countDocuments()).toBe(0);
253
+ }
254
+ );
255
+
256
+ test('rechecks expiry on a retry and leaves the generation for cleanup', async () => {
257
+ const context = await open(version);
258
+ await using _factory = context.factory;
259
+ await using writer = context.writer;
260
+ using registered = vi.spyOn(ObjectStorageLifecycle.prototype, 'prepareUploads');
261
+ using put = vi.spyOn(context.objectStorage, 'put');
262
+ const publish = PersistedBatchV3.prototype.publish;
263
+ let attempts = 0;
264
+ using _publish = vi.spyOn(PersistedBatchV3.prototype, 'publish').mockImplementation(async function (
265
+ this: PersistedBatchV3,
266
+ ...args
267
+ ) {
268
+ attempts++;
269
+ if (attempts === 2) {
270
+ const uploads = await registered.mock.results[0].value;
271
+ uploads[0].deleteAfter = new Date(Date.now() + OBJECT_STORAGE_PUBLICATION_SAFETY_MARGIN_MS);
272
+ }
273
+ await publish.apply(this, args);
274
+ throw new mongo.MongoServerError({
275
+ code: 112,
276
+ message: 'publication conflict',
277
+ errorLabels: ['TransientTransactionError']
278
+ });
279
+ });
280
+ await expect(writer.commit('1/2')).rejects.toThrow('publication lease expired');
281
+ expect(attempts).toBe(2);
282
+ expect(put).toHaveBeenCalledTimes(1);
283
+ expect(registered).toHaveBeenCalledTimes(1);
284
+ expect(await context.markers.countDocuments()).toBe(1);
285
+ expect(await context.data.countDocuments()).toBe(0);
286
+ expect(await ObjectStorageUsage.readAllDefinitionUsage(context.db)).toEqual([]);
287
+ });
288
+
289
+ test.each([false, true])(
290
+ 'terminal commit uncertainty preserves the correct marker state (committed: %s)',
291
+ async (committed) => {
292
+ const context = await open(version);
293
+ await using _factory = context.factory;
294
+ await using writer = context.writer;
295
+ await using other = await context.bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
296
+ const failure = new mongo.MongoServerError({
297
+ message: 'commit outcome unknown',
298
+ errorLabels: ['UnknownTransactionCommitResult']
299
+ });
300
+ const session = writer.session;
301
+ const transact = session.withTransaction.bind(session);
302
+ using _transaction = vi.spyOn(session, 'withTransaction').mockImplementationOnce(async (...args) => {
303
+ if (committed) {
304
+ await transact(...args);
305
+ throw failure;
306
+ }
307
+ return transact(async (...callbackArgs) => {
308
+ await args[0](...callbackArgs);
309
+ throw failure;
310
+ }, args[1]);
311
+ });
312
+ await expect(writer.commit('1/2')).rejects.toThrow(failure);
313
+ expect(await context.markers.countDocuments()).toBe(committed ? 0 : 1);
314
+ expect(await context.data.countDocuments()).toBe(committed ? 1 : 0);
315
+ expect(writer.last_flushed_op).toBeNull();
316
+ await expect(other.setResumeLsn('1/3')).rejects.toThrow('restart replication');
317
+ await context.markers.updateMany({}, { $set: { delete_after: new Date(0) } });
318
+ await context.lifecycle.cleanup(logger);
319
+ expect(context.objectStorage.store.size).toBe(committed ? 1 : 0);
320
+ }
321
+ );
322
+
323
+ test('truncate PUTs complete before its transaction and publish their markers atomically', async () => {
324
+ const context = await open(version);
325
+ await using factory = context.factory;
326
+ await using writer = context.writer;
327
+ await writer.commit('1/2');
328
+ using monitor = new PublicationCommandMonitor(factory.db.client);
329
+ const put = context.objectStorage.put.bind(context.objectStorage);
330
+ using uploading = vi.spyOn(context.objectStorage, 'put').mockImplementation(async (...args) => {
331
+ expect(monitor.activeReplicationAttempts).toHaveLength(0);
332
+ expect(await context.markers.countDocuments({ path: args[0] })).toBe(1);
333
+ await put(...args);
334
+ });
335
+ await writer.truncate([context.table]);
336
+ expect(uploading).toHaveBeenCalledTimes(1);
337
+ expect(await context.markers.countDocuments()).toBe(0);
338
+ expect(await context.records.countDocuments()).toBe(0);
339
+ expect(await context.data.countDocuments()).toBe(2);
340
+ });
341
+
342
+ test('cancels queued PUTs and drains active requests before returning', async () => {
343
+ const controller = new AbortController();
344
+ const context = await open(version, { signal: controller.signal });
345
+ await using _factory = context.factory;
346
+ await using writer = context.writer;
347
+ // Enough data for separate chunks, within a single bounded publication.
348
+ await writer.save({
349
+ sourceTable: context.table,
350
+ tag: storage.SaveOperationTag.INSERT,
351
+ after: { id: 'B', description: 'x'.repeat(1_100_000) },
352
+ afterReplicaId: test_utils.rid('B')
353
+ });
354
+ const active = Promise.withResolvers<void>();
355
+ const release = Promise.withResolvers<void>();
356
+ const queued = Promise.withResolvers<void>();
357
+ let remoteCompletions = 0;
358
+ using put = vi
359
+ .spyOn(context.objectStorage, 'put')
360
+ .mockImplementationOnce(async (path, data, metadata) => {
361
+ active.resolve();
362
+ await release.promise;
363
+ // An accepted request can complete after local cancellation. Its marker
364
+ // remains protected for an hour; cancellation must not delete it early.
365
+ context.objectStorage.store.set(path, { data, metadata });
366
+ remoteCompletions++;
367
+ })
368
+ .mockImplementationOnce(async (_path, _data, _metadata, options) => {
369
+ queued.resolve();
370
+ await active.promise;
371
+ if (!options?.signal?.aborted) {
372
+ await new Promise<void>((resolve) =>
373
+ options!.signal!.addEventListener('abort', () => resolve(), { once: true })
374
+ );
375
+ }
376
+ options?.signal?.throwIfAborted();
377
+ });
378
+ const failed = expect(writer.commit('1/2')).rejects.toThrow('cancel uploads');
379
+ await queued.promise;
380
+ controller.abort(new Error('cancel uploads'));
381
+ expect(remoteCompletions).toBe(0);
382
+ release.resolve();
383
+ await failed;
384
+ expect(remoteCompletions).toBe(1);
385
+ expect(put).toHaveBeenCalledTimes(2);
386
+ expect(await context.markers.countDocuments()).toBe(2);
387
+ await context.markers.updateMany({}, { $set: { delete_after: new Date(0) } });
388
+ await context.lifecycle.cleanup(logger);
389
+ expect(context.objectStorage.store.size).toBe(0);
390
+ expect(await context.markers.countDocuments()).toBe(0);
391
+ expect(await context.records.countDocuments()).toBe(0);
392
+ });
393
+ });
@@ -1,7 +1,7 @@
1
1
  import { mongoTestStorageFactoryGenerator } from '@module/utils/test-utils.js';
2
2
  import { updateSyncRulesFromConfig } from '@powersync/service-core';
3
3
  import { test_utils } from '@powersync/service-core-tests';
4
- import { PrecompiledSyncConfig, SqlSyncRules } from '@powersync/service-sync-rules';
4
+ import { PrecompiledSyncConfig, parseSyncConfig } from '@powersync/service-sync-rules';
5
5
  import { describe, expect, test } from 'vitest';
6
6
  import { env } from './env.js';
7
7
  import { TEST_STORAGE_VERSIONS } from './util.js';
@@ -39,7 +39,7 @@ describe.for(TEST_STORAGE_VERSIONS)('source-table config with storage v%i', (sto
39
39
  let expected;
40
40
  {
41
41
  await using factory = await storageFactory().factory();
42
- const parsed = SqlSyncRules.fromYaml(CONFIGURED, test_utils.PARSE_OPTIONS);
42
+ const parsed = parseSyncConfig(CONFIGURED, test_utils.PARSE_OPTIONS);
43
43
  expected = (parsed.config as PrecompiledSyncConfig).plan.sourceTableConfig;
44
44
  await factory.updateSyncRules(updateSyncRulesFromConfig(parsed, { storageVersion }));
45
45
  }
@@ -56,7 +56,7 @@ describe.for(TEST_STORAGE_VERSIONS)('source-table config with storage v%i', (sto
56
56
  test('continues loading unfiltered plans without source options', async () => {
57
57
  await using factory = await storageFactory().factory();
58
58
  await factory.updateSyncRules(
59
- updateSyncRulesFromConfig(SqlSyncRules.fromYaml(STREAMS, test_utils.PARSE_OPTIONS), {
59
+ updateSyncRulesFromConfig(parseSyncConfig(STREAMS, test_utils.PARSE_OPTIONS), {
60
60
  storageVersion
61
61
  })
62
62
  );
@@ -69,7 +69,7 @@ describe.for(TEST_STORAGE_VERSIONS)('source-table config with storage v%i', (sto
69
69
  await using factory = await storageFactory().factory();
70
70
  const deploy = (yaml: string) =>
71
71
  factory.updateSyncRules(
72
- updateSyncRulesFromConfig(SqlSyncRules.fromYaml(yaml, test_utils.PARSE_OPTIONS), {
72
+ updateSyncRulesFromConfig(parseSyncConfig(yaml, test_utils.PARSE_OPTIONS), {
73
73
  storageVersion
74
74
  })
75
75
  );
@@ -2,8 +2,8 @@ import { mongoTestStorageFactoryGenerator } from '@module/utils/test-utils.js';
2
2
  import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
3
3
  import { bucketRequest, getTestStorage, test_utils } from '@powersync/service-core-tests';
4
4
  import { describe, expect, test } from 'vitest';
5
- import { DEFAULT_INLINE_THRESHOLD_BYTES } from '../../src/storage/implementation/common/PersistedBatch.js';
6
5
  import { MongoSyncBucketStorage } from '../../src/storage/implementation/createMongoSyncBucketStorage.js';
6
+ import { DEFAULT_INLINE_THRESHOLD_BYTES } from '../../src/storage/implementation/v3/PersistedBatchV3.js';
7
7
  import { VersionedPowerSyncMongoV3 } from '../../src/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
8
8
  import { env } from './env.js';
9
9
  import { MemoryObjectStorage } from './helpers/MemoryObjectStorage.js';
@@ -1,7 +1,7 @@
1
1
  import * as lib_mongo from '@powersync/lib-service-mongodb';
2
2
  import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
3
3
  import { bucketRequest, compactActive, getTestStorage, test_utils } from '@powersync/service-core-tests';
4
- import { describe, expect, test } from 'vitest';
4
+ import { describe, expect, test, vi } from 'vitest';
5
5
  import { MongoSyncBucketStorage } from '../../src/storage/implementation/createMongoSyncBucketStorage.js';
6
6
  import { MongoSyncBucketStorageV3 } from '../../src/storage/implementation/v3/MongoSyncBucketStorageV3.js';
7
7
  import { VersionedPowerSyncMongoV3 } from '../../src/storage/implementation/v3/VersionedPowerSyncMongoV3.js';
@@ -53,6 +53,98 @@ async function expectUsageMatchesBucketData(bucketStorage: MongoSyncBucketStorag
53
53
  }
54
54
 
55
55
  describe('S3 compaction storage lifecycle', () => {
56
+ test.each(
57
+ [false, true].flatMap((compactChunksOnly) =>
58
+ [
59
+ ...[-1, 0, 1].map((thresholdOffset) => ({ thresholdOffset, initiallyInline: true })),
60
+ { thresholdOffset: -1, initiallyInline: false }
61
+ ].map((placement) => ({ compactChunksOnly, ...placement }))
62
+ )
63
+ )(
64
+ 'unchanged chunk placement: V4, chunks only=$compactChunksOnly, inline=$initiallyInline, threshold offset=$thresholdOffset',
65
+ async ({ compactChunksOnly, thresholdOffset, initiallyInline }) => {
66
+ const { memoryStorage, factory: factoryGen } = memoryS3Factory({
67
+ inlineThresholdBytes: initiallyInline ? 10_000 : 0
68
+ });
69
+ await using factory = await factoryGen.factory();
70
+ const syncRules = await factory.updateSyncRules(
71
+ updateSyncRulesFromYaml(
72
+ `
73
+ config:
74
+ edition: 3
75
+ streams:
76
+ global:
77
+ query: SELECT id, description FROM items
78
+ `,
79
+ { storageVersion: 4 }
80
+ )
81
+ );
82
+ const bucketStorage = (await getTestStorage(factory, syncRules)) as MongoSyncBucketStorageV3;
83
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
84
+ const sourceTable = await test_utils.resolveTestTable(writer, 'items', ['id'], factoryGen, 1);
85
+ await writer.markAllSnapshotDone('1/1');
86
+ await writer.save({
87
+ sourceTable,
88
+ tag: storage.SaveOperationTag.INSERT,
89
+ after: { id: 'A', description: 'unchanged' },
90
+ afterReplicaId: test_utils.rid('A')
91
+ });
92
+ await writer.commit('1/1');
93
+
94
+ const definitionId = syncRules.syncConfigContent[0].mapping.allBucketDefinitionIds()[0];
95
+ const db = bucketStorage.db;
96
+ const collection = db.bucketData(bucketStorage.replicationStreamId, definitionId);
97
+ const [before] = await collection.find({}).toArray();
98
+ expect(before.ops != null).toBe(initiallyInline);
99
+ expect(before.storage_ref != null).toBe(!initiallyInline);
100
+ const checkpoint = await bucketStorage.getCheckpoint();
101
+ const request = bucketRequest(syncRules.syncConfigContent[0], 'global|0[]', 0n);
102
+ const dataBefore = await test_utils.getBatchArray(bucketStorage.getBucketDataBatch(checkpoint, [request]));
103
+
104
+ // Simulate changing the threshold after this chunk was written.
105
+ const reconfiguredStorage = new MongoSyncBucketStorageV3(
106
+ factory,
107
+ bucketStorage.replicationStreamId,
108
+ bucketStorage.replicationStream,
109
+ bucketStorage.replicationStreamName,
110
+ undefined,
111
+ {
112
+ storageConfig: bucketStorage.storageConfig,
113
+ objectStorage: memoryStorage,
114
+ inlineThresholdBytes: before.size + thresholdOffset
115
+ }
116
+ );
117
+ const put = vi.spyOn(memoryStorage, 'put');
118
+ expect(
119
+ await reconfiguredStorage
120
+ .createMongoCompactor({
121
+ maxOpId: checkpoint.checkpoint,
122
+ ...(compactChunksOnly
123
+ ? { compactChunksOnly: true, forceChunkCompaction: true }
124
+ : { compactBuckets: [request.bucket] })
125
+ })
126
+ .compact()
127
+ ).toBe(1);
128
+
129
+ const documents = await collection.find({}).toArray();
130
+ expect(documents).toHaveLength(1);
131
+ const [after] = documents;
132
+ if (initiallyInline && thresholdOffset < 0) {
133
+ expect(after.storage_ref).toBeDefined();
134
+ expect(after.ops).toBeUndefined();
135
+ expect(put).toHaveBeenCalledTimes(1);
136
+ const { ops, ...metadata } = before;
137
+ expect(after).toMatchObject(metadata);
138
+ } else {
139
+ expect(after).toEqual(before);
140
+ expect(put).not.toHaveBeenCalled();
141
+ }
142
+ await expectUsageMatchesBucketData(reconfiguredStorage, definitionId);
143
+ const dataAfter = await test_utils.getBatchArray(reconfiguredStorage.getBucketDataBatch(checkpoint, [request]));
144
+ expect(test_utils.getBatchData(dataAfter)).toEqual(test_utils.getBatchData(dataBefore));
145
+ }
146
+ );
147
+
56
148
  test('a later compaction recovers from a transient object storage failure', async () => {
57
149
  const { memoryStorage, factory: factoryGen } = memoryS3Factory();
58
150
  await using factory = await factoryGen.factory();
@@ -1,4 +1,5 @@
1
1
  import { MongoSyncBucketStorageV3 } from '@module/storage/implementation/v3/MongoSyncBucketStorageV3.js';
2
+ import { ReplicationAbortedError } from '@powersync/lib-services-framework';
2
3
  import {
3
4
  deserializeParameterLookup,
4
5
  JwtPayload,
@@ -7,7 +8,7 @@ import {
7
8
  updateSyncRulesFromYaml
8
9
  } from '@powersync/service-core';
9
10
  import { bucketRequest, getTestStorage, register, test_utils } from '@powersync/service-core-tests';
10
- import { RequestParameters, ScopedParameterLookup, SqlSyncRules } from '@powersync/service-sync-rules';
11
+ import { parseSyncConfig, RequestParameters, ScopedParameterLookup } from '@powersync/service-sync-rules';
11
12
  import * as bson from 'bson';
12
13
  import { describe, expect, test } from 'vitest';
13
14
  import { MongoBucketStorage } from '../../src/storage/MongoBucketStorage.js';
@@ -72,7 +73,7 @@ function objectIdGenerator(id: string) {
72
73
  * `ResolveTablesOptions.parsedSyncConfig`.
73
74
  */
74
75
  function parsedSyncConfigSetFor(yaml: string, storageVersion: number) {
75
- const parsed = SqlSyncRules.fromYaml(yaml, test_utils.PARSE_OPTIONS);
76
+ const parsed = parseSyncConfig(yaml, test_utils.PARSE_OPTIONS);
76
77
  // These fixtures are legacy Sync Rules, which always report a deprecation warning.
77
78
  expect(parsed.errors.filter((error) => error.type !== 'warning')).toEqual([]);
78
79
  return new MongoParsedSyncConfigSet(1, getMongoStorageConfig(storageVersion), 'test_slot', [
@@ -939,6 +940,70 @@ event_definitions:
939
940
  expect(deserializeParameterLookup(parameterEntry!.lookup)).toEqual(['shape-check']);
940
941
  });
941
942
 
943
+ test.runIf(storageVersion >= 3)('fetches parameter sets beyond the aggregation stage limit', async () => {
944
+ await using factory = await storageConfig.factory();
945
+ const syncRules = await factory.updateSyncRules(
946
+ updateSyncRulesFromYaml(
947
+ /* yaml */ `
948
+ # Parameter lookup batching regression fixture.
949
+ config:
950
+ edition: 3
951
+ streams:
952
+ by_owner:
953
+ auto_subscribe: true
954
+ query: |
955
+ SELECT * FROM test
956
+ WHERE id IN (SELECT owner_id FROM test WHERE id = auth.parameter('test'))
957
+ `,
958
+ { storageVersion }
959
+ )
960
+ );
961
+ const bucketStorage = await getTestStorage(factory, syncRules);
962
+ await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
963
+ const sourceTable = await test_utils.resolveTestTable(writer, 'test', ['id'], INITIALIZED_MONGO_STORAGE_FACTORY);
964
+
965
+ for (const id of ['row-first', 'row-middle', 'row-last']) {
966
+ await writer.save({
967
+ sourceTable,
968
+ tag: storage.SaveOperationTag.INSERT,
969
+ after: { id, description: id, owner_id: `user-${id}` },
970
+ afterReplicaId: test_utils.rid(id)
971
+ });
972
+ }
973
+ await writer.markAllSnapshotDone('1/1');
974
+ await writer.commit('1/1');
975
+
976
+ const parsed = syncRules.syncConfigContent[0].parsed(test_utils.PARSE_OPTIONS);
977
+ const [parameterSource] = [...parsed.syncConfigs[0].config.bucketParameterLookupSources];
978
+ const scope = parsed.hydrationState.getParameterIndexLookupScope(parameterSource);
979
+
980
+ // MongoDB rejects aggregation pipelines with more than 1000 stages, and each
981
+ // lookup after the first adds a $unionWith stage (#873).
982
+ const lookups: ScopedParameterLookup[] = [];
983
+ for (let i = 0; i < 996; i++) {
984
+ let id = `missing-${i}`;
985
+ if (i == 0) {
986
+ id = 'row-first';
987
+ } else if (i == 900) {
988
+ id = 'row-middle';
989
+ } else if (i == 995) {
990
+ id = 'row-last';
991
+ }
992
+ lookups.push(ScopedParameterLookup.direct(scope, [id]));
993
+ }
994
+
995
+ const checkpoint = await bucketStorage.getCheckpoint();
996
+ const parameterSets = await checkpoint.getParameterSets(lookups, 1000);
997
+
998
+ expect(parameterSets.length).toEqual(3);
999
+ await expect(checkpoint.getParameterSets(lookups, 2)).rejects.toThrow('Too many parameter results');
1000
+ expect(await checkpoint.getParameterSets(lookups, 3)).toEqual(parameterSets);
1001
+ const rowsByKey = new Map(parameterSets.map((set) => [set.lookup.indexKey[0], set.rows]));
1002
+ expect(rowsByKey.get('row-first')).toEqual([{ '0': 'user-row-first' }]);
1003
+ expect(rowsByKey.get('row-middle')).toEqual([{ '0': 'user-row-middle' }]);
1004
+ expect(rowsByKey.get('row-last')).toEqual([{ '0': 'user-row-last' }]);
1005
+ });
1006
+
942
1007
  test.runIf(storageVersion >= 3)('replaces an existing deploying sync config', async () => {
943
1008
  await using factory = await storageConfig.factory();
944
1009
 
@@ -1343,6 +1408,22 @@ streams:
1343
1408
  expect(updatedActiveStorage.replicationStream.replicationJobId).toContain(
1344
1409
  (await factory.getActiveSyncConfig())!.content.syncConfigId
1345
1410
  );
1411
+
1412
+ // The full-stream writer still holds the old config's definition mapping.
1413
+ // Both metadata writes and the next checkpoint must exit so a replacement
1414
+ // job can load only live mappings.
1415
+ await expect(
1416
+ secondWriter.resolveTables({
1417
+ connection_id: 1,
1418
+ source: sourceDescriptor('todos', { objectId: 'todos-relation-after-activation' }),
1419
+ idGenerator: objectIdGenerator('6544e3899293153fa7b3835e')
1420
+ })
1421
+ ).rejects.toBeInstanceOf(ReplicationAbortedError);
1422
+ await expect(secondWriter.commit('3/1')).rejects.toBeInstanceOf(ReplicationAbortedError);
1423
+ const persistedStream = (await (factory as MongoBucketStorage).db.sync_rules.findOne({
1424
+ _id: first.replicationStreamId
1425
+ })) as ReplicationStreamDocumentV3;
1426
+ expect(persistedStream.resume_lsn).toBe('2/1');
1346
1427
  }
1347
1428
  );
1348
1429