ci/woodpecker/pr/ci Pipeline was successful
N1: resolveSchemaCheckConfigPath drops the MOSAIC_CONFIG env candidate entirely (mirrors apps/gateway/src/env.ts's deliberate env-has-no-config-authority stance; a stale env var could verify a database the daemon never reads). verify.ts now passes undefined rather than the env var. New spec: a decoy MOSAIC_CONFIG must never win over the daemon-written config. N2: install treats a THROWN post-install verification as fatal (exit 1 with remediation pointer) in addition to the existing result-object hard-fail — an install must never report success over an unverified database, whichever way the check failed.
111 lines
4.4 KiB
TypeScript
111 lines
4.4 KiB
TypeScript
/**
|
|
* 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<void>;
|
|
getMigrationStatus(url: string): Promise<SchemaStatusCounts>;
|
|
}
|
|
|
|
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<SchemaCheckResult> {
|
|
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,
|
|
};
|
|
}
|
|
}
|