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>
This commit is contained in:
Till JS 2026-04-20 14:45:59 +02:00
parent 1eda3f5395
commit 5ec1dfc747
5 changed files with 114 additions and 8 deletions

View file

@ -131,13 +131,7 @@ New services: pick a short, unambiguous name (`auth`, not `mana_auth_schema`), a
### Verification
Before merging a change that adds a new Drizzle schema file, confirm with:
```bash
rg "pgTable\(" services/ apps/api/ packages/ --type ts
```
Any hit that's not inside `mana-sync` is a violation. There's no automated lint rule yet — adding one is tracked in the architecture audit.
Enforced by `pnpm run validate:pg-schema` (`scripts/validate-pg-schema-isolation.mjs`), wired into the CI `validate` job. Scans every TypeScript file under `services/`, `apps/api/`, and `packages/` for raw `pgTable(` call sites and fails the PR if any are found. Imports of the symbol are ignored — only actual call sites are violations.
## Schema Design

View file

@ -443,6 +443,9 @@ jobs:
- name: Validate no recursive turbo calls
run: pnpm run validate:turbo
- name: Validate pgSchema isolation (no raw pgTable)
run: pnpm run validate:pg-schema
- name: Audit crypto registry (Dexie ↔ registry ↔ allowlist)
run: pnpm run check:crypto

View file

@ -20,6 +20,7 @@
"check:status": "bash scripts/check-status.sh",
"validate:dockerfiles": "node scripts/validate-dockerfiles.mjs",
"validate:turbo": "node scripts/validate-no-recursive-turbo.mjs",
"validate:pg-schema": "node scripts/validate-pg-schema-isolation.mjs",
"check:crypto": "node scripts/audit-crypto-registry.mjs",
"check:crypto:seed": "node scripts/audit-crypto-registry.mjs --seed",
"audit:deps": "node scripts/audit-workspace-deps.mjs",

View file

@ -0,0 +1,109 @@
#!/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();

View file

@ -6,7 +6,6 @@
import {
pgSchema,
pgTable,
uuid,
integer,
text,