import { collabEnvSchema, parseEnv } from '@dorfteich/shared'; import { Client } from 'pg'; import { createAccessListener } from './access-listener.js'; import { createPool, pingDatabase } from './db.js'; import { createLogger } from './logger.js'; import { createMaintenanceListener, type MaintenanceListener } from './maintenance-listener.js'; import { PostgresPagePersistence } from './persistence.js'; import { createRestoreListener } from './restore-listener.js'; import { createTaskToggleListener } from './task-toggle-listener.js'; import { closeDocumentConnections, createCollabServer } from './server.js'; import { PostgresSessionRegistry } from './session-registry.js'; import { PostgresVersionStore } from './version-store.js'; /** * Entry point of the collaboration server (ADR 0003). Validates the * environment, opens a database pool for the health probe, and starts the * Hocuspocus server. A clean shutdown closes both so the container stops fast. */ async function bootstrap(): Promise { // Validate configuration up front; crash with a readable list on problems. const env = parseEnv(collabEnvSchema, process.env); const logger = createLogger(env); const pool = createPool(env.DATABASE_URL); // Created before the server (the server's hooks call markOpen/markClosed); // its heartbeat gets the server's open-document accessor at start() below, // which sidesteps the mutual reference between the two. const sessionRegistry = new PostgresSessionRegistry({ pool, logger }); const versionStore = new PostgresVersionStore({ pool, logger }); // Persists + closes all sessions before the backup sidecar replaces the // database, and refuses new ones until the restore is over (issue #103). // The close action is bound after the server exists (mutual reference, // same trick as the session registry above). let closeAllConnections = (): void => {}; const maintenanceListener: MaintenanceListener = createMaintenanceListener({ createClient: () => new Client({ connectionString: env.DATABASE_URL }), closeAllConnections: () => closeAllConnections(), logger, }); const server = createCollabServer({ version: env.APP_VERSION, logger, tokenSecret: env.COLLAB_TOKEN_SECRET, pingDatabase: () => pingDatabase(pool), persistence: new PostgresPagePersistence(pool), sessionRegistry, versionStore, isMaintenanceActive: () => maintenanceListener.isActive(), }); closeAllConnections = () => { for (const documentName of server.hocuspocus.documents.keys()) { closeDocumentConnections(server.hocuspocus, documentName); } }; // Terminate live sessions when access to a pond is revoked (issue #39). The // listener owns a dedicated connection because `LISTEN` is connection-bound // and cannot be served from the pool. const accessListener = createAccessListener({ createClient: () => new Client({ connectionString: env.DATABASE_URL }), pool, openDocumentNames: () => [...server.hocuspocus.documents.keys()], closeConnections: (documentName) => closeDocumentConnections(server.hocuspocus, documentName), logger, }); // Applies api-requested restores to the live document (issue #42). const restoreListener = createRestoreListener({ createClient: () => new Client({ connectionString: env.DATABASE_URL }), pool, openDirectConnection: (documentName) => server.hocuspocus.openDirectConnection(documentName, { userId: 'restore', mode: 'rw' }), logger, }); // Applies api-requested task-checkbox toggles (issue #153). const taskToggleListener = createTaskToggleListener({ createClient: () => new Client({ connectionString: env.DATABASE_URL }), openDirectConnection: (documentName) => server.hocuspocus.openDirectConnection(documentName, { userId: 'task-toggle', mode: 'rw' }), logger, }); await server.listen(env.PORT); await accessListener.start(); await restoreListener.start(); await taskToggleListener.start(); await maintenanceListener.start(); sessionRegistry.start(() => [...server.hocuspocus.documents.keys()]); logger.info({ event: 'listen', port: env.PORT }, 'collaboration server listening'); const shutdown = (signal: NodeJS.Signals): void => { logger.info({ event: 'shutdown', signal }, 'shutting down'); sessionRegistry.stop(); void Promise.allSettled([ accessListener.stop(), restoreListener.stop(), taskToggleListener.stop(), maintenanceListener.stop(), server.destroy(), pool.end(), ]).then(() => process.exit(0)); }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); } void bootstrap();