From 52a80c7768cb76997e20e44c5cbeb5f09f144304 Mon Sep 17 00:00:00 2001 From: melvinchia3636 Date: Tue, 9 Sep 2025 16:48:51 +0800 Subject: [PATCH] feat(server): implement database connection and collection validation during server starting process Former-commit-id: 68c3a1e5867c1b34f4e439a6735e4d094c4aef08 [formerly c356ba449e4b90d8a585a4fe1d5509f3f5a9f77c] [formerly f722aab091f6d05405b65d46c9c4fbbefe856f5c [formerly 555c7a1db5910c3b0187357202a7663db72a5489]] Former-commit-id: 6ba8b762e82e176a08e0dd1924c81ce66d4f6546 [formerly 1738ea41e2ab3e4134aee976befbef5d0f261512] Former-commit-id: e72ccd9862d331cfc51f3d5f2564bdec134d4dc1 --- scripts/generateCollectionsSchemas.ts | 14 +- server/src/core/app.ts | 5 - server/src/core/functions/utils/checkDB.ts | 171 +++++++++++++++++++++ server/src/index.ts | 22 ++- 4 files changed, 198 insertions(+), 14 deletions(-) create mode 100644 server/src/core/functions/utils/checkDB.ts diff --git a/scripts/generateCollectionsSchemas.ts b/scripts/generateCollectionsSchemas.ts index 3381a9597..72dadfa96 100644 --- a/scripts/generateCollectionsSchemas.ts +++ b/scripts/generateCollectionsSchemas.ts @@ -20,13 +20,6 @@ if (!process.env.PB_HOST || !process.env.PB_EMAIL || !process.env.PB_PASSWORD) { const pb = new Pocketbase(process.env.PB_HOST) -let MAIN_SCHEMA_EXPORTS = `import flattenSchemas from '@functions/utils/flattenSchema' - -export const SCHEMAS = { -` - -const moduleSchemas: Record = {} - try { await pb .collection('_superusers') @@ -41,6 +34,13 @@ try { process.exit(1) } +let MAIN_SCHEMA_EXPORTS = `import flattenSchemas from '@functions/utils/flattenSchema' + +export const SCHEMAS = { +` + +const moduleSchemas: Record = {} + const allModules = fs.readdirSync('./server/src/lib', { withFileTypes: true }) const modulesMap: Record = {} diff --git a/server/src/core/app.ts b/server/src/core/app.ts index 1a680a39d..2e91c24e3 100644 --- a/server/src/core/app.ts +++ b/server/src/core/app.ts @@ -1,5 +1,4 @@ import cors from 'cors' -import dotenv from 'dotenv' import express from 'express' import helmet from 'helmet' @@ -8,10 +7,6 @@ import rateLimitingMiddleware from './middlewares/rateLimitingMiddleware' import router from './routes' import { CORS_ALLOWED_ORIGINS } from './routes/constants/corsAllowedOrigins' -dotenv.config({ - path: './env/.env.local' -}) - const app = express() // Security headers diff --git a/server/src/core/functions/utils/checkDB.ts b/server/src/core/functions/utils/checkDB.ts new file mode 100644 index 000000000..221999478 --- /dev/null +++ b/server/src/core/functions/utils/checkDB.ts @@ -0,0 +1,171 @@ +import { LoggingService } from '@functions/logging/loggingService' +import COLLECTION_SCHEMAS from '@schema' +import Pocketbase from 'pocketbase' + +interface DBConnectionConfig { + host: string + email: string + password: string +} + +interface CollectionValidationResult { + isValid: boolean + missingCollections: string[] + totalCollections: number +} + +class DatabaseConnectionError extends Error { + constructor( + message: string, + public readonly cause?: Error + ) { + super(message) + this.name = 'DatabaseConnectionError' + } +} + +class DatabaseValidationError extends Error { + constructor( + message: string, + public readonly missingCollections: string[] + ) { + super(message) + this.name = 'DatabaseValidationError' + } +} + +/** + * Validates the required environment variables for database connection + * @throws {DatabaseConnectionError} When required environment variables are missing + */ +function validateEnvironmentVariables(): DBConnectionConfig { + const { PB_HOST, PB_EMAIL, PB_PASSWORD } = process.env + + if (!PB_HOST || !PB_EMAIL || !PB_PASSWORD) { + throw new DatabaseConnectionError( + 'Missing required environment variables: PB_HOST, PB_EMAIL, and PB_PASSWORD must be provided' + ) + } + + return { host: PB_HOST, email: PB_EMAIL, password: PB_PASSWORD } +} + +/** + * Establishes and validates connection to PocketBase with superuser privileges + * @param config Database connection configuration + * @returns Authenticated PocketBase instance + * @throws {DatabaseConnectionError} When connection or authentication fails + */ +async function connectToPocketBase( + config: DBConnectionConfig +): Promise { + const pb = new Pocketbase(config.host) + + try { + await pb + .collection('_superusers') + .authWithPassword(config.email, config.password) + + if (!pb.authStore.isSuperuser || !pb.authStore.isValid) { + throw new DatabaseConnectionError( + 'Authentication failed: Invalid credentials or insufficient privileges' + ) + } + + LoggingService.info('Successfully connected to PocketBase', 'DB') + + return pb + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' + + throw new DatabaseConnectionError( + `Failed to connect to PocketBase: ${errorMessage}`, + error instanceof Error ? error : undefined + ) + } +} + +/** + * Maps collection names to their target names in PocketBase + * Handles special cases like users__users -> users + */ +function mapCollectionName(collectionName: string): string { + return collectionName === 'users__users' ? 'users' : collectionName +} + +/** + * Validates that all required collections exist in PocketBase + * @param pb Authenticated PocketBase instance + * @returns Validation result with details about missing collections + */ +async function validateCollections( + pb: Pocketbase +): Promise { + const allCollections = await pb.collections.getFullList() + + const existingCollectionNames = new Set(allCollections.map(c => c.name)) + + const requiredCollections = Object.keys(COLLECTION_SCHEMAS) + + const missingCollections: string[] = [] + + for (const collection of requiredCollections) { + const targetCollection = mapCollectionName(collection) + + if (!existingCollectionNames.has(targetCollection)) { + missingCollections.push(collection) + } + } + + return { + isValid: missingCollections.length === 0, + missingCollections, + totalCollections: requiredCollections.length + } +} + +/** + * Validates database connection and schema integrity + * Ensures PocketBase is accessible and contains all required collections + * + * @throws {DatabaseConnectionError} When connection fails + * @throws {DatabaseValidationError} When required collections are missing + */ +export default async function checkDB(): Promise { + try { + // Validate environment configuration + const config = validateEnvironmentVariables() + + // Establish database connection + const pb = await connectToPocketBase(config) + + // Validate collection schema + const validationResult = await validateCollections(pb) + + if (!validationResult.isValid) { + throw new DatabaseValidationError( + `Missing collections in PocketBase: ${validationResult.missingCollections.join(', ')}`, + validationResult.missingCollections + ) + } + + LoggingService.info( + `Database validation complete. All ${validationResult.totalCollections} collections are present`, + 'DB' + ) + } catch (error) { + if ( + error instanceof DatabaseConnectionError || + error instanceof DatabaseValidationError + ) { + LoggingService.error(error.message, 'DB') + } else { + LoggingService.error( + `Unexpected error during database validation: ${error}`, + 'DB' + ) + } + process.exit(1) + } +} diff --git a/server/src/index.ts b/server/src/index.ts index 539168a1f..5ca0e4c7f 100755 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,11 +1,26 @@ import { LoggingService } from '@functions/logging/loggingService' import { setupSocket } from '@functions/socketio/setupSocket' +import checkDB from '@functions/utils/checkDB' import traceRouteStack from '@functions/utils/traceRouteStack' +import dotenv from 'dotenv' import { createServer } from 'node:http' import { Server } from 'socket.io' import app from './core/app' +dotenv.config({ + path: './env/.env.local' +}) + +if (!process.env.MASTER_KEY) { + LoggingService.error( + 'Please provide MASTER_KEY in your environment variables.' + ) + process.exit(1) +} + +await checkDB() + const server = createServer(app) const io = new Server(server) @@ -19,6 +34,9 @@ app.request.io = io server.listen(process.env.PORT, () => { const routes = traceRouteStack(app._router.stack) - LoggingService.debug(`Registered routes: ${routes.length}`) - LoggingService.info(`REST API server running on port ${process.env.PORT}`) + LoggingService.debug(`Registered routes: ${routes.length}`, 'API') + LoggingService.info( + `REST API server running on port ${process.env.PORT}`, + 'API' + ) })