/** * Install-time database schema verification (#1392). * * Fresh standalone (postgres) installs once ended "healthy" with a completely * empty database: the resolved dependency set shipped no migrations at all * (#1389), the gateway started fine, and nothing failed until the first real * query. The issue's own conclusion: the installer must "instruct and verify". * * This check runs AFTER migrations have been (re-)applied, so the only way it * fails is a genuinely broken migration set or database — which is exactly * when install must not report success. Failure is fatal by design (fast-fail * STANDARDS); callers print the remediation text and exit non-zero. */ import { existsSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; import { loadConfig } from '@mosaicstack/config'; export interface SchemaStatusCounts { appliedCount: number; expectedCount: number; expectedLastTag: string; complete: boolean; } /** Injectable migration surface — keeps this unit-testable without a DB. */ export interface SchemaCheckDeps { runMigrations(url: string): Promise; getMigrationStatus(url: string): Promise; } export type SchemaCheckResult = | { status: 'ok'; detail: string } | { status: 'skipped'; detail: string } | { status: 'fail'; detail: string; remediation: string }; export const SCHEMA_FAIL_REMEDIATION = [ 'The gateway database does not carry the full schema.', 'Causes seen in the wild: dependency set resolved without migrations (#1389), or a partially-migrated database (#1402).', 'Remediation:', ' 1. Re-run: mosaic gateway install (applies migrations and verifies again)', ' 2. Check the resolved @mosaicstack/db version is the same pipeline as the gateway (npm ls -g @mosaicstack/db)', ' 3. Manual apply: run runMigrations() from @mosaicstack/db against the storage URL, then re-verify', ].join('\n'); /** * Resolve the config the INSTALLED gateway would use — same priority the * daemon applies (apps/gateway/src/env.ts resolveGatewayConfigPath), minus * the source-tree anchors that do not exist on an installed host. Verifying * against any other config could green-light a database the daemon never * reads (#1392: verify what runs, not what happens to lie in cwd). */ export function resolveSchemaCheckConfigPath(explicit?: string): string | undefined { if (explicit) return resolve(explicit); // NOTE: no env-var candidate, deliberately. apps/gateway/src/env.ts gives env // NO config authority (a stale MOSAIC_CONFIG could verify a database the // daemon never reads — rev-code-02 review 285, note N1). Resolution order // mirrors the daemon's file priorities only. const candidates = [ join(homedir(), '.config', 'mosaic', 'gateway', 'mosaic.config.json'), // daemon-written resolve(process.cwd(), 'mosaic.config.json'), join(homedir(), '.mosaic', 'mosaic.config.json'), ]; for (const c of candidates) { if (c && existsSync(c)) return c; } return undefined; // loadConfig falls back to env-var detection } export async function checkDatabaseSchema( deps: SchemaCheckDeps, configPath?: string, ): Promise { const config = loadConfig(resolveSchemaCheckConfigPath(configPath)); // Local tier: the gateway itself runs PGlite migrations at startup (see // DatabaseModule.onModuleInit), and a broken local tier fails the health // check instead. Nothing for the installer to verify here. if (config.storage.type !== 'postgres') { return { status: 'skipped', detail: 'database schema (local tier — migrated by gateway at startup)', }; } const url = config.storage.url; try { await deps.runMigrations(url); const status = await deps.getMigrationStatus(url); if (status.complete) { return { status: 'ok', detail: `database schema (${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations)`, }; } return { status: 'fail', detail: `database schema incomplete (${status.appliedCount.toString()}/${status.expectedCount.toString()} applied, last expected: ${status.expectedLastTag})`, remediation: SCHEMA_FAIL_REMEDIATION, }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { status: 'fail', detail: `database schema check errored: ${msg}`, remediation: SCHEMA_FAIL_REMEDIATION, }; } }