import * as plugins from '../ts/plugins.js'; import { isSocketListening, } from '../ts/functions.embeddeddb.js'; const migrationVersion = 1; const journalFileName = '.smartdb-location-migration.json'; const maximumJournalBytes = 16 * 1024; interface IDirectoryIdentity { device: string; inode: string; } interface IMigrationJournalBase extends IDirectoryIdentity { version: typeof migrationVersion; sourceDirectory: string; destinationDirectory: string; } interface IPreparedMigrationJournal extends IMigrationJournalBase { phase: 'prepared'; } interface ICompletedMigrationJournal extends IMigrationJournalBase { phase: 'completed'; destinationDevice: string; destinationInode: string; contentDigestSha256: string; } type TMigrationJournal = IPreparedMigrationJournal | ICompletedMigrationJournal; export interface IEmbeddedDatabaseLocationMigrationOptions { sourceDirectory: string; destinationDirectory: string; sourceSocketPath: string; destinationSocketPath: string; } export interface IEmbeddedDatabaseLocationMigrationResult { localDb: plugins.smartdb.LocalSmartDb; connectionUri: string; } export interface IRebaseCompletedEmbeddedDatabaseLocationMigrationOptions { sourceDirectory: string; previousDestinationDirectory: string; destinationDirectory: string; } const directoryIdentity = async ( directoryArg: string, ): Promise => { try { const stats = await plugins.fs.promises.lstat(directoryArg, { bigint: true }); if (!stats.isDirectory() || stats.isSymbolicLink()) { throw new Error(`Controller database path must be a real directory: ${directoryArg}`); } return { device: stats.dev.toString(10), inode: stats.ino.toString(10), }; } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw errorArg; } }; const identitiesEqual = ( leftArg: IDirectoryIdentity, rightArg: IDirectoryIdentity, ): boolean => leftArg.device === rightArg.device && leftArg.inode === rightArg.inode; const assertExactKeys = ( valueArg: Record, expectedArg: string[], ): void => { const actual = Object.keys(valueArg).sort(); const expected = [...expectedArg].sort(); if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { throw new Error('The embedded database migration journal has unexpected fields.'); } }; const parseJournal = (valueArg: unknown): TMigrationJournal => { if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) { throw new Error('The embedded database migration journal is malformed.'); } const value = valueArg as Record; if (value.version !== migrationVersion) { throw new Error('The embedded database migration journal version is unsupported.'); } if (value.phase !== 'prepared' && value.phase !== 'completed') { throw new Error('The embedded database migration journal phase is invalid.'); } assertExactKeys(value, [ 'version', 'phase', 'sourceDirectory', 'destinationDirectory', 'device', 'inode', ...(value.phase === 'completed' ? ['destinationDevice', 'destinationInode', 'contentDigestSha256'] : []), ]); for (const key of ['sourceDirectory', 'destinationDirectory', 'device', 'inode']) { if (typeof value[key] !== 'string' || value[key].length === 0) { throw new Error(`The embedded database migration journal ${key} is invalid.`); } } if ( !/^\d+$/.test(value.device as string) || !/^\d+$/.test(value.inode as string) ) { throw new Error('The embedded database migration journal source identity is invalid.'); } if (value.phase === 'completed') { if ( typeof value.destinationDevice !== 'string' || !/^\d+$/.test(value.destinationDevice) || typeof value.destinationInode !== 'string' || !/^\d+$/.test(value.destinationInode) || typeof value.contentDigestSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.contentDigestSha256) ) { throw new Error('The embedded database migration journal completion is invalid.'); } } return value as unknown as TMigrationJournal; }; const readJournal = async (journalPathArg: string): Promise => { let handle: plugins.fs.promises.FileHandle; try { handle = await plugins.fs.promises.open( journalPathArg, plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_NOFOLLOW, ); } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw errorArg; } try { const stats = await handle.stat(); if (!stats.isFile() || stats.size < 2 || stats.size > maximumJournalBytes) { throw new Error('The embedded database migration journal is not a bounded regular file.'); } if (typeof process.getuid === 'function' && stats.uid !== process.getuid()) { throw new Error('The embedded database migration journal is not owned by the current user.'); } if ((stats.mode & 0o077) !== 0) { throw new Error('The embedded database migration journal must not be group or world accessible.'); } return parseJournal(JSON.parse(await handle.readFile('utf8')) as unknown); } finally { await handle.close(); } }; const syncDirectory = async (directoryArg: string): Promise => { const handle = await plugins.fs.promises.open(directoryArg, plugins.fs.constants.O_RDONLY); try { await handle.sync(); } finally { await handle.close(); } }; const writeJournal = async ( journalPathArg: string, journalArg: TMigrationJournal, ): Promise => { const parentDirectory = plugins.path.dirname(journalPathArg); await plugins.fs.promises.mkdir(parentDirectory, { recursive: true, mode: 0o700 }); const parentStats = await plugins.fs.promises.lstat(parentDirectory); if (!parentStats.isDirectory() || parentStats.isSymbolicLink()) { throw new Error(`Controller data path must be a real directory: ${parentDirectory}`); } const temporaryPath = `${journalPathArg}.${process.pid}-${plugins.crypto.randomBytes(8).toString('hex')}.tmp`; let handle: plugins.fs.promises.FileHandle | undefined; try { handle = await plugins.fs.promises.open( temporaryPath, plugins.fs.constants.O_WRONLY | plugins.fs.constants.O_CREAT | plugins.fs.constants.O_EXCL | plugins.fs.constants.O_NOFOLLOW, 0o600, ); await handle.writeFile(`${JSON.stringify(journalArg)}\n`, 'utf8'); await handle.sync(); await handle.close(); handle = undefined; await plugins.fs.promises.rename(temporaryPath, journalPathArg); await syncDirectory(parentDirectory); } catch (errorArg) { if (handle) await handle.close().catch(() => undefined); await plugins.fs.promises.unlink(temporaryPath).catch(() => undefined); throw errorArg; } }; const assertJournalMatches = ( journalArg: TMigrationJournal, optionsArg: IEmbeddedDatabaseLocationMigrationOptions, ): void => { if ( journalArg.sourceDirectory !== optionsArg.sourceDirectory || journalArg.destinationDirectory !== optionsArg.destinationDirectory ) { throw new Error('The embedded database migration journal belongs to another migration.'); } }; export const rebaseCompletedEmbeddedDatabaseLocationMigration = async ( optionsArg: IRebaseCompletedEmbeddedDatabaseLocationMigrationOptions, ): Promise => { const sourceDirectory = plugins.path.resolve(optionsArg.sourceDirectory); const previousDestinationDirectory = plugins.path.resolve( optionsArg.previousDestinationDirectory, ); const destinationDirectory = plugins.path.resolve(optionsArg.destinationDirectory); const previousJournalPath = plugins.path.join( plugins.path.dirname(previousDestinationDirectory), journalFileName, ); const destinationJournalPath = plugins.path.join( plugins.path.dirname(destinationDirectory), journalFileName, ); const previous = await readJournal(previousJournalPath); if (!previous || previous.phase !== 'completed') { throw new Error('The embedded database migration journal is not completed for rebasing.'); } assertJournalMatches(previous, { sourceDirectory, destinationDirectory: previousDestinationDirectory, sourceSocketPath: '', destinationSocketPath: '', }); const destinationIdentity = await directoryIdentity(destinationDirectory); if (!destinationIdentity || !identitiesEqual(destinationIdentity, { device: previous.destinationDevice, inode: previous.destinationInode, })) { throw new Error('The rebased embedded database directory identity does not match its journal.'); } const rebased: ICompletedMigrationJournal = { ...previous, destinationDirectory, destinationDevice: destinationIdentity.device, destinationInode: destinationIdentity.inode, }; const existing = await readJournal(destinationJournalPath); if (existing) { if ( existing.phase !== 'completed' || existing.sourceDirectory !== rebased.sourceDirectory || existing.destinationDirectory !== rebased.destinationDirectory || existing.device !== rebased.device || existing.inode !== rebased.inode || existing.destinationDevice !== rebased.destinationDevice || existing.destinationInode !== rebased.destinationInode || existing.contentDigestSha256 !== rebased.contentDigestSha256 ) { throw new Error('The rebased embedded database migration journal conflicts with its source.'); } return; } await writeJournal(destinationJournalPath, rebased); const persisted = await readJournal(destinationJournalPath); if ( !persisted || persisted.phase !== 'completed' || persisted.sourceDirectory !== rebased.sourceDirectory || persisted.destinationDirectory !== rebased.destinationDirectory || persisted.destinationDevice !== rebased.destinationDevice || persisted.destinationInode !== rebased.destinationInode || persisted.contentDigestSha256 !== rebased.contentDigestSha256 ) { throw new Error('The rebased embedded database migration journal was not persisted.'); } }; const databaseNamesFromCollections = ( collectionsArg: plugins.smartdb.ICollectionInfo[], ): string[] => [...new Set(collectionsArg.map((collection) => collection.db))].sort(); const assertDatabaseNamesMatch = ( sourceArg: string[], destinationArg: string[], ): void => { if ( sourceArg.length !== destinationArg.length || sourceArg.some((databaseName, index) => databaseName !== destinationArg[index]) ) { throw new Error('The migrated SmartDB root contains a different logical database set.'); } }; const aggregateContentDigests = ( digestsArg: Map, ): string => { const hash = plugins.crypto.createHash('sha256'); for (const [databaseName, digest] of [...digestsArg.entries()].sort(([left], [right]) => ( left < right ? -1 : left > right ? 1 : 0 ))) { hash.update(databaseName, 'utf8'); hash.update('\0'); hash.update(digest.sha256, 'ascii'); hash.update('\0'); } return hash.digest('hex'); }; const assertCurrentStorageFormat = async (directoryArg: string): Promise => { const databaseEntries = await plugins.fs.promises.readdir(directoryArg, { withFileTypes: true }); for (const databaseEntry of databaseEntries) { if (!databaseEntry.isDirectory() || databaseEntry.name.startsWith('.__rustdb_')) continue; const collectionEntries = await plugins.fs.promises.readdir( plugins.path.join(directoryArg, databaseEntry.name), { withFileTypes: true }, ); if (collectionEntries.some((entry) => ( entry.isFile() && entry.name.endsWith('.json') && !entry.name.endsWith('.indexes.json') ))) { throw new Error( `The legacy controller database uses an old SmartDB storage format at ${directoryArg}. ` + 'Start it once with the previous controller version before upgrading.', ); } } }; const assertDigestMatch = ( sourceArg: plugins.smartdb.ISmartDbDatabaseContentDigest, destinationArg: plugins.smartdb.ISmartDbDatabaseContentDigest, ): void => { if ( sourceArg.format !== destinationArg.format || sourceArg.algorithm !== destinationArg.algorithm || sourceArg.sha256 !== destinationArg.sha256 || sourceArg.scannedBsonBytes !== destinationArg.scannedBsonBytes || sourceArg.collections !== destinationArg.collections || sourceArg.documents !== destinationArg.documents || sourceArg.indexes !== destinationArg.indexes ) { throw new Error('The migrated controller database content digest does not match its source.'); } }; export class EmbeddedDatabaseLocationMigration { private readonly journalPath: string; private sourceDb?: plugins.smartdb.LocalSmartDb; private importDestinationDb?: plugins.smartdb.LocalSmartDb; private operationalDestinationDb?: plugins.smartdb.LocalSmartDb; constructor(private readonly options: IEmbeddedDatabaseLocationMigrationOptions) { this.options = { ...options, sourceDirectory: plugins.path.resolve(options.sourceDirectory), destinationDirectory: plugins.path.resolve(options.destinationDirectory), }; this.journalPath = plugins.path.join( plugins.path.dirname(this.options.destinationDirectory), journalFileName, ); } public get hasOwnedResources(): boolean { return Boolean(this.sourceDb || this.importDestinationDb || this.operationalDestinationDb); } public async stop(): Promise { const errors: unknown[] = []; for (const entry of [ ['operationalDestinationDb', this.operationalDestinationDb], ['importDestinationDb', this.importDestinationDb], ['sourceDb', this.sourceDb], ] as const) { const [key, database] = entry; if (!database) continue; try { await database.stop(); if (this[key] === database) this[key] = undefined; } catch (errorArg) { errors.push(errorArg); } } if (errors.length === 1) throw errors[0]; if (errors.length > 1) { throw new AggregateError(errors, 'Embedded database migration cleanup failed.'); } } public async run(): Promise { if (this.options.sourceDirectory === this.options.destinationDirectory) return undefined; let journal = await readJournal(this.journalPath); if (journal) assertJournalMatches(journal, this.options); const sourceIdentity = await directoryIdentity(this.options.sourceDirectory); const destinationIdentity = await directoryIdentity(this.options.destinationDirectory); if (journal?.phase === 'completed') { if (!destinationIdentity) { throw new Error( `The migrated controller database is missing at ${this.options.destinationDirectory}. ` + `The retained legacy backup at ${this.options.sourceDirectory} was not restored automatically.`, ); } if (!identitiesEqual(destinationIdentity, { device: journal.destinationDevice, inode: journal.destinationInode, })) { throw new Error('The migrated controller database directory identity has changed.'); } return undefined; } if (!journal) { if (!sourceIdentity) return undefined; if (destinationIdentity) { throw new Error( `Both the legacy and current controller database directories exist: ` + `${this.options.sourceDirectory} and ${this.options.destinationDirectory}. ` + 'Refusing to choose between them without a migration journal.', ); } journal = { version: migrationVersion, phase: 'prepared', sourceDirectory: this.options.sourceDirectory, destinationDirectory: this.options.destinationDirectory, ...sourceIdentity, }; await writeJournal(this.journalPath, journal); journal = await readJournal(this.journalPath); if (!journal) throw new Error('The embedded database migration journal was not persisted.'); assertJournalMatches(journal, this.options); } if (journal.phase !== 'prepared') { throw new Error('The embedded database migration journal is not resumable.'); } if (!sourceIdentity || !identitiesEqual(sourceIdentity, journal)) { throw new Error('The legacy controller database directory identity has changed.'); } if (await isSocketListening(this.options.sourceSocketPath)) { throw new Error( `The legacy controller database is still in use at ${this.options.sourceDirectory}. ` + 'Stop the old controller before starting this version so its data can be migrated safely.', ); } await assertCurrentStorageFormat(this.options.sourceDirectory); try { this.sourceDb = new plugins.smartdb.LocalSmartDb({ folderPath: this.options.sourceDirectory, }); await this.sourceDb.start(); const sourceServer = this.sourceDb.getServer(); const sourceDatabaseNames = databaseNamesFromCollections( await sourceServer.getCollections(), ); const sourceDigests = new Map(); this.importDestinationDb = new plugins.smartdb.LocalSmartDb({ folderPath: this.options.destinationDirectory, }); await this.importDestinationDb.start(); const destinationServer = this.importDestinationDb.getServer(); for (const databaseName of sourceDatabaseNames) { const exported = await sourceServer.exportDatabase({ databaseName }); const sourceDigest = await sourceServer.getDatabaseContentDigest({ databaseName }); const importResult = await destinationServer.importDatabase({ databaseName, source: exported, }); const destinationDigest = await destinationServer.getDatabaseContentDigest({ databaseName }); assertDigestMatch(sourceDigest, destinationDigest); const exportedDocuments = exported.collections.reduce( (sum, collection) => sum + collection.documents.length, 0, ); const exportedIndexes = exported.collections.reduce( (sum, collection) => sum + collection.indexes.length, 0, ); if ( importResult.collections !== exported.collections.length || importResult.documents !== exportedDocuments || sourceDigest.collections !== exported.collections.length || sourceDigest.documents !== exportedDocuments || sourceDigest.indexes !== exportedIndexes ) { throw new Error(`The migrated database '${databaseName}' counts do not match its export.`); } sourceDigests.set(databaseName, sourceDigest); } assertDatabaseNamesMatch( sourceDatabaseNames, databaseNamesFromCollections(await destinationServer.getCollections()), ); await this.importDestinationDb.stop(); this.importDestinationDb = undefined; this.operationalDestinationDb = new plugins.smartdb.LocalSmartDb({ folderPath: this.options.destinationDirectory, socketPath: this.options.destinationSocketPath, }); const operationalConnection = await this.operationalDestinationDb.start(); const operationalServer = this.operationalDestinationDb.getServer(); assertDatabaseNamesMatch( sourceDatabaseNames, databaseNamesFromCollections(await operationalServer.getCollections()), ); for (const [databaseName, sourceDigest] of sourceDigests) { assertDigestMatch( sourceDigest, await operationalServer.getDatabaseContentDigest({ databaseName }), ); } const completedDestinationIdentity = await directoryIdentity(this.options.destinationDirectory); if (!completedDestinationIdentity) { throw new Error('The migrated controller database directory disappeared before completion.'); } const completed: ICompletedMigrationJournal = { ...journal, phase: 'completed', destinationDevice: completedDestinationIdentity.device, destinationInode: completedDestinationIdentity.inode, contentDigestSha256: aggregateContentDigests(sourceDigests), }; await writeJournal(this.journalPath, completed); await this.sourceDb.stop(); this.sourceDb = undefined; const result = { localDb: this.operationalDestinationDb, connectionUri: operationalConnection.connectionUri, }; this.operationalDestinationDb = undefined; return result; } catch (errorArg) { try { await this.stop(); } catch (cleanupErrorArg) { throw new AggregateError( [errorArg, cleanupErrorArg], 'Embedded database migration failed and cleanup was incomplete.', { cause: errorArg }, ); } throw errorArg; } } }