fix(#1392,#1402): hash-ledger migrations for both tiers + install-time schema verification

- runMigrations (postgres) no longer delegates to drizzle's postgres-js
  migrator: shared per-statement, skip-by-hash loop (applyMigrationsByHash)
  applies migrations in journal order regardless of 'when' timestamps.
  Fixes #1402 D1 (0009/0010 silently skipped on 0008-era upgrades —
  reproduced on real postgres: old path leaves 15/17, new path 17/17) and
  D2 (single-transaction wrap breaking 0009's ALTER TYPE sequence).
- runPgliteMigrations refactored onto the same core; behavior preserved
  (pre-existing PGlite migrate tests pass unchanged).
- New getMigrationStatus(): ledger-vs-journal completeness, read-only,
  never creates a ledger — an unmigrated DB reports 0/N (the #1389
  signature).
- Gateway startup (DatabaseModule.onModuleInit) verifies postgres schema
  after adapter.migrate() and REFUSES to start on incomplete (15/17-style)
  schemas, with remediation pointing at #1392/#1402.
- 'mosaic gateway install'/'verify' gain a database-schema check: postgres
  tiers get explicit runMigrations + completeness verification against the
  config the installed gateway actually resolves (daemon config priority).
  Install hard-fails on failed schema verification (fast-fail), closing the
  T63 failure mode where install reported success over an empty DB.
This commit is contained in:
2026-08-24 17:45:59 -05:00
parent d7b1dd9601
commit f1114b26b7
9 changed files with 796 additions and 74 deletions
@@ -0,0 +1,62 @@
import { describe, it, expect, vi } from 'vitest';
// The module under test imports @mosaicstack/db at module scope; we replace only the
// pieces DatabaseModule uses (partial mock — the real module also exports the
// schema the storage adapter's import chain needs) so the test pins the #1392
// contract (refuse to start on an incomplete schema) without a live database.
vi.mock('@mosaicstack/db', async (importOriginal) => {
const actual: object = await importOriginal();
return {
...actual,
createDb: vi.fn(),
createPgliteDb: vi.fn(),
getMigrationStatus: vi.fn(),
runPgliteMigrations: vi.fn(),
};
});
import { DatabaseModule } from './database.module.js';
import { getMigrationStatus } from '@mosaicstack/db';
function makeModule(storageType: 'postgres' | 'pglite', tier: string) {
const storageAdapter = { name: storageType, migrate: vi.fn(), close: vi.fn() };
const handle = { close: vi.fn() };
const config = { tier, storage: { type: storageType, url: 'postgresql://x' } };
return {
mod: new DatabaseModule(handle as any, storageAdapter as any, config as any),
storageAdapter,
};
}
describe('DatabaseModule.onModuleInit — #1392 schema verification', () => {
it('refuses to start when the postgres schema is incomplete', async () => {
const { mod, storageAdapter } = makeModule('postgres', 'standalone');
vi.mocked(getMigrationStatus).mockResolvedValue({
appliedCount: 15,
expectedCount: 17,
expectedLastTag: '0016_salty_morlocks',
complete: false,
});
await expect(mod.onModuleInit()).rejects.toThrow('Database schema incomplete: 15/17');
expect(storageAdapter.migrate).toHaveBeenCalled(); // migrations attempted first
});
it('starts normally when the schema is complete', async () => {
const { mod } = makeModule('postgres', 'standalone');
vi.mocked(getMigrationStatus).mockResolvedValue({
appliedCount: 17,
expectedCount: 17,
expectedLastTag: '0016_salty_morlocks',
complete: true,
});
await expect(mod.onModuleInit()).resolves.toBeUndefined();
});
it('does not verify postgres status for the local tier (PGlite migrates itself)', async () => {
const { mod } = makeModule('pglite', 'local');
vi.mocked(getMigrationStatus).mockClear();
await expect(mod.onModuleInit()).resolves.toBeUndefined();
expect(getMigrationStatus).not.toHaveBeenCalled();
});
});
@@ -12,6 +12,7 @@ import {
import {
createDb,
createPgliteDb,
getMigrationStatus,
runPgliteMigrations,
type Db,
type DbHandle,
@@ -74,6 +75,11 @@ export class DatabaseModule implements OnApplicationShutdown, OnModuleInit {
// the same DATABASE_URL, so a single call covers both the gateway DB and
// the storage tables. We deliberately do NOT call runMigrations() here to
// avoid opening a second short-lived connection and doubling startup cost.
//
// #1392: we DO verify afterwards (getMigrationStatus opens one short-lived
// connection) and refuse to start on an incomplete schema. A gateway that
// boots "healthy" on an empty or partial database is precisely the failure
// that shipped in the T63 batch: silent at startup, catastrophic later.
async onModuleInit(): Promise<void> {
if (this.config.tier === 'local') {
this.logger.log('Applying PGlite schema migrations...');
@@ -81,6 +87,24 @@ export class DatabaseModule implements OnApplicationShutdown, OnModuleInit {
}
this.logger.log(`Initializing storage adapter (${this.storageAdapter.name})...`);
await this.storageAdapter.migrate();
if (this.config.storage.type === 'postgres') {
const status = await getMigrationStatus(this.config.storage.url);
if (!status.complete) {
this.logger.error(
`Database schema incomplete: ${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations applied ` +
`(last expected: ${status.expectedLastTag}). ` +
'Refusing to start on a partial schema — see issues #1392/#1402. ' +
"Remediation: re-run 'mosaic gateway install' (it now verifies), or apply migrations manually.",
);
throw new Error(
`Database schema incomplete: ${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations applied`,
);
}
this.logger.log(
`Database schema verified: ${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations applied.`,
);
}
}
async onApplicationShutdown(): Promise<void> {