test-rxdb
Version:
A local realtime NoSQL Database for JavaScript applications -
568 lines (512 loc) • 20.7 kB
text/typescript
import {
Observable,
Subject,
filter,
firstValueFrom,
map,
shareReplay
} from 'rxjs';
import {
isBulkWriteConflictError,
newRxError
} from '../../rx-error.ts';
import type {
NumberFunctionMap,
RxCollection,
RxDatabase,
RxError,
RxReplicationWriteToMasterRow,
RxStorageInstance,
RxTypeError
} from '../../types/index.d.ts';
import {
MIGRATION_DEFAULT_BATCH_SIZE,
addMigrationStateToDatabase,
getOldCollectionMeta,
migrateDocumentData,
mustMigrate
} from './migration-helpers.ts';
import {
PROMISE_RESOLVE_TRUE,
RXJS_SHARE_REPLAY_DEFAULTS,
clone,
deepEqual,
ensureNotFalsy,
errorToPlainJson,
getDefaultRevision,
getDefaultRxDocumentMeta
} from '../utils/index.ts';
import type {
MigrationStatusUpdate,
RxMigrationStatus,
RxMigrationStatusDocument
} from './migration-types.ts';
import {
getSingleDocument,
hasEncryption,
observeSingle,
writeSingle
} from '../../rx-storage-helper.ts';
import {
BroadcastChannel,
createLeaderElection
} from 'broadcast-channel';
import {
META_INSTANCE_SCHEMA_TITLE,
awaitRxStorageReplicationFirstInSync,
cancelRxStorageReplication,
defaultConflictHandler,
getRxReplicationMetaInstanceSchema,
replicateRxStorageInstance,
rxStorageInstanceToReplicationHandler
} from '../../replication-protocol/index.ts';
import { overwritable } from '../../overwritable.ts';
import {
INTERNAL_CONTEXT_MIGRATION_STATUS,
addConnectedStorageToCollection,
getPrimaryKeyOfInternalDocument
} from '../../rx-database-internal-store.ts';
import { prepareQuery } from '../../rx-query.ts';
import { normalizeMangoQuery } from '../../rx-query-helper.ts';
export class RxMigrationState {
public database: RxDatabase;
private started: boolean = false;
public readonly oldCollectionMeta: ReturnType<typeof getOldCollectionMeta>;
public readonly mustMigrate: ReturnType<typeof mustMigrate>;
public readonly statusDocId: string;
public readonly $: Observable<RxMigrationStatus>;
constructor(
public readonly collection: RxCollection,
public readonly migrationStrategies: NumberFunctionMap,
public readonly statusDocKey = [
collection.name,
'v',
collection.schema.version
].join('-'),
) {
this.database = collection.database;
this.oldCollectionMeta = getOldCollectionMeta(this);
this.mustMigrate = mustMigrate(this);
this.statusDocId = getPrimaryKeyOfInternalDocument(
this.statusDocKey,
INTERNAL_CONTEXT_MIGRATION_STATUS
);
addMigrationStateToDatabase(this);
this.$ = observeSingle<RxMigrationStatusDocument>(
this.database.internalStore,
this.statusDocId
).pipe(
filter(d => !!d),
map(d => ensureNotFalsy(d).data),
shareReplay(RXJS_SHARE_REPLAY_DEFAULTS)
);
}
getStatus() {
return firstValueFrom(this.$);
}
/**
* Starts the migration.
* Returns void so that people to not get the idea to await
* this function.
* Instead use migratePromise() if you want to await
* the migration. This ensures it works even if the migration
* is run on a different browser tab.
*/
async startMigration(batchSize: number = MIGRATION_DEFAULT_BATCH_SIZE): Promise<void> {
const must = await this.mustMigrate;
if (!must) {
return;
}
if (this.started) {
throw newRxError('DM1');
}
this.started = true;
let broadcastChannel: BroadcastChannel | undefined = undefined;
/**
* To ensure that multiple tabs do not migrate the same collection,
* we use a new broadcastChannel/leaderElector for each collection.
* This is required because collections can be added dynamically and
* not all tabs might know about this collection.
*/
if (this.database.multiInstance) {
broadcastChannel = new BroadcastChannel([
'rx-migration-state',
this.database.name,
this.collection.name,
this.collection.schema.version
].join('|'));
const leaderElector = createLeaderElection(broadcastChannel);
await leaderElector.awaitLeadership();
}
/**
* Instead of writing a custom migration protocol,
* we do a push-only replication from the old collection data to the new one.
* This also ensure that restarting the replication works without problems.
*/
const oldCollectionMeta = await this.oldCollectionMeta;
const oldStorageInstance = await this.database.storage.createStorageInstance({
databaseName: this.database.name,
collectionName: this.collection.name,
databaseInstanceToken: this.database.token,
multiInstance: this.database.multiInstance,
options: {},
schema: oldCollectionMeta.data.schema,
password: this.database.password,
devMode: overwritable.isDevMode()
});
const connectedInstances = await this.getConnectedStorageInstances();
/**
* Initially write the migration status into a meta document.
*/
const totalCount = await this.countAllDoucments(
[oldStorageInstance].concat(connectedInstances.map(r => r.oldStorage))
);
await this.updateStatus(s => {
s.count.total = totalCount;
return s;
});
try {
/**
* First migrate the connected storages,
* afterwards migrate the normal collection.
*/
await Promise.all(
connectedInstances.map(async (connectedInstance) => {
await addConnectedStorageToCollection(
this.collection,
connectedInstance.newStorage.collectionName,
connectedInstance.newStorage.schema
);
await this.migrateStorage(
connectedInstance.oldStorage,
connectedInstance.newStorage,
batchSize
);
await connectedInstance.newStorage.close();
})
);
await this.migrateStorage(
oldStorageInstance,
/**
* Use the originalStorageInstance here
* so that the _meta.lwt time keeps the same
* and our replication checkpoints still point to the
* correct checkpoint.
*/
this.collection.storageInstance.originalStorageInstance,
batchSize
);
} catch (err) {
await oldStorageInstance.close();
await this.updateStatus(s => {
s.status = 'ERROR';
s.error = errorToPlainJson(err as Error);
return s;
});
return;
}
// remove old collection meta doc
await writeSingle(
this.database.internalStore,
{
previous: oldCollectionMeta,
document: Object.assign(
{},
oldCollectionMeta,
{
_deleted: true
}
)
},
'rx-migration-remove-collection-meta'
);
await this.updateStatus(s => {
s.status = 'DONE';
return s;
});
if (broadcastChannel) {
await broadcastChannel.close();
}
}
public updateStatusHandlers: MigrationStatusUpdate[] = [];
public updateStatusQueue: Promise<any> = PROMISE_RESOLVE_TRUE;
public updateStatus(
handler: MigrationStatusUpdate
) {
this.updateStatusHandlers.push(handler);
this.updateStatusQueue = this.updateStatusQueue.then(async () => {
if (this.updateStatusHandlers.length === 0) {
return;
}
// re-run until no conflict
const useHandlers = this.updateStatusHandlers;
this.updateStatusHandlers = [];
while (true) {
const previous = await getSingleDocument<RxMigrationStatusDocument>(
this.database.internalStore,
this.statusDocId
);
let newDoc = clone(previous);
if (!previous) {
newDoc = {
id: this.statusDocId,
key: this.statusDocKey,
context: INTERNAL_CONTEXT_MIGRATION_STATUS,
data: {
collectionName: this.collection.name,
status: 'RUNNING',
count: {
total: 0,
handled: 0,
percent: 0
}
},
_deleted: false,
_meta: getDefaultRxDocumentMeta(),
_rev: getDefaultRevision(),
_attachments: {}
};
}
let status = ensureNotFalsy(newDoc).data;
for (const oneHandler of useHandlers) {
status = oneHandler(status);
}
status.count.percent = Math.round((status.count.handled / status.count.total) * 100);
if (
newDoc && previous &&
deepEqual(newDoc.data, previous.data)
) {
break;
}
try {
await writeSingle<RxMigrationStatusDocument>(
this.database.internalStore,
{
previous,
document: ensureNotFalsy(newDoc)
},
INTERNAL_CONTEXT_MIGRATION_STATUS
);
// write successful
break;
} catch (err) {
// ignore conflicts
if (!isBulkWriteConflictError(err)) {
throw err;
}
}
}
});
return this.updateStatusQueue;
}
public async migrateStorage(
oldStorage: RxStorageInstance<any, any, any>,
newStorage: RxStorageInstance<any, any, any>,
batchSize: number
) {
const replicationMetaStorageInstance = await this.database.storage.createStorageInstance({
databaseName: this.database.name,
collectionName: 'rx-migration-state-meta-' + this.collection.name + '-' + this.collection.schema.version,
databaseInstanceToken: this.database.token,
multiInstance: this.database.multiInstance,
options: {},
schema: getRxReplicationMetaInstanceSchema(oldStorage.schema, hasEncryption(oldStorage.schema)),
password: this.database.password,
devMode: overwritable.isDevMode()
});
const replicationHandlerBase = rxStorageInstanceToReplicationHandler(
newStorage,
/**
* Ignore push-conflicts.
* If this happens we drop the 'old' document state.
*/
defaultConflictHandler,
this.database.token,
true
);
const replicationState = replicateRxStorageInstance({
keepMeta: true,
identifier: [
'rx-migration-state',
this.collection.name,
oldStorage.schema.version,
this.collection.schema.version
].join('-'),
replicationHandler: {
masterChangesSince() {
return Promise.resolve({
checkpoint: null,
documents: []
});
},
masterWrite: async (rows) => {
rows = await Promise.all(
rows
.map(async (row) => {
let newDocData = row.newDocumentState;
if (newStorage.schema.title === META_INSTANCE_SCHEMA_TITLE) {
newDocData = row.newDocumentState.docData;
if (row.newDocumentState.isCheckpoint === '1') {
return {
assumedMasterState: undefined,
newDocumentState: row.newDocumentState
};
}
}
const migratedDocData: RxReplicationWriteToMasterRow<any> = await migrateDocumentData(
this.collection,
oldStorage.schema.version,
newDocData
);
const newRow: RxReplicationWriteToMasterRow<any> = {
// drop the assumed master state, we do not have to care about conflicts here.
assumedMasterState: undefined,
newDocumentState: newStorage.schema.title === META_INSTANCE_SCHEMA_TITLE
? Object.assign({}, row.newDocumentState, { docData: migratedDocData })
: migratedDocData
};
return newRow;
})
);
// filter out the documents where the migration strategy returned null
rows = rows.filter(row => !!row.newDocumentState);
const result = await replicationHandlerBase.masterWrite(rows);
return result;
},
masterChangeStream$: new Subject<any>().asObservable()
},
forkInstance: oldStorage,
metaInstance: replicationMetaStorageInstance,
pushBatchSize: batchSize,
pullBatchSize: 0,
conflictHandler: defaultConflictHandler,
hashFunction: this.database.hashFunction
});
let hasError: RxError | RxTypeError | false = false;
replicationState.events.error.subscribe(err => hasError = err);
// update replication status on each change
replicationState.events.processed.up.subscribe(() => {
this.updateStatus(status => {
status.count.handled = status.count.handled + 1;
return status;
});
});
await awaitRxStorageReplicationFirstInSync(replicationState);
await cancelRxStorageReplication(replicationState);
await this.updateStatusQueue;
if (hasError) {
await replicationMetaStorageInstance.close();
throw hasError;
}
// cleanup old storages
await Promise.all([
oldStorage.remove(),
replicationMetaStorageInstance.remove()
]);
}
public async countAllDoucments(
storageInstances: RxStorageInstance<any, any, any>[]
): Promise<number> {
let ret = 0;
await Promise.all(
storageInstances.map(async (instance) => {
const preparedQuery = prepareQuery(
instance.schema,
normalizeMangoQuery(
instance.schema,
{
selector: {}
}
)
);
const countResult = await instance.count(preparedQuery);
ret += countResult.count;
})
);
return ret;
}
public async getConnectedStorageInstances() {
const oldCollectionMeta = await this.oldCollectionMeta;
const ret: {
oldStorage: RxStorageInstance<any, any, any>;
newStorage: RxStorageInstance<any, any, any>;
}[] = [];
await Promise.all(
await Promise.all(
oldCollectionMeta
.data
.connectedStorages
.map(async (connectedStorage) => {
// atm we can only migrate replication states.
if (connectedStorage.schema.title !== META_INSTANCE_SCHEMA_TITLE) {
throw new Error('unknown migration handling for schema');
}
const newSchema = getRxReplicationMetaInstanceSchema(
clone(this.collection.schema.jsonSchema),
hasEncryption(connectedStorage.schema)
);
newSchema.version = this.collection.schema.version;
const [oldStorage, newStorage] = await Promise.all([
this.database.storage.createStorageInstance({
databaseInstanceToken: this.database.token,
databaseName: this.database.name,
devMode: overwritable.isDevMode(),
multiInstance: this.database.multiInstance,
options: {},
schema: connectedStorage.schema,
password: this.database.password,
collectionName: connectedStorage.collectionName
}),
this.database.storage.createStorageInstance({
databaseInstanceToken: this.database.token,
databaseName: this.database.name,
devMode: overwritable.isDevMode(),
multiInstance: this.database.multiInstance,
options: {},
schema: newSchema,
password: this.database.password,
collectionName: connectedStorage.collectionName
})
]);
ret.push({ oldStorage, newStorage });
})
)
);
return ret;
}
async migratePromise(batchSize?: number): Promise<RxMigrationStatus> {
this.startMigration(batchSize);
const must = await this.mustMigrate;
if (!must) {
return {
status: 'DONE',
collectionName: this.collection.name,
count: {
handled: 0,
percent: 0,
total: 0
}
};
}
const result = await Promise.race([
firstValueFrom(
this.$.pipe(
filter(d => d.status === 'DONE')
)
),
firstValueFrom(
this.$.pipe(
filter(d => d.status === 'ERROR')
)
)
]);
if (result.status === 'ERROR') {
throw newRxError('DM4', {
collection: this.collection.name,
error: result.error
});
} else {
return result;
}
}
}