morphit/apps/indexer/scripts/schema-migration-coverage-smoke.ts

236 lines
9.8 KiB
TypeScript

#!/usr/bin/env tsx
/**
* schema-migration-coverage-smoke — cross-check schema.sql's declared
* head version against `MIGRATIONS[]` coverage in migrations.ts.
*
* Part 122 cp6 F8. Tighter form of cp2 F5: instead of pinning a
* brittle literal head-version COMMENT STRING, this smoke PARSES
* both artifacts and pins the DERIVED NUMERIC values. An editor
* who tweaks the prose of the schema head banner (e.g. fixes a
* typo in "multi-network") no longer breaks the sentinel — only
* a semantic change (version number drift) does.
*
* Background: pre-launch, the indexer schema was collapsed —
* `MIGRATIONS[]` has exactly ONE entry (version 1) with
* `subsumesVersions: [2..27]`. Subsequent schema changes (v28-v32)
* were added INLINE as DDL appended after the collapsed base, NOT
* as separate MIGRATIONS entries. Only the head version (v32) has
* an explicit `-- v<N>` banner; v28-v31 are unannotated inline DDL.
*
* Fresh deploys are fine — they apply schema.sql in full. The
* foot-gun is post-launch UPGRADE deploys where a new v33
* inline-only DDL gets silently dropped because runMigrations()
* sees v1 applied and has nothing else to do.
*
* The invariant this smoke defends:
*
* (a) schema.sql's highest `-- v<N>` banner matches the pinned
* SCHEMA_HEAD_VERSION below. Adding v33 forces a same-turn
* decision: bump SCHEMA_HEAD_VERSION here AND decide whether
* a MIGRATIONS[33] entry is needed.
*
* (b) MIGRATIONS[]'s coverage (union of `version` + every
* `subsumesVersions[]` across entries) matches the pinned
* MIGRATIONS_COVERAGE_HIGH below. Adding a MIGRATIONS[N]
* entry forces bumping COVERAGE_HIGH here.
*
* (c) SCHEMA_HEAD_VERSION >= MIGRATIONS_COVERAGE_HIGH (sanity:
* MIGRATIONS[] can't claim to cover a version that doesn't
* exist in schema.sql).
*
* The gap SCHEMA_HEAD_VERSION - MIGRATIONS_COVERAGE_HIGH is the
* pre-launch inline-only window (currently 5: v28-v32). After
* launch this gap closes naturally as new schema changes land as
* MIGRATIONS[N] entries rather than inline DDL.
*
* Scenarios:
* 1. schema.sql's highest `-- v<N>` banner === SCHEMA_HEAD_VERSION
* 2. MIGRATIONS[] coverage highest === MIGRATIONS_COVERAGE_HIGH
* 3. SCHEMA_HEAD_VERSION >= MIGRATIONS_COVERAGE_HIGH (sanity)
* 4. No schema.sql `-- v<N>` banner above SCHEMA_HEAD_VERSION
* (catches the case where a developer adds v33 but forgets
* to bump the pin here)
*
* Usage:
* tsx apps/indexer/scripts/schema-migration-coverage-smoke.ts
*/
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
const REPO_ROOT = join(import.meta.dirname, '..', '..', '..');
const SCHEMA_SQL = join(REPO_ROOT, 'apps', 'indexer', 'src', 'db', 'schema.sql');
const MIGRATIONS_TS = join(REPO_ROOT, 'apps', 'indexer', 'src', 'db', 'migrations.ts');
// ─── Pinned expected values (the contract this smoke defends) ─────
/** Highest `-- v<N>` banner in schema.sql. Bump in lockstep
* with a schema change; the smoke fails until you do, which is
* the point. cp131 DEEP-003 — bumped 33 → 35 after the parser
* was widened to recognize the cp123/cp127-era banner format
* `-- ─── v<N>: <description>` (previously only `-- v<N> / ...`
* was recognized, silently undercounting v34 and v35). */
// v1.5.0 (cp471): 43 → 45. v44 = orders.status += 'completed' (the
// morphit_order_complete_v1 op); v45 = user_settings, the ENCRYPTED
// settings-to-chain blob. Both verified present + idempotent in schema.sql
// AND migrations.ts before bumping — this pin attests to that, it is not a
// rubber stamp. The guard caught these: v44/v45 were added in earlier v1.5.0
// turns without the same-turn bump it exists to force.
const SCHEMA_HEAD_VERSION = 53;
/** Highest version covered by MIGRATIONS[] (max of `version` or any
* `subsumesVersions[]` entry). Bump only when a new MIGRATIONS
* entry lands. cp131 DEEP-002 — bumped 27 → 35 when
* `subsumesVersions` was extended to match the in-place
* v28-v35 sections in schema.sql. cp425 — bumped 36 → 37 when the
* accepted_assets migration (v37) landed. cp466 — bumped 41 → 42
* when the chat_folders migration (v42, t.txt #5) landed. */
// v1.5.0 (cp471): 43 → 45, in lockstep with SCHEMA_HEAD_VERSION above.
const MIGRATIONS_COVERAGE_HIGH = 53;
interface ScenarioResult {
readonly name: string;
readonly ok: boolean;
readonly detail?: string;
}
const results: ScenarioResult[] = [];
function readFileOrFail(path: string): string {
if (!existsSync(path)) {
throw new Error(`required file missing: ${path}`);
}
return readFileSync(path, 'utf-8');
}
/** Parse `-- v<N>` head-section banners in schema.sql. Recognizes
* two banner formats that coexist in the codebase:
*
* Format A (cp82-era, used through v33):
* `-- v<N>` followed by end-of-line OR ` / Part ...` continuation
* Format B (cp123/cp127-era, used for v34 and v35):
* `-- ─── v<N>:` (box-decorator prefix + colon separator)
*
* cp131 DEEP-003 — pre-cp131 the regex only recognized Format A,
* silently undercounting v34 (review_concentration) and v35
* (price_drift_baseline). Both formats are now accepted.
*
* Excludes narrative references like `-- v5 used to add ...` or
* `-- v1-v27 stay with treasury IS NULL` by requiring one of the
* two banner suffix patterns. */
function parseSchemaHeadBanners(): number[] {
const src = readFileOrFail(SCHEMA_SQL);
const found = new Set<number>();
for (const line of src.split('\n')) {
// Format A: -- v<N> at start, optional / continuation
const mA = /^--\s+v(\d+)(?:\s*$|\s+\/\s+)/.exec(line);
if (mA) {
found.add(parseInt(mA[1]!, 10));
continue;
}
// Format B: -- ─── v<N>: <description> ─── (box-decorator
// frame). The non-ASCII U+2500/2501-class box-drawing
// characters appear in cp123+ schema sections. We don't
// pin the exact decorator characters — any non-word run
// between `-- ` and `v<N>` is accepted, since the
// alternative is to babysit a unicode allowlist that
// drifts with the editor's mood.
const mB = /^--\s+\W+\s*v(\d+)\s*:/.exec(line);
if (mB) {
found.add(parseInt(mB[1]!, 10));
}
}
return [...found].sort((a, b) => a - b);
}
/** Parse MIGRATIONS[]: union of `version` + every `subsumesVersions[]`
* entry across all entries. */
function parseMigrationsCoverage(): number[] {
const src = readFileOrFail(MIGRATIONS_TS);
const covered = new Set<number>();
for (const m of src.matchAll(/^\s*version:\s*(\d+),/gm)) {
covered.add(parseInt(m[1]!, 10));
}
for (const block of src.matchAll(/subsumesVersions:\s*\[\s*([\d\s,\n]+?)\s*\]/g)) {
const numbersInBlock = block[1]!.match(/\d+/g) ?? [];
for (const n of numbersInBlock) {
covered.add(parseInt(n, 10));
}
}
return [...covered].sort((a, b) => a - b);
}
// ─── Run ──
const schemaBanners = parseSchemaHeadBanners();
const migrationsCoverage = parseMigrationsCoverage();
const schemaMax = schemaBanners.length > 0 ? Math.max(...schemaBanners) : 0;
const migrationsMax = migrationsCoverage.length > 0 ? Math.max(...migrationsCoverage) : 0;
results.push({
name: `schema.sql highest -- v<N> banner === SCHEMA_HEAD_VERSION (${SCHEMA_HEAD_VERSION})`,
ok: schemaMax === SCHEMA_HEAD_VERSION,
detail:
schemaMax === SCHEMA_HEAD_VERSION
? undefined
: `schema.sql highest banner: v${schemaMax}. Pinned SCHEMA_HEAD_VERSION: ${SCHEMA_HEAD_VERSION}. ` +
`If you just added a schema change, bump SCHEMA_HEAD_VERSION in this smoke in lockstep, ` +
`AND decide whether the new version needs a MIGRATIONS[] entry (it does if upgrade-deploys need ` +
`the new DDL — pre-launch all deploys are fresh so the inline-only pattern is safe; post-launch this is a foot-gun).`
});
results.push({
name: `MIGRATIONS[] coverage highest === MIGRATIONS_COVERAGE_HIGH (${MIGRATIONS_COVERAGE_HIGH})`,
ok: migrationsMax === MIGRATIONS_COVERAGE_HIGH,
detail:
migrationsMax === MIGRATIONS_COVERAGE_HIGH
? undefined
: `MIGRATIONS[] highest version (union of version + subsumesVersions): v${migrationsMax}. ` +
`Pinned MIGRATIONS_COVERAGE_HIGH: ${MIGRATIONS_COVERAGE_HIGH}. ` +
`If you added a MIGRATIONS[N] entry, bump MIGRATIONS_COVERAGE_HIGH here.`
});
results.push({
name: `SCHEMA_HEAD_VERSION (${SCHEMA_HEAD_VERSION}) >= MIGRATIONS_COVERAGE_HIGH (${MIGRATIONS_COVERAGE_HIGH}) — sanity`,
ok: SCHEMA_HEAD_VERSION >= MIGRATIONS_COVERAGE_HIGH,
detail:
SCHEMA_HEAD_VERSION >= MIGRATIONS_COVERAGE_HIGH
? undefined
: `MIGRATIONS[] claims to cover up to v${MIGRATIONS_COVERAGE_HIGH} but schema.sql tops out at v${SCHEMA_HEAD_VERSION}. ` +
`MIGRATIONS[] can't cover a version that doesn't exist in the schema.`
});
const aboveHead = schemaBanners.filter((v) => v > SCHEMA_HEAD_VERSION);
results.push({
name: `no schema.sql -- v<N> banner above pinned head`,
ok: aboveHead.length === 0,
detail:
aboveHead.length === 0
? undefined
: `schema.sql has banners [${aboveHead.join(', ')}] above pinned SCHEMA_HEAD_VERSION=${SCHEMA_HEAD_VERSION}. Bump the pin.`
});
console.log(
`schema-migration coverage smoke: ${results.length} scenarios ` +
`(schema.sql banners=[${schemaBanners.join(',')}], ` +
`MIGRATIONS[] coverage=[${migrationsCoverage.join(',')}], ` +
`inline gap = v${migrationsMax + 1}..v${schemaMax} = ${schemaMax - migrationsMax} versions)\n`
);
let failed = 0;
for (const r of results) {
if (r.ok) {
console.log(`${r.name}`);
} else {
console.log(`${r.name}`);
if (r.detail) {
for (const line of r.detail.split('\n')) {
console.log(` ${line}`);
}
}
failed++;
}
}
console.log('');
if (failed === 0) {
console.log(`✓ all ${results.length} schema-migration coverage checks hold`);
process.exit(0);
} else {
console.error(`${failed} failed, ${results.length - failed} passed`);
process.exit(1);
}