managarten/scripts/validate-pg-schema-isolation.mjs
Till JS 5ec1dfc747 chore(db): enforce pgSchema isolation with a lint script
The "every Drizzle table uses pgSchema" rule was documented in
.claude/guidelines/database.md (added yesterday as part of Concern 5)
but enforced only by convention. A new service could slip a raw
\`pgTable()\` past review and collide in the default \`public\` schema
of \`mana_platform\`, and nothing would surface the mistake until a
production migration failed.

- \`scripts/validate-pg-schema-isolation.mjs\` scans every tracked
  TypeScript file under services/, apps/api/, packages/ for call sites
  of \`pgTable(\` (not imports — imports can still be useful for types).
  Strips comments before matching so doc-examples like "use \`pgTable()\`"
  don't trigger false positives.
- Wired as \`pnpm run validate:pg-schema\` and a new CI step in the
  validate job (right after the turbo-recursion check). 721 files
  scan clean today.
- Removed an unused \`pgTable\` import in mana-subscriptions that would
  have been the only import of the symbol remaining after this change.
- Updated .claude/guidelines/database.md — the old verification blurb
  said "no automated lint rule yet", now points at the enforcer.

Drift verified: injecting a synthetic \`pgTable('bad', {})\` into
subscriptions.ts failed with a clear file:line violation pointing at
the database guideline.

Closes the "no automated lint rule" gap noted in the database guideline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:45:59 +02:00

109 lines
3.7 KiB
JavaScript
Executable file

#!/usr/bin/env node
/**
* Validate that every Drizzle table declaration uses `pgSchema('x').table(...)`
* instead of raw `pgTable(...)`.
*
* Why: `mana_platform` is one shared Postgres database for every service.
* Without schema namespacing, a `users` table in one service collides with
* `users` in another. The rule is in `.claude/guidelines/database.md` but
* was enforced only by convention until now — a new service could slip
* a raw `pgTable()` past review and pollute the default `public` schema.
*
* Rule: no call-site of `pgTable(` may appear in TypeScript under
* `services/`, `apps/api/`, or `packages/`. Imports of the symbol are
* ignored (they can still be useful for types), only actual calls are
* violations.
*
* Exception list: none. `mana-sync` is Go; it has no .ts schema files
* to begin with. Projection tables on top of `mana_sync` (e.g.
* `mana-ai`'s mission_snapshots) use `pgSchema('mana_ai').table(...)`
* to stay out of the core sync namespace.
*
* Zero deps — plain Node ESM. Uses `git ls-files` so node_modules and
* build output are auto-excluded.
*/
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, '..');
/** Directories we scan for schema files. */
const SCAN_GLOBS = ['services/**/*.ts', 'apps/api/**/*.ts', 'packages/**/*.ts'];
/** Paths we never flag. `node_modules` and `dist` are already filtered by
* `git ls-files`; this is for in-tree exceptions. */
const ALLOWLIST_PATHS = [
// `dist/` directories that slipped into git. Defensive — shouldn't exist.
/\/dist\//,
];
function listTsFiles() {
const out = execSync(`git ls-files ${SCAN_GLOBS.map((g) => `"${g}"`).join(' ')}`, {
cwd: REPO_ROOT,
encoding: 'utf8',
});
return out
.split('\n')
.map((p) => p.trim())
.filter(Boolean)
.filter((p) => !ALLOWLIST_PATHS.some((re) => re.test(p)));
}
/**
* Strip // line comments and /* block comments *\/ so a doc-comment
* mentioning `pgTable()` doesn't trigger a false positive.
*/
function stripComments(source) {
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
}
function validate() {
const files = listTsFiles();
const violations = [];
let scanned = 0;
for (const rel of files) {
scanned++;
const abs = join(REPO_ROOT, rel);
let source;
try {
source = readFileSync(abs, 'utf8');
} catch {
continue; // deleted between ls-files and read
}
const stripped = stripComments(source);
// Find each line that calls `pgTable(`. Import lines look like
// `import { pgTable } from ...` — they never have `pgTable(` so
// they're auto-excluded by this regex.
const lines = stripped.split('\n');
for (let i = 0; i < lines.length; i++) {
if (/\bpgTable\s*\(/.test(lines[i])) {
violations.push(
`${rel}:${i + 1}: raw \`pgTable(\` call — use \`pgSchema('<name>').table(...)\` instead. ` +
`See .claude/guidelines/database.md §"Schema Isolation".`
);
}
}
}
if (violations.length > 0) {
console.error(`\n✗ pgSchema isolation check FAILED (${violations.length} violation(s)):\n`);
for (const v of violations) console.error(`${v}`);
console.error(
`\nEvery Drizzle table in this monorepo must live under its own Postgres schema. ` +
`A raw \`pgTable()\` call drops the table into the default \`public\` schema and ` +
`risks colliding with other services sharing \`mana_platform\`.\n`
);
process.exit(1);
}
console.log(
`✓ No raw pgTable() calls: scanned ${scanned} TypeScript files under services/, apps/api/, packages/.`
);
}
validate();