import * as plugins from './plugins.js'; import { EmbeddedDatabaseMigrationRunner, } from '../ts_migration/classes.migrationrunner.js'; import { embeddedDatabaseSocketPath, isSocketListening, } from './functions.embeddeddb.js'; export { embeddedDatabaseSocketPath } from './functions.embeddeddb.js'; export interface IEmbeddedControllerDatabaseOptions { /** directory holding the smartdb storage files; its hash names the unix socket */ dataDirectory: string; /** previous installed default, logically migrated only while its engine is stopped */ legacyDataDirectory?: string; } const lstatDirectory = async (directoryArg: string): Promise => { try { const stats = await plugins.fs.promises.lstat(directoryArg); if (!stats.isDirectory()) { throw new Error(`Controller database path must be a real directory: ${directoryArg}`); } return stats; } catch (errorArg) { if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw errorArg; } }; /** * The controller's embedded database engine: a @push.rocks/smartdb * LocalSmartDb over a unix socket in the AGL sockets directory. The socket * path is deterministic so a second controller process (e.g. the * temp-password CLI) attaches to the running daemon's engine instead of * opening the storage files a second time; only the process that booted * the engine stops it. */ export class EmbeddedControllerDatabase { private localDb?: plugins.smartdb.LocalSmartDb; private connectionUri?: string; private startPromise?: Promise; private migrationRunner?: EmbeddedDatabaseMigrationRunner; private resolvedSocketPath?: string; constructor(private readonly options: IEmbeddedControllerDatabaseOptions) {} public get socketPath(): string { // The hashed name is deterministic so another AGL process can attach to // the same engine without owning its storage files. It is resolved once: // the path names a bound socket and must not move under a running engine. this.resolvedSocketPath ??= embeddedDatabaseSocketPath(this.options.dataDirectory); return this.resolvedSocketPath; } public get ownsEngine(): boolean { return this.localDb !== undefined; } /** Starts (or attaches to) the engine and returns the smartdata-ready URI. */ public async start(): Promise { if (this.connectionUri) return this.connectionUri; if (this.startPromise) return this.startPromise; const startPromise = this.startOnce(); this.startPromise = startPromise; try { return await startPromise; } finally { if (this.startPromise === startPromise) this.startPromise = undefined; } } private async startOnce(): Promise { if (this.localDb || this.migrationRunner?.hasOwnedResources) { throw new Error('Embedded database startup cleanup must complete before retrying.'); } const socketDirectory = plugins.path.dirname(this.socketPath); await plugins.fs.promises.mkdir(socketDirectory, { recursive: true, mode: 0o700 }); await plugins.fs.promises.chmod(socketDirectory, 0o700); if (this.options.legacyDataDirectory) { const migrationRunner = new EmbeddedDatabaseMigrationRunner({ sourceDirectory: this.options.legacyDataDirectory, destinationDirectory: this.options.dataDirectory, sourceSocketPath: embeddedDatabaseSocketPath(this.options.legacyDataDirectory), destinationSocketPath: this.socketPath, }); this.migrationRunner = migrationRunner; try { const result = await migrationRunner.run(); if (result) { this.localDb = result.localDb; this.connectionUri = result.connectionUri; return result.connectionUri; } } finally { if (!migrationRunner.hasOwnedResources && this.migrationRunner === migrationRunner) { this.migrationRunner = undefined; } } } await plugins.fs.promises.mkdir(this.options.dataDirectory, { recursive: true, mode: 0o700, }); await lstatDirectory(this.options.dataDirectory); const socketUri = `mongodb://${encodeURIComponent(this.socketPath)}/?directConnection=true`; if (await this.isSocketListening()) { this.connectionUri = socketUri; return socketUri; } const localDb = new plugins.smartdb.LocalSmartDb({ folderPath: this.options.dataDirectory, socketPath: this.socketPath, }); this.localDb = localDb; try { await localDb.start(); } catch (errorArg) { try { await localDb.stop(); if (this.localDb === localDb) this.localDb = undefined; } catch (cleanupErrorArg) { throw new AggregateError( [errorArg, cleanupErrorArg], 'Embedded database startup failed and cleanup was incomplete.', { cause: errorArg }, ); } throw errorArg; } this.connectionUri = socketUri; return socketUri; } public async stop(): Promise { if (this.startPromise) { await this.startPromise.catch(() => undefined); } const localDb = this.localDb; const migrationRunner = this.migrationRunner; const errors: unknown[] = []; if (localDb) { try { await localDb.stop(); if (this.localDb === localDb) this.localDb = undefined; } catch (errorArg) { errors.push(errorArg); } } if (migrationRunner) { try { await migrationRunner.stop(); if (this.migrationRunner === migrationRunner) this.migrationRunner = undefined; } catch (errorArg) { errors.push(errorArg); } } if (errors.length === 0) this.connectionUri = undefined; if (errors.length === 1) throw errors[0]; if (errors.length > 1) { throw new AggregateError(errors, 'Embedded database shutdown was incomplete.'); } } private isSocketListening(): Promise { return isSocketListening(this.socketPath); } }