83 lines
4.0 KiB
JavaScript
83 lines
4.0 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* CI tenant-boundary drift gate.
|
|
*
|
|
* The explicit manifest classifies every Prisma model, including nullable
|
|
* hybrids, FK-derived children, global catalogs, platform-admin data, and the
|
|
* Tenant root. The legacy lifecycle registry remains checked separately because
|
|
* snapshot/restore/delete currently operates on direct `tenantId` fields only.
|
|
*/
|
|
import { readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { TENANT_SCOPE_MANIFEST } from "../prisma/tenant-scope-manifest.mjs";
|
|
import { parsePrismaSchemaDirectory, validateTenantScopeManifest } from "./lib/prisma-tenant-scope.mjs";
|
|
|
|
function listRegisteredLifecycleModels(modelsFile) {
|
|
const text = readFileSync(modelsFile, "utf8");
|
|
const start = text.indexOf("export const TENANT_SCOPED_MODELS");
|
|
if (start === -1) throw new Error(`could not find TENANT_SCOPED_MODELS in ${modelsFile}`);
|
|
const end = text.indexOf("];", start);
|
|
if (end === -1) throw new Error(`could not find end of TENANT_SCOPED_MODELS array in ${modelsFile}`);
|
|
|
|
const names = new Set();
|
|
for (const match of text.slice(start, end).matchAll(/model:\s*"([^"]+)"/g)) names.add(match[1]);
|
|
return names;
|
|
}
|
|
|
|
export function inspectCrossCuttingModels(rootDirectory = process.cwd(), options = {}) {
|
|
const schemaDirectory = path.join(rootDirectory, "prisma", "schema");
|
|
const modelsFile = path.join(rootDirectory, "packages", "cli", "src", "commands", "tenant", "models.ts");
|
|
const manifest = options.manifest ?? TENANT_SCOPE_MANIFEST;
|
|
const models = parsePrismaSchemaDirectory(schemaDirectory);
|
|
const manifestResult = validateTenantScopeManifest(models, manifest);
|
|
const registered = options.registeredLifecycleModels ?? listRegisteredLifecycleModels(modelsFile);
|
|
const directTenantModels = models.filter((model) => model.tenantField);
|
|
const directNames = new Set(directTenantModels.map((model) => model.model));
|
|
const missingLifecycleModels = directTenantModels.filter((model) => !registered.has(model.model));
|
|
const unknownLifecycleModels = [...registered].filter((model) => !directNames.has(model)).sort();
|
|
const errors = [
|
|
...manifestResult.errors,
|
|
...missingLifecycleModels.map((model) => `${model.schema}: ${model.model} has tenantId but is missing from TENANT_SCOPED_MODELS`),
|
|
...unknownLifecycleModels.map((model) => `${model} is registered in TENANT_SCOPED_MODELS but has no tenantId field`),
|
|
];
|
|
|
|
return {
|
|
ok: errors.length === 0,
|
|
errors,
|
|
models,
|
|
manifestResult,
|
|
directTenantModels,
|
|
missingLifecycleModels,
|
|
unknownLifecycleModels,
|
|
manifest,
|
|
};
|
|
}
|
|
|
|
export function formatCrossCuttingModels(result) {
|
|
const classifiedIndirect = [...result.manifestResult.indirectModels].filter((model) => {
|
|
const entry = result.manifest[model];
|
|
return entry?.scope === "derived-child" || entry?.scope === "platform-admin" || entry?.tenantParentBoundary === true;
|
|
}).length;
|
|
const lines = [
|
|
`${result.ok ? "PASS" : "FAIL"} cross-cutting model registry completeness`,
|
|
`Tenant scope manifest: ${result.manifestResult.classifiedCount}/${result.manifestResult.modelCount} Prisma models classified`,
|
|
`Indirect tenant paths: ${result.manifestResult.indirectModels.size} detected, ${classifiedIndirect} boundary-classified`,
|
|
`Lifecycle registry: ${result.directTenantModels.length - result.missingLifecycleModels.length}/${result.directTenantModels.length} direct tenantId models registered`,
|
|
];
|
|
for (const error of result.errors) lines.push(`FAIL ${error}`);
|
|
lines.push(`Summary: ${result.errors.length} missing or inconsistent classifications`);
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
export function runCrossCuttingModels(rootDirectory = process.cwd()) {
|
|
const result = inspectCrossCuttingModels(rootDirectory);
|
|
(result.ok ? process.stdout : process.stderr).write(formatCrossCuttingModels(result));
|
|
return result.ok ? 0 : 1;
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
process.exitCode = runCrossCuttingModels();
|
|
}
|