UNPKG

@authzkit/prisma-tenant-guard-generator

Version:

Generate tenant guard metadata from Prisma schemas

153 lines (151 loc) 5.8 kB
#!/usr/bin/env node // src/generator.ts import pkg from "@prisma/generator-helper"; import { resolve } from "path"; import { mkdir, writeFile } from "fs/promises"; import { dirname } from "path"; var { generatorHandler } = pkg; function detectCompositeSelector(model, tenantField) { for (const index of model.uniqueIndexes ?? []) { if (index.fields.includes(tenantField) && index.fields.length > 1) { return index.name ?? index.fields.join("_"); } } const primaryFields = model.primaryKey?.fields ?? []; if (primaryFields.includes(tenantField) && primaryFields.length > 1) { return (model.primaryKey?.name ?? primaryFields.join("_")) || void 0; } return void 0; } function formatMeta(meta) { const lines = []; lines.push("import type { TenantMeta } from '@authzkit/prisma-tenant-guard';"); lines.push(""); lines.push("export const tenantMeta = " + serializeMeta(meta) + " satisfies TenantMeta;"); lines.push(""); lines.push("export default tenantMeta;"); lines.push(""); return lines.join("\n"); } function serializeMeta(meta) { const entries = Object.entries(meta).sort(([a], [b]) => a.localeCompare(b)); if (entries.length === 0) { return "{}"; } const indent = (level) => " ".repeat(level); const lines = ["{"]; for (const [model, config] of entries) { const modelLines = []; if (config.tenantField) { modelLines.push(`${indent(2)}tenantField: '${config.tenantField}',`); } if (config.compositeSelector) { modelLines.push(`${indent(2)}compositeSelector: '${config.compositeSelector}',`); } if (config.nestedTargets && Object.keys(config.nestedTargets).length > 0) { modelLines.push(`${indent(2)}nestedTargets: {`); const nestedEntries = Object.entries(config.nestedTargets).sort(([a], [b]) => a.localeCompare(b)); for (const [field, target] of nestedEntries) { const targetValue = typeof target === "string" ? target : JSON.stringify(target); modelLines.push(`${indent(3)}${field}: '${targetValue}',`); } modelLines.push(`${indent(2)}},`); } if (modelLines.length === 0) { lines.push(`${indent(1)}${model}: {},`); continue; } lines.push(`${indent(1)}${model}: {`); lines.push(...modelLines); lines.push(`${indent(1)}},`); } lines.push("}"); return lines.join("\n"); } var generate = async (options) => { const { generator, dmmf } = options; const outputPath = generator.output?.value || "./generated/tenant-guard"; const resolvedOutputPath = resolve(outputPath, "meta.ts"); const jsonOutputPath = resolve(outputPath, "meta.json"); const config = generator.config; const tenantFieldCandidates = (config.tenantField || "tenantId").split(",").map((field) => field.trim()).filter(Boolean); if (tenantFieldCandidates.length === 0) { tenantFieldCandidates.push("tenantId"); } const includeSet = typeof config.include === "string" ? new Set(config.include.split(",").map((s) => s.trim())) : config.include ? new Set(config.include) : void 0; const excludeSet = typeof config.exclude === "string" ? new Set(config.exclude.split(",").map((s) => s.trim())) : config.exclude ? new Set(config.exclude) : void 0; try { const candidates = []; for (const model of dmmf.datamodel.models) { if (includeSet && !includeSet.has(model.name)) { continue; } if (excludeSet && excludeSet.has(model.name)) { continue; } const tenantField = model.fields.find( (field) => field.kind === "scalar" && tenantFieldCandidates.includes(field.name) ); if (!tenantField) { continue; } const compositeSelector = detectCompositeSelector(model, tenantField.name); const relations = []; for (const field of model.fields) { if (field.kind !== "object") { continue; } if (!field.type || typeof field.type !== "string") { continue; } relations.push({ fieldName: field.name, targetModel: field.type }); } const candidate = { name: model.name, tenantField: tenantField.name, relations }; if (compositeSelector) { candidate.compositeSelector = compositeSelector; } candidates.push(candidate); } const candidateByName = new Map(candidates.map((candidate) => [candidate.name, candidate])); const meta = {}; for (const candidate of candidates) { const nestedTargets = {}; for (const relation of candidate.relations) { if (!candidateByName.has(relation.targetModel)) { continue; } nestedTargets[relation.fieldName] = relation.targetModel; } meta[candidate.name] = { tenantField: candidate.tenantField, ...candidate.compositeSelector ? { compositeSelector: candidate.compositeSelector } : {}, ...Object.keys(nestedTargets).length > 0 ? { nestedTargets } : {} }; } const outputDir = dirname(resolvedOutputPath); await mkdir(outputDir, { recursive: true }); const tsContent = formatMeta(meta); await writeFile(resolvedOutputPath, tsContent, "utf8"); const jsonDir = dirname(jsonOutputPath); await mkdir(jsonDir, { recursive: true }); await writeFile(jsonOutputPath, JSON.stringify(meta, null, 2) + "\n", "utf8"); console.log(`\u2714 Generated tenant guard meta for ${Object.keys(meta).length} models -> ${resolvedOutputPath} (.ts), ${jsonOutputPath} (.json)`); } catch (error) { console.error("Failed to generate tenant guard meta:", error); throw error; } }; generatorHandler({ onManifest: () => ({ defaultOutput: "../.prisma/tenant-guard", prettyName: "AuthzKit Tenant Guard Generator" }), onGenerate: generate }); export { generate };