Merge pull request #77 from open-reception/60-autogenerate-main-database-for-initial-setup

60 - Generate and migrate central database on startup if needed. Adde…
This commit is contained in:
Karl Ludwig Weise
2025-09-09 17:58:10 +02:00
committed by GitHub
4 changed files with 339 additions and 7 deletions
@@ -0,0 +1,292 @@
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { UniversalLogger } from "$lib/logger";
import { ValidationError } from "$lib/server/utils/errors";
import { env } from "$env/dynamic/private";
const logger = new UniversalLogger().setContext("CentralDatabaseMigrationService");
export interface CentralDatabaseConfig {
host: string;
port: number;
database: string;
username: string;
password: string;
}
export class CentralDatabaseMigrationService {
/**
* Create a new database
*/
private static async createCentralDatabase(config: CentralDatabaseConfig): Promise<void> {
logger.info("Creating central database", { database: config.database });
// Connect to postgres database to create the new central database
const adminConnectionString = `postgres://${config.username}:${config.password}@${config.host}:${config.port}/postgres`;
const adminClient = postgres(adminConnectionString);
try {
// Create the database
await adminClient.unsafe(`CREATE DATABASE "${config.database}"`);
logger.info("Central database created successfully", { database: config.database });
} catch (error) {
if (error instanceof Error && error.message.includes("already exists")) {
logger.warn("Central database already exists", { database: config.database });
} else {
logger.error("Failed to create central database", {
database: config.database,
error: String(error),
});
throw error;
}
} finally {
await adminClient.end();
}
}
/**
* Initialize central database with schema
*/
private static async initializeCentralSchema(config: CentralDatabaseConfig): Promise<void> {
logger.info("Initializing central database schema", { database: config.database });
const connectionString = `postgres://${config.username}:${config.password}@${config.host}:${config.port}/${config.database}`;
const client = postgres(connectionString);
try {
// Create drizzle instance for the central database
const db = drizzle(client);
// Get the path to central migrations
const migrationsPath = join(process.cwd(), "migrations");
// Apply migrations
await migrate(db, { migrationsFolder: migrationsPath });
logger.info("Central database schema initialized successfully", {
database: config.database,
});
} catch (error) {
logger.error("Failed to initialize central database schema", {
database: config.database,
error: String(error),
});
throw error;
} finally {
await client.end();
}
}
/**
* Check if central database exists
*/
private static async centralDatabaseExists(config: CentralDatabaseConfig): Promise<boolean> {
const adminConnectionString = `postgres://${config.username}:${config.password}@${config.host}:${config.port}/postgres`;
const adminClient = postgres(adminConnectionString);
try {
const result = await adminClient`
SELECT 1 FROM pg_database WHERE datname = ${config.database}
`;
return result.length > 0;
} catch (error) {
logger.error("Failed to check central database existence", {
database: config.database,
error: String(error),
});
return false;
} finally {
await adminClient.end();
}
}
/**
* Get current schema version for the central database
*/
private static async getCentralSchemaVersion(
config: CentralDatabaseConfig,
): Promise<string | null> {
const connectionString = `postgres://${config.username}:${config.password}@${config.host}:${config.port}/${config.database}`;
const client = postgres(connectionString);
try {
// Check if migrations table exists
const tableExists = await client`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = '__drizzle_migrations'
);
`;
if (!tableExists[0].exists) {
return null;
}
// Get the latest migration
const result = await client`
SELECT hash FROM __drizzle_migrations
ORDER BY created_at DESC
LIMIT 1
`;
return result.length > 0 ? result[0].hash : null;
} catch (error) {
logger.error("Failed to get central schema version", {
database: config.database,
error: String(error),
});
return null;
} finally {
await client.end();
}
}
/**
* Get the latest available migration hash for central database
*/
private static getLatestCentralMigrationHash(): string | null {
try {
const migrationsPath = join(process.cwd(), "migrations");
const metaPath = join(migrationsPath, "meta", "_journal.json");
if (!existsSync(metaPath)) {
return null;
}
const journal = JSON.parse(readFileSync(metaPath, "utf-8"));
const entries = journal.entries || [];
if (entries.length === 0) {
return null;
}
// Get the latest entry
const latestEntry = entries[entries.length - 1];
return latestEntry.hash || null;
} catch (error) {
logger.error("Failed to get latest central migration hash", { error: String(error) });
return null;
}
}
/**
* Check if central database needs migration
*/
private static async centralNeedsMigration(config: CentralDatabaseConfig): Promise<boolean> {
const currentVersion = await this.getCentralSchemaVersion(config);
const latestVersion = this.getLatestCentralMigrationHash();
if (!latestVersion) {
return false; // No migrations available
}
if (!currentVersion) {
return true; // Database has no schema yet
}
return currentVersion !== latestVersion;
}
/**
* Apply pending migrations to the central database
*/
private static async migrateCentralDatabase(config: CentralDatabaseConfig): Promise<void> {
logger.info("Migrating central database", { database: config.database });
const connectionString = `postgres://${config.username}:${config.password}@${config.host}:${config.port}/${config.database}`;
const client = postgres(connectionString);
try {
const db = drizzle(client);
const migrationsPath = join(process.cwd(), "migrations");
await migrate(db, { migrationsFolder: migrationsPath });
logger.info("Central database migrated successfully", { database: config.database });
} catch (error) {
logger.error("Failed to migrate central database", {
database: config.database,
error: String(error),
});
throw error;
} finally {
await client.end();
}
}
/**
* Create central database configuration from DATABASE_URL environment variable
*/
private static parseCentralDatabaseUrl(): CentralDatabaseConfig {
if (!env.DATABASE_URL) {
throw new ValidationError("DATABASE_URL environment variable is not set");
}
try {
const url = new URL(env.DATABASE_URL);
return {
host: url.hostname,
port: parseInt(url.port) || 5432,
database: url.pathname.slice(1), // Remove leading slash
username: url.username,
password: url.password,
};
} catch {
throw new ValidationError(`Invalid DATABASE_URL: ${env.DATABASE_URL}`);
}
}
/**
* Create a complete central database with schema
*/
private static async createAndInitializeCentralDatabase(): Promise<void> {
const config = this.parseCentralDatabaseUrl();
// Create the database
await this.createCentralDatabase(config);
// Initialize the schema
await this.initializeCentralSchema(config);
logger.info("Central database created and initialized", {
database: config.database,
host: config.host,
port: config.port,
});
}
/**
* Ensure central database is up to date
*/
static async ensureCentralDatabaseUpToDate(): Promise<void> {
const config = this.parseCentralDatabaseUrl();
logger.info("Checking central database status", { database: config.database });
// Check if database exists
const exists = await this.centralDatabaseExists(config);
if (!exists) {
logger.info("Central database does not exist, creating...", { database: config.database });
await this.createAndInitializeCentralDatabase();
return;
}
logger.info("Central database exists, checking for pending migrations", {
database: config.database,
});
// Check if migration is needed
const needsMigration = await this.centralNeedsMigration(config);
if (needsMigration) {
logger.info("Central database needs migration, applying...", { database: config.database });
await this.migrateCentralDatabase(config);
} else {
logger.info("Central database is up to date", { database: config.database });
}
}
}
+20 -1
View File
@@ -1,6 +1,7 @@
import { centralDb } from "../db";
import { tenant } from "../db/central-schema";
import { TenantMigrationService } from "./tenant-migration-service";
import { CentralDatabaseMigrationService } from "./central-database-migration-service";
import { UniversalLogger } from "$lib/logger";
const logger = new UniversalLogger().setContext("StartupService");
@@ -21,7 +22,10 @@ export class StartupService {
logger.info("Starting application initialization");
try {
// Check and migrate all tenant databases
// First ensure central database is up to date
await this.ensureCentralDatabase();
// Then check and migrate all tenant databases
await this.migrateTenantDatabases();
this.initialized = true;
@@ -32,6 +36,21 @@ export class StartupService {
}
}
/**
* Ensure central database exists and is up to date
*/
private static async ensureCentralDatabase(): Promise<void> {
logger.info("Ensuring central database is up to date");
try {
await CentralDatabaseMigrationService.ensureCentralDatabaseUpToDate();
logger.info("Central database is ready");
} catch (error) {
logger.error("Failed to setup central database", { error: String(error) });
throw error;
}
}
/**
* Check and migrate all tenant databases
*/
@@ -13,12 +13,16 @@ import { sendTenantAdminInviteEmail } from "../email/email-service";
if (!env.DATABASE_URL) throw new Error("DATABASE_URL is not set");
const tentantCreationSchema = z.object({
shortName: z.string().min(4).max(15),
const tenantCreationSchema = z.object({
shortName: z
.string()
.min(4)
.max(15)
.regex(/^[a-z][a-z-]*[a-z]$/),
inviteAdmin: z.email().optional(),
});
export type TenantCreationRequest = z.infer<typeof tentantCreationSchema>;
export type TenantCreationRequest = z.infer<typeof tenantCreationSchema>;
export interface TenantConfiguration extends Record<string, string | number | boolean> {
brandColor: string;
@@ -60,7 +64,7 @@ export class TenantAdminService {
static async createTenant(request: TenantCreationRequest) {
const log = logger.setContext("TenantAdminService");
const validation = tentantCreationSchema.safeParse(request);
const validation = tenantCreationSchema.safeParse(request);
if (!validation.success) throw new ValidationError("Invalid tenant creation request");
@@ -237,6 +241,15 @@ export class TenantAdminService {
updateFields: Object.keys(updateData),
});
if (updateData.shortName) {
const shortNameValidation = tenantCreationSchema.shape.shortName.safeParse(
updateData.shortName,
);
if (!shortNameValidation.success) {
throw new ValidationError("Invalid shortName format");
}
}
try {
const result = await centralDb
.update(centralSchema.tenant)
@@ -257,19 +257,27 @@ export class TenantMigrationService {
static async ensureTenantDatabaseUpToDate(databaseUrl: string): Promise<void> {
const config = this.parseDatabaseUrl(databaseUrl);
logger.info("Checking tenant database status", { database: config.database });
// Check if database exists
const exists = await this.tenantDatabaseExists(config);
if (!exists) {
logger.info("Tenant database does not exist, creating...", { database: config.database });
await this.createAndInitializeTenantDatabase(databaseUrl);
return;
}
logger.info("Tenant database exists, checking for pending migrations", {
database: config.database,
});
// Check if migration is needed
const needsMigration = await this.tenantNeedsMigration(config);
if (needsMigration) {
logger.info("Tenant database needs migration, applying...", { database: config.database });
await this.migrateTenantDatabase(config);
} else {
logger.info("Tenant database is up to date", { database: config.database });
}
logger.debug("Tenant database is up to date", { database: config.database });
}
}