#!/usr/bin/env node /** * Generate the greenfield base schema (000_base_schema.sql) by letting TypeORM * `synchronize` build every table from the entities against a throwaway * Postgres, then dumping the schema. Run when entities change materially: * * docker run -d --name ch-schemagen -e POSTGRES_PASSWORD=pass \ * -e POSTGRES_USER=cloudhost -e POSTGRES_DB=cloudhost \ * -p 55432:5432 postgres:16-alpine * node scripts/generate-base-schema.mjs * * The output is wrapped so it is safe to run on an already-populated database * (every statement uses IF NOT EXISTS / duplicate_object guards where possible; * the migration runner also records it in schema_migrations so it runs once). */ import 'reflect-metadata'; import { DataSource } from 'typeorm'; import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const backendRoot = path.resolve(__dirname, '..'); const outPath = path.join(backendRoot, 'migrations', '000_base_schema.sql'); // Use the COMPILED entities (run `npm run build` first) — union-typed columns // only carry correct decorator metadata through the project's tsc build. const ds = new DataSource({ type: 'postgres', host: process.env.SCHEMA_DB_HOST || '127.0.0.1', port: parseInt(process.env.SCHEMA_DB_PORT || '55432', 10), username: 'cloudhost', password: 'pass', database: 'cloudhost', entities: [path.join(backendRoot, 'dist/**/*.entity.js')], synchronize: true, }); await ds.initialize(); await ds.destroy(); // Dump schema-only from the container, then strip owner/ACL noise. const dumped = execFileSync('docker', [ 'exec', 'ch-schemagen', 'pg_dump', '-U', 'cloudhost', '-d', 'cloudhost', '--schema-only', '--no-owner', '--no-privileges', ], { maxBuffer: 32 * 1024 * 1024 }).toString(); // Strip: // - psql client meta-commands that are version-specific (\restrict is // pg_dump 16.13+ only) and would break on the migrations image's psql; // - the `search_path = ''` reset, which otherwise persists into the trailing // `INSERT INTO schema_migrations` the runner appends (unqualified) and the // footer below, causing "no schema has been selected to create in". const raw = dumped .split('\n') .filter( (line) => !/^\\(restrict|unrestrict)\b/.test(line) && !/set_config\('search_path'/.test(line), ) .join('\n'); const header = `-- 000_base_schema.sql — greenfield base schema (generated from TypeORM entities). -- Auto-generated by scripts/generate-base-schema.mjs. Do not edit by hand. -- Incremental migrations (001+) run afterwards on top of this schema. `; // The legacy pricing-catalog migrations (004-009) target a superseded // snake_case pricing schema that is incompatible with the current entities. // On greenfield the base schema already creates the entity-shaped pricing // tables and the app self-seeds their rows (PricingCatalogService.ensureDefaults // on boot), so mark those migrations as already applied to skip them. const supersededPricingMigrations = [ '004_pricing_catalog.sql', '005_pricing_catalog_all_runtimes.sql', '006_addon_rate_resources.sql', '007_optional_service_pricing_matrix.sql', '008_application_optional_service_resources.sql', '009_optional_service_deploy_defaults.sql', ]; const footer = ` -- Mark superseded legacy pricing migrations as applied (see generator note). CREATE TABLE IF NOT EXISTS schema_migrations (filename TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()); INSERT INTO schema_migrations (filename) VALUES ${supersededPricingMigrations.map((m) => ` ('${m}')`).join(',\n')} ON CONFLICT (filename) DO NOTHING; `; fs.writeFileSync(outPath, header + raw + footer); console.log(`Wrote ${outPath} (${raw.length} bytes)`);