@powersync/service-module-mongodb-storage 0.24.2 → 0.25.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.
- package/CHANGELOG.md +11 -0
- package/dist/migrations/db/migrations/1684951997326-init.js +3 -29
- package/dist/migrations/db/migrations/1684951997326-init.js.map +1 -1
- package/dist/migrations/db/migrations/1741697235857-bucket-state-index.js +3 -24
- package/dist/migrations/db/migrations/1741697235857-bucket-state-index.js.map +1 -1
- package/dist/migrations/db/migrations/1760433882550-bucket-state-index2.js +3 -24
- package/dist/migrations/db/migrations/1760433882550-bucket-state-index2.js.map +1 -1
- package/dist/migrations/db/migrations/1794268800000-v1-storage-collections.d.ts +3 -0
- package/dist/migrations/db/migrations/1794268800000-v1-storage-collections.js +21 -0
- package/dist/migrations/db/migrations/1794268800000-v1-storage-collections.js.map +1 -0
- package/dist/storage/MongoBucketStorage.d.ts +3 -0
- package/dist/storage/MongoBucketStorage.js +52 -1
- package/dist/storage/MongoBucketStorage.js.map +1 -1
- package/dist/storage/implementation/MongoSyncBucketStorage.js +11 -5
- package/dist/storage/implementation/MongoSyncBucketStorage.js.map +1 -1
- package/dist/storage/implementation/db.d.ts +2 -0
- package/dist/storage/implementation/db.js +14 -0
- package/dist/storage/implementation/db.js.map +1 -1
- package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js +3 -1
- package/dist/storage/implementation/v1/VersionedPowerSyncMongoV1.js.map +1 -1
- package/dist/utils/test-utils.js +0 -4
- package/dist/utils/test-utils.js.map +1 -1
- package/package.json +2 -2
- package/src/migrations/db/migrations/1684951997326-init.ts +3 -36
- package/src/migrations/db/migrations/1741697235857-bucket-state-index.ts +3 -31
- package/src/migrations/db/migrations/1760433882550-bucket-state-index2.ts +3 -31
- package/src/migrations/db/migrations/1794268800000-v1-storage-collections.ts +22 -0
- package/src/storage/MongoBucketStorage.ts +61 -1
- package/src/storage/implementation/MongoSyncBucketStorage.ts +12 -5
- package/src/storage/implementation/db.ts +15 -0
- package/src/storage/implementation/v1/VersionedPowerSyncMongoV1.ts +3 -1
- package/src/utils/test-utils.ts +0 -5
- package/test/src/v1_collections.test.ts +233 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,39 +1,6 @@
|
|
|
1
|
-
import * as lib_mongo from '@powersync/lib-service-mongodb';
|
|
2
1
|
import { migrations } from '@powersync/service-core';
|
|
3
|
-
import * as storage from '../../../storage/storage-index.js';
|
|
4
|
-
import { MongoStorageConfig } from '../../../types/types.js';
|
|
5
2
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
service_context: { configuration }
|
|
9
|
-
} = context;
|
|
10
|
-
const database = storage.createPowerSyncMongo(configuration.storage as MongoStorageConfig);
|
|
11
|
-
await lib_mongo.waitForAuth(database.db);
|
|
12
|
-
try {
|
|
13
|
-
await database.bucket_parameters.createIndex(
|
|
14
|
-
{
|
|
15
|
-
'key.g': 1,
|
|
16
|
-
lookup: 1,
|
|
17
|
-
_id: 1
|
|
18
|
-
},
|
|
19
|
-
{ name: 'lookup1' }
|
|
20
|
-
);
|
|
21
|
-
} finally {
|
|
22
|
-
await database.client.close();
|
|
23
|
-
}
|
|
24
|
-
};
|
|
3
|
+
// Indexes for v1/v2 data collections are managed by stream initialization.
|
|
4
|
+
export const up: migrations.PowerSyncMigrationFunction = async () => {};
|
|
25
5
|
|
|
26
|
-
export const down: migrations.PowerSyncMigrationFunction = async (
|
|
27
|
-
const {
|
|
28
|
-
service_context: { configuration }
|
|
29
|
-
} = context;
|
|
30
|
-
|
|
31
|
-
const database = storage.createPowerSyncMongo(configuration.storage as MongoStorageConfig);
|
|
32
|
-
try {
|
|
33
|
-
if (await database.bucket_parameters.indexExists('lookup')) {
|
|
34
|
-
await database.bucket_parameters.dropIndex('lookup1');
|
|
35
|
-
}
|
|
36
|
-
} finally {
|
|
37
|
-
await database.client.close();
|
|
38
|
-
}
|
|
39
|
-
};
|
|
6
|
+
export const down: migrations.PowerSyncMigrationFunction = async () => {};
|
|
@@ -1,34 +1,6 @@
|
|
|
1
1
|
import { migrations } from '@powersync/service-core';
|
|
2
|
-
import * as storage from '../../../storage/storage-index.js';
|
|
3
|
-
import { MongoStorageConfig } from '../../../types/types.js';
|
|
4
2
|
|
|
5
|
-
|
|
3
|
+
// Indexes for v1/v2 data collections are managed by stream initialization.
|
|
4
|
+
export const up: migrations.PowerSyncMigrationFunction = async () => {};
|
|
6
5
|
|
|
7
|
-
export const
|
|
8
|
-
const {
|
|
9
|
-
service_context: { configuration }
|
|
10
|
-
} = context;
|
|
11
|
-
const db = storage.createPowerSyncMongo(configuration.storage as MongoStorageConfig);
|
|
12
|
-
|
|
13
|
-
try {
|
|
14
|
-
await db.createBucketStateIndex();
|
|
15
|
-
} finally {
|
|
16
|
-
await db.client.close();
|
|
17
|
-
}
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
export const down: migrations.PowerSyncMigrationFunction = async (context) => {
|
|
21
|
-
const {
|
|
22
|
-
service_context: { configuration }
|
|
23
|
-
} = context;
|
|
24
|
-
|
|
25
|
-
const db = storage.createPowerSyncMongo(configuration.storage as MongoStorageConfig);
|
|
26
|
-
|
|
27
|
-
try {
|
|
28
|
-
if (await db.bucket_state.indexExists(INDEX_NAME)) {
|
|
29
|
-
await db.bucket_state.dropIndex(INDEX_NAME);
|
|
30
|
-
}
|
|
31
|
-
} finally {
|
|
32
|
-
await db.client.close();
|
|
33
|
-
}
|
|
34
|
-
};
|
|
6
|
+
export const down: migrations.PowerSyncMigrationFunction = async () => {};
|
|
@@ -1,34 +1,6 @@
|
|
|
1
1
|
import { migrations } from '@powersync/service-core';
|
|
2
|
-
import * as storage from '../../../storage/storage-index.js';
|
|
3
|
-
import { MongoStorageConfig } from '../../../types/types.js';
|
|
4
2
|
|
|
5
|
-
|
|
3
|
+
// Indexes for v1/v2 data collections are managed by stream initialization.
|
|
4
|
+
export const up: migrations.PowerSyncMigrationFunction = async () => {};
|
|
6
5
|
|
|
7
|
-
export const
|
|
8
|
-
const {
|
|
9
|
-
service_context: { configuration }
|
|
10
|
-
} = context;
|
|
11
|
-
const db = storage.createPowerSyncMongo(configuration.storage as MongoStorageConfig);
|
|
12
|
-
|
|
13
|
-
try {
|
|
14
|
-
await db.createBucketStateIndex2();
|
|
15
|
-
} finally {
|
|
16
|
-
await db.client.close();
|
|
17
|
-
}
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
export const down: migrations.PowerSyncMigrationFunction = async (context) => {
|
|
21
|
-
const {
|
|
22
|
-
service_context: { configuration }
|
|
23
|
-
} = context;
|
|
24
|
-
|
|
25
|
-
const db = storage.createPowerSyncMongo(configuration.storage as MongoStorageConfig);
|
|
26
|
-
|
|
27
|
-
try {
|
|
28
|
-
if (await db.bucket_state.indexExists(INDEX_NAME)) {
|
|
29
|
-
await db.bucket_state.dropIndex(INDEX_NAME);
|
|
30
|
-
}
|
|
31
|
-
} finally {
|
|
32
|
-
await db.client.close();
|
|
33
|
-
}
|
|
34
|
-
};
|
|
6
|
+
export const down: migrations.PowerSyncMigrationFunction = async () => {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { migrations } from '@powersync/service-core';
|
|
2
|
+
import { MongoBucketStorage, createPowerSyncMongo } from '../../../storage/storage-index.js';
|
|
3
|
+
import { MongoStorageConfig } from '../../../types/types.js';
|
|
4
|
+
|
|
5
|
+
export const up: migrations.PowerSyncMigrationFunction = async ({ service_context: { configuration } }) => {
|
|
6
|
+
const db = createPowerSyncMongo(configuration.storage as MongoStorageConfig);
|
|
7
|
+
try {
|
|
8
|
+
const factory = new MongoBucketStorage(db, { replicationStreamNamePrefix: configuration.slot_name_prefix });
|
|
9
|
+
await factory.dropUnusedV1StorageCollections();
|
|
10
|
+
} finally {
|
|
11
|
+
await db.client.close();
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const down: migrations.PowerSyncMigrationFunction = async ({ service_context: { configuration } }) => {
|
|
16
|
+
const db = createPowerSyncMongo(configuration.storage as MongoStorageConfig);
|
|
17
|
+
try {
|
|
18
|
+
await db.initializeV1StorageCollections();
|
|
19
|
+
} finally {
|
|
20
|
+
await db.client.close();
|
|
21
|
+
}
|
|
22
|
+
};
|
|
@@ -576,7 +576,67 @@ export class MongoBucketStorage extends storage.BucketStorageFactory {
|
|
|
576
576
|
}
|
|
577
577
|
|
|
578
578
|
private async withTransaction<T>(callback: (session: mongo.ClientSession) => Promise<T>): Promise<T> {
|
|
579
|
-
|
|
579
|
+
// Serialize deployment with collection drops so newly deployed streams retain their storage.
|
|
580
|
+
return this.withStorageSchemaLock((refresh) =>
|
|
581
|
+
this.client.withSession((session) =>
|
|
582
|
+
session.withTransaction(async () => {
|
|
583
|
+
await refresh();
|
|
584
|
+
return callback(session);
|
|
585
|
+
})
|
|
586
|
+
)
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
private async withStorageSchemaLock<T>(callback: (refresh: () => Promise<void>) => Promise<T>): Promise<T> {
|
|
591
|
+
const name = 'v1-storage-collections';
|
|
592
|
+
// Seed by unique _id so concurrent first acquisitions share one lock document.
|
|
593
|
+
await this.db.db
|
|
594
|
+
.collection<{ _id: string; name: string }>('locks')
|
|
595
|
+
.updateOne({ _id: name }, { $setOnInsert: { name } }, { upsert: true });
|
|
596
|
+
const manager = new lib_mongo.locks.MongoLockManager({ collection: this.db.locks, name, timeout: 300_000 });
|
|
597
|
+
let result!: T;
|
|
598
|
+
await manager.lock(
|
|
599
|
+
async (refresh) => {
|
|
600
|
+
let lastRefresh = Date.now();
|
|
601
|
+
result = await callback(async () => {
|
|
602
|
+
// Rate-limit refresh writes; identical timestamps are not a modified document.
|
|
603
|
+
if (Date.now() - lastRefresh >= 30_000) {
|
|
604
|
+
await refresh();
|
|
605
|
+
lastRefresh = Date.now();
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
},
|
|
609
|
+
{ max_wait_ms: 60_000 }
|
|
610
|
+
);
|
|
611
|
+
return result;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Returns true when no v1/v2 stream needs the shared data collections. */
|
|
615
|
+
async dropUnusedV1StorageCollections(signal?: AbortSignal): Promise<boolean> {
|
|
616
|
+
return this.withStorageSchemaLock(async (refresh) => {
|
|
617
|
+
signal?.throwIfAborted();
|
|
618
|
+
const live = await this.db.sync_rules.findOne(
|
|
619
|
+
{
|
|
620
|
+
$or: [{ storage_version: { $lt: storage.STORAGE_VERSION_3 } }, { storage_version: { $exists: false } }],
|
|
621
|
+
// Processing streams write data; errored streams still serve clients.
|
|
622
|
+
state: { $nin: [storage.SyncRuleState.STOP, storage.SyncRuleState.TERMINATED] }
|
|
623
|
+
},
|
|
624
|
+
{ projection: { _id: 1 } }
|
|
625
|
+
);
|
|
626
|
+
if (live != null) return false;
|
|
627
|
+
for (const collection of this.db.v1DataCollections) {
|
|
628
|
+
signal?.throwIfAborted();
|
|
629
|
+
await refresh();
|
|
630
|
+
const dropped = await collection
|
|
631
|
+
.drop({ maxTimeMS: lib_mongo.db.MONGO_CLEAR_OPERATION_TIMEOUT_MS })
|
|
632
|
+
.catch((error) => {
|
|
633
|
+
if (!lib_mongo.isMongoNamespaceNotFoundError(error)) throw error;
|
|
634
|
+
return false;
|
|
635
|
+
});
|
|
636
|
+
if (dropped) logger.info(`Dropped unused v1/v2 storage collection: ${collection.collectionName}`);
|
|
637
|
+
}
|
|
638
|
+
return true;
|
|
639
|
+
});
|
|
580
640
|
}
|
|
581
641
|
|
|
582
642
|
/**
|
|
@@ -405,11 +405,18 @@ export abstract class MongoSyncBucketStorage
|
|
|
405
405
|
this.factory.discardOpIdAllocator(this.replicationStreamId);
|
|
406
406
|
await this.clearSyncRuleState();
|
|
407
407
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
408
|
+
// Only stopped-stream cleanup may remove shared collections. Clearing an
|
|
409
|
+
// active or processing stream for a snapshot must not change shared storage.
|
|
410
|
+
const droppedV1Collections =
|
|
411
|
+
this.replicationStream.state == storage.SyncRuleState.STOP &&
|
|
412
|
+
(await this.factory.dropUnusedV1StorageCollections(signal));
|
|
413
|
+
if (this.storageConfig.version >= storage.STORAGE_VERSION_3 || !droppedV1Collections) {
|
|
414
|
+
await this.clearBucketData(signal);
|
|
415
|
+
await this.clearParameterIndexes(signal);
|
|
416
|
+
await this.clearSourceRecords(signal);
|
|
417
|
+
await this.clearBucketState(signal);
|
|
418
|
+
await this.clearSourceTables(signal);
|
|
419
|
+
}
|
|
413
420
|
await this.clearCustomCheckpointRequests(signal);
|
|
414
421
|
|
|
415
422
|
this.#storageInitialized = false;
|
|
@@ -178,6 +178,21 @@ export class PowerSyncMongo {
|
|
|
178
178
|
return collections.map((c) => this.db.collection<CommonSourceTableDocument>(c.name));
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
get v1DataCollections() {
|
|
182
|
+
return [this.current_data, this.bucket_data, this.bucket_parameters, this.bucket_state, this.source_tables];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async initializeV1StorageCollections(): Promise<void> {
|
|
186
|
+
for (const collection of this.v1DataCollections) {
|
|
187
|
+
await this.db.createCollection(collection.collectionName).catch((error) => {
|
|
188
|
+
if (!lib_mongo.isMongoServerError(error) || error.codeName !== 'NamespaceExists') throw error;
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
await this.bucket_parameters.createIndex({ 'key.g': 1, lookup: 1, _id: 1 }, { name: 'lookup1' });
|
|
192
|
+
await this.createBucketStateIndex();
|
|
193
|
+
await this.createBucketStateIndex2();
|
|
194
|
+
}
|
|
195
|
+
|
|
181
196
|
/**
|
|
182
197
|
* Clear all collections.
|
|
183
198
|
*/
|
|
@@ -26,7 +26,9 @@ export class VersionedPowerSyncMongoV1 extends BaseVersionedPowerSyncMongo {
|
|
|
26
26
|
return this.upstream.source_tables as any as mongo.Collection<SourceTableDocumentV1>;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
async initializeStreamStorage(_replicationStreamId: number): Promise<void> {
|
|
29
|
+
async initializeStreamStorage(_replicationStreamId: number): Promise<void> {
|
|
30
|
+
await this.upstream.initializeV1StorageCollections();
|
|
31
|
+
}
|
|
30
32
|
|
|
31
33
|
get bucketDataV1(): mongo.Collection<BucketDataDocumentV1> {
|
|
32
34
|
return this.upstream.bucket_data;
|
package/src/utils/test-utils.ts
CHANGED
|
@@ -15,11 +15,6 @@ export function mongoTestStorageFactoryGenerator(factoryOptions: MongoTestStorag
|
|
|
15
15
|
factory: async (options?: TestStorageOptions) => {
|
|
16
16
|
const db = connectMongoForTests(factoryOptions.url, factoryOptions.isCI, factoryOptions.monitorCommands);
|
|
17
17
|
|
|
18
|
-
// None of the tests insert data into this collection, so it was never created
|
|
19
|
-
if (!(await db.db.listCollections({ name: db.bucket_parameters.collectionName }).hasNext())) {
|
|
20
|
-
await db.db.createCollection('bucket_parameters');
|
|
21
|
-
}
|
|
22
|
-
|
|
23
18
|
// Full migrations are not currently run for tests, so we manually create this
|
|
24
19
|
await db.createCheckpointEventsCollection();
|
|
25
20
|
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { MongoBucketStorage } from '@module/storage/MongoBucketStorage.js';
|
|
2
|
+
import { mongoTestStorageFactoryGenerator } from '@module/utils/test-utils.js';
|
|
3
|
+
import { storage, updateSyncRulesFromYaml } from '@powersync/service-core';
|
|
4
|
+
import { getTestStorage, releaseTestStorageLease, test_utils } from '@powersync/service-core-tests';
|
|
5
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
6
|
+
import { up as parameterIndexes } from '../../src/migrations/db/migrations/1684951997326-init.js';
|
|
7
|
+
import { up as bucketIndexes } from '../../src/migrations/db/migrations/1741697235857-bucket-state-index.js';
|
|
8
|
+
import { up as dirtyBucketIndexes } from '../../src/migrations/db/migrations/1760433882550-bucket-state-index2.js';
|
|
9
|
+
import {
|
|
10
|
+
up as removeUnusedV1Collections,
|
|
11
|
+
down as restoreV1Collections
|
|
12
|
+
} from '../../src/migrations/db/migrations/1794268800000-v1-storage-collections.js';
|
|
13
|
+
import { env } from './env.js';
|
|
14
|
+
|
|
15
|
+
const collectionNames = ['current_data', 'bucket_data', 'bucket_parameters', 'bucket_state', 'source_tables'];
|
|
16
|
+
const migrationContext = {
|
|
17
|
+
service_context: {
|
|
18
|
+
configuration: { storage: { type: 'mongodb', uri: env.MONGO_TEST_URL }, slot_name_prefix: 'test_' }
|
|
19
|
+
}
|
|
20
|
+
} as unknown as Parameters<typeof parameterIndexes>[0];
|
|
21
|
+
const testStorage = mongoTestStorageFactoryGenerator({
|
|
22
|
+
url: env.MONGO_TEST_URL,
|
|
23
|
+
isCI: env.CI,
|
|
24
|
+
monitorCommands: true,
|
|
25
|
+
supportsMultipleSyncConfigs: true
|
|
26
|
+
});
|
|
27
|
+
const yaml = /* yaml */ `
|
|
28
|
+
# sync config fixture.
|
|
29
|
+
config:
|
|
30
|
+
edition: 3
|
|
31
|
+
streams:
|
|
32
|
+
global:
|
|
33
|
+
query: SELECT id FROM test
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
async function activate(factory: MongoBucketStorage, version: number, lsn: string) {
|
|
37
|
+
const stream = await factory.updateSyncRules(updateSyncRulesFromYaml(yaml, { storageVersion: version }));
|
|
38
|
+
const bucketStorage = await getTestStorage(factory, stream);
|
|
39
|
+
await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
|
|
40
|
+
const sourceTable = await test_utils.resolveTestTable(
|
|
41
|
+
writer,
|
|
42
|
+
'test',
|
|
43
|
+
['id'],
|
|
44
|
+
testStorage,
|
|
45
|
+
stream.replicationStreamId
|
|
46
|
+
);
|
|
47
|
+
await writer.save({
|
|
48
|
+
sourceTable,
|
|
49
|
+
tag: storage.SaveOperationTag.INSERT,
|
|
50
|
+
after: { id: 'item-1' },
|
|
51
|
+
afterReplicaId: test_utils.rid('item-1')
|
|
52
|
+
});
|
|
53
|
+
await writer.markAllSnapshotDone(lsn);
|
|
54
|
+
await writer.commit(lsn);
|
|
55
|
+
return stream;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function names(factory: MongoBucketStorage) {
|
|
59
|
+
return (await factory.db.db.listCollections({}, { nameOnly: true }).toArray()).map((c) => c.name);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function terminateStopped(factory: MongoBucketStorage) {
|
|
63
|
+
for (const stream of await factory.getStoppedReplicationStreams()) {
|
|
64
|
+
const lock = await stream.lock();
|
|
65
|
+
try {
|
|
66
|
+
await factory.getInstance(stream, { skipLifecycleHooks: true }).terminate({ clearStorage: true });
|
|
67
|
+
} finally {
|
|
68
|
+
await lock.release();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('v1/v2 collection lifecycle', () => {
|
|
74
|
+
test('migrations and v4 initialization do not create v1/v2 collections', async () => {
|
|
75
|
+
await using factory = await testStorage.factory();
|
|
76
|
+
await factory.db.drop();
|
|
77
|
+
await factory.db.createCheckpointEventsCollection();
|
|
78
|
+
for (const migration of [parameterIndexes, bucketIndexes, dirtyBucketIndexes, removeUnusedV1Collections]) {
|
|
79
|
+
await migration(migrationContext);
|
|
80
|
+
}
|
|
81
|
+
for (const name of collectionNames) expect(await names(factory)).not.toContain(name);
|
|
82
|
+
await activate(factory, storage.STORAGE_VERSION_4, '1/1');
|
|
83
|
+
for (const name of collectionNames) expect(await names(factory)).not.toContain(name);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test.each([1, 2])('drops unused v%i collections instead of deleting individual records', async (version) => {
|
|
87
|
+
await using factory = await testStorage.factory();
|
|
88
|
+
const previous = await activate(factory, version, '1/1');
|
|
89
|
+
await releaseTestStorageLease(factory, previous.replicationStreamId);
|
|
90
|
+
expect(await names(factory)).toEqual(expect.arrayContaining(collectionNames));
|
|
91
|
+
const current = await activate(factory, storage.STORAGE_VERSION_4, '2/1');
|
|
92
|
+
const dropped: string[] = [];
|
|
93
|
+
const deleted: string[] = [];
|
|
94
|
+
factory.db.client.on('commandStarted', (event) => {
|
|
95
|
+
if (event.commandName === 'drop') dropped.push(event.command.drop);
|
|
96
|
+
if (event.commandName === 'delete') deleted.push(event.command.delete);
|
|
97
|
+
});
|
|
98
|
+
await terminateStopped(factory);
|
|
99
|
+
expect(dropped).toEqual(expect.arrayContaining(collectionNames));
|
|
100
|
+
expect(deleted.filter((name) => collectionNames.includes(name))).toEqual([]);
|
|
101
|
+
for (const name of collectionNames) expect(await names(factory)).not.toContain(name);
|
|
102
|
+
expect((await factory.db.sync_rules.findOne({ _id: previous.replicationStreamId }))?.state).toBe(
|
|
103
|
+
storage.SyncRuleState.TERMINATED
|
|
104
|
+
);
|
|
105
|
+
expect((await factory.getActiveSyncConfig())?.replicationStream.replicationStreamId).toBe(
|
|
106
|
+
current.replicationStreamId
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const returning = await activate(factory, version, '3/1');
|
|
110
|
+
await releaseTestStorageLease(factory, current.replicationStreamId);
|
|
111
|
+
await terminateStopped(factory);
|
|
112
|
+
expect(await names(factory)).toEqual(expect.arrayContaining(collectionNames));
|
|
113
|
+
expect((await factory.db.bucket_parameters.listIndexes().toArray()).map((index) => index.name)).toContain(
|
|
114
|
+
'lookup1'
|
|
115
|
+
);
|
|
116
|
+
expect((await factory.db.bucket_state.listIndexes().toArray()).map((index) => index.name)).toEqual(
|
|
117
|
+
expect.arrayContaining(['bucket_updates', 'dirty_count'])
|
|
118
|
+
);
|
|
119
|
+
expect(await factory.db.current_data.countDocuments({ '_id.g': returning.replicationStreamId })).toBe(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test.each([storage.SyncRuleState.PROCESSING, storage.SyncRuleState.ACTIVE, storage.SyncRuleState.ERRORED])(
|
|
123
|
+
'retains shared collections for a stream in state %s and deletes only stopped stream records',
|
|
124
|
+
async (state) => {
|
|
125
|
+
await using factory = await testStorage.factory();
|
|
126
|
+
const previous = await activate(factory, storage.STORAGE_VERSION_1, '1/1');
|
|
127
|
+
await releaseTestStorageLease(factory, previous.replicationStreamId);
|
|
128
|
+
const current = await activate(factory, storage.STORAGE_VERSION_2, '2/1');
|
|
129
|
+
await factory.db.sync_rules.updateOne({ _id: current.replicationStreamId }, { $set: { state } });
|
|
130
|
+
await terminateStopped(factory);
|
|
131
|
+
expect(await names(factory)).toEqual(expect.arrayContaining(collectionNames));
|
|
132
|
+
expect(await factory.db.current_data.countDocuments({ '_id.g': previous.replicationStreamId })).toBe(0);
|
|
133
|
+
expect(await factory.db.current_data.countDocuments({ '_id.g': current.replicationStreamId })).toBe(1);
|
|
134
|
+
// A missing storage_version also identifies a v1 stream.
|
|
135
|
+
await factory.db.sync_rules.updateOne({ _id: current.replicationStreamId }, { $unset: { storage_version: 1 } });
|
|
136
|
+
expect(await factory.dropUnusedV1StorageCollections()).toBe(false);
|
|
137
|
+
await removeUnusedV1Collections(migrationContext);
|
|
138
|
+
expect(await names(factory)).toEqual(expect.arrayContaining(collectionNames));
|
|
139
|
+
expect(await factory.db.current_data.countDocuments({ '_id.g': current.replicationStreamId })).toBe(1);
|
|
140
|
+
}
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
test('restarting and clearing an active v4 stream do not drop v1 collections', async () => {
|
|
144
|
+
await using factory = await testStorage.factory();
|
|
145
|
+
const previous = await activate(factory, storage.STORAGE_VERSION_2, '1/1');
|
|
146
|
+
await releaseTestStorageLease(factory, previous.replicationStreamId);
|
|
147
|
+
const current = await activate(factory, storage.STORAGE_VERSION_4, '2/1');
|
|
148
|
+
await factory.db.sync_rules.updateOne(
|
|
149
|
+
{ _id: previous.replicationStreamId },
|
|
150
|
+
{ $set: { state: storage.SyncRuleState.TERMINATED } }
|
|
151
|
+
);
|
|
152
|
+
await using restarted = await testStorage.factory({ doNotClear: true });
|
|
153
|
+
const drop = vi.spyOn(restarted, 'dropUnusedV1StorageCollections');
|
|
154
|
+
expect(await restarted.getStoppedReplicationStreams()).toEqual([]);
|
|
155
|
+
expect(drop).not.toHaveBeenCalled();
|
|
156
|
+
expect(await names(restarted)).toEqual(expect.arrayContaining(collectionNames));
|
|
157
|
+
await factory.getInstance(current).clear();
|
|
158
|
+
expect(await names(factory)).toEqual(expect.arrayContaining(collectionNames));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('migration removes unused v1 collections and rollback recreates collections and indexes', async () => {
|
|
162
|
+
await using factory = await testStorage.factory();
|
|
163
|
+
const previous = await activate(factory, storage.STORAGE_VERSION_2, '1/1');
|
|
164
|
+
await releaseTestStorageLease(factory, previous.replicationStreamId);
|
|
165
|
+
const current = await activate(factory, storage.STORAGE_VERSION_4, '2/1');
|
|
166
|
+
await factory.db.sync_rules.updateOne(
|
|
167
|
+
{ _id: previous.replicationStreamId },
|
|
168
|
+
{ $set: { state: storage.SyncRuleState.TERMINATED } }
|
|
169
|
+
);
|
|
170
|
+
await removeUnusedV1Collections(migrationContext);
|
|
171
|
+
for (const name of collectionNames) expect(await names(factory)).not.toContain(name);
|
|
172
|
+
// Both directions are idempotent, and rollback must retain any existing data.
|
|
173
|
+
await removeUnusedV1Collections(migrationContext);
|
|
174
|
+
await restoreV1Collections(migrationContext);
|
|
175
|
+
await factory.db.current_data.insertOne({ _id: { g: 99, t: 'test', k: 'item' } } as any);
|
|
176
|
+
await restoreV1Collections(migrationContext);
|
|
177
|
+
expect(await names(factory)).toEqual(expect.arrayContaining(collectionNames));
|
|
178
|
+
expect(await factory.db.current_data.countDocuments({ '_id.g': 99 })).toBe(1);
|
|
179
|
+
expect((await factory.db.bucket_parameters.listIndexes().toArray()).map((index) => index.name)).toEqual(
|
|
180
|
+
expect.arrayContaining(['_id_', 'lookup1'])
|
|
181
|
+
);
|
|
182
|
+
expect((await factory.db.bucket_state.listIndexes().toArray()).map((index) => index.name)).toEqual(
|
|
183
|
+
expect.arrayContaining(['_id_', 'bucket_updates', 'dirty_count'])
|
|
184
|
+
);
|
|
185
|
+
expect((await factory.getActiveSyncConfig())?.replicationStream.replicationStreamId).toBe(
|
|
186
|
+
current.replicationStreamId
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('retries a partially completed collection drop', async () => {
|
|
191
|
+
await using factory = await testStorage.factory();
|
|
192
|
+
const previous = await activate(factory, storage.STORAGE_VERSION_2, '1/1');
|
|
193
|
+
await releaseTestStorageLease(factory, previous.replicationStreamId);
|
|
194
|
+
await activate(factory, storage.STORAGE_VERSION_4, '2/1');
|
|
195
|
+
const drop = vi.spyOn(factory.db.bucket_parameters, 'drop').mockRejectedValueOnce(new Error('drop interrupted'));
|
|
196
|
+
await expect(terminateStopped(factory)).rejects.toThrow('drop interrupted');
|
|
197
|
+
expect((await factory.db.sync_rules.findOne({ _id: previous.replicationStreamId }))?.state).toBe(
|
|
198
|
+
storage.SyncRuleState.STOP
|
|
199
|
+
);
|
|
200
|
+
await terminateStopped(factory);
|
|
201
|
+
for (const name of collectionNames) expect(await names(factory)).not.toContain(name);
|
|
202
|
+
drop.mockRestore();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('serializes collection drops with deployment by another worker', async () => {
|
|
206
|
+
await using factory = await testStorage.factory();
|
|
207
|
+
await using other = await testStorage.factory({ doNotClear: true });
|
|
208
|
+
const entered = Promise.withResolvers<void>();
|
|
209
|
+
const resume = Promise.withResolvers<void>();
|
|
210
|
+
const originalDrop = factory.db.current_data.drop.bind(factory.db.current_data);
|
|
211
|
+
const drop = vi.spyOn(factory.db.current_data, 'drop').mockImplementation(async (options) => {
|
|
212
|
+
entered.resolve();
|
|
213
|
+
await resume.promise;
|
|
214
|
+
return originalDrop(options);
|
|
215
|
+
});
|
|
216
|
+
const cleanup = factory.dropUnusedV1StorageCollections();
|
|
217
|
+
await entered.promise;
|
|
218
|
+
let deployed = false;
|
|
219
|
+
const deployment = other.updateSyncRules(updateSyncRulesFromYaml(yaml, { storageVersion: 2 })).then(() => {
|
|
220
|
+
deployed = true;
|
|
221
|
+
});
|
|
222
|
+
try {
|
|
223
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
224
|
+
expect(deployed).toBe(false);
|
|
225
|
+
} finally {
|
|
226
|
+
resume.resolve();
|
|
227
|
+
await cleanup;
|
|
228
|
+
drop.mockRestore();
|
|
229
|
+
}
|
|
230
|
+
await deployment;
|
|
231
|
+
expect(deployed).toBe(true);
|
|
232
|
+
}, 15_000);
|
|
233
|
+
});
|