#!/usr/bin/env ts-node /** * generate-schema-map.ts * * Parses prisma/schema.prisma and emits docs/SCHEMA_MAP.md — a grouped * reference of all Prisma models with relation counts and per-group mermaid * ER diagrams. Run via `npm run schema:map` after schema changes. * * MODEL_GROUPS is the curated source of truth for how Echo OCR's models * cluster by feature area. New models fall into the "Other" bucket so the * map stays complete even when this file is out of date — that's a useful * signal that you forgot to categorize a new model, so leave it that way. */ import { promises as fs } from "fs"; import * as path from "path"; const REPO_ROOT = path.resolve(__dirname, ".."); const SCHEMA_PATH = path.join(REPO_ROOT, "prisma", "schema.prisma"); const OUTPUT_PATH = path.join(REPO_ROOT, "docs", "SCHEMA_MAP.md"); interface ParsedField { name: string; type: string; isRelation: boolean; isList: boolean; isOptional: boolean; attributes: string; } interface ParsedModel { name: string; block: string; fields: ParsedField[]; tableMap?: string; comment?: string; } interface ParsedEnum { name: string; values: string[]; } const MODEL_GROUPS: Record = { "Auth & Users": ["User", "Account", "Session", "VerificationToken", "PasswordResetToken"], "Org & Membership": ["Organization", "OrgMember", "Invitation", "ApiKey"], "Locations & Events": ["Location", "CollectionDay"], "Form Templates": ["FormTemplate", "FormField"], "Cards & OCR": ["ResponseCard", "ProcessingJob"], "People (CRM)": ["Person"], "Integrations": ["Integration"], "Activity & Notifications": ["ActivityLog", "Notification"], "Settings": ["AppSettings", "SystemConfig"], }; const GROUP_ORDER = Object.keys(MODEL_GROUPS); function parseSchema(source: string): { models: ParsedModel[]; enums: ParsedEnum[]; } { const models: ParsedModel[] = []; const enums: ParsedEnum[] = []; const modelRegex = /(?:^|\n)((?:\/\/\/[^\n]*\n)*)model\s+(\w+)\s*\{([\s\S]*?)\n\}/g; let m: RegExpExecArray | null; while ((m = modelRegex.exec(source)) !== null) { const [, leadingComments, name, body] = m; const fields: ParsedField[] = []; let tableMap: string | undefined; const lines = body.split("\n"); for (const rawLine of lines) { const line = rawLine.trim(); if (!line || line.startsWith("//")) continue; if (line.startsWith("@@map(")) { const mm = line.match(/@@map\("([^"]+)"\)/); if (mm) tableMap = mm[1]; continue; } if (line.startsWith("@@")) continue; const fieldMatch = line.match(/^(\w+)\s+([\w\[\]?]+)(\s+.*)?$/); if (!fieldMatch) continue; const [, fieldName, rawType, attrs] = fieldMatch; const isList = rawType.endsWith("[]"); const isOptional = rawType.endsWith("?"); const baseType = rawType.replace(/[\[\]?]/g, ""); const isRelation = /^[A-Z]/.test(baseType); fields.push({ name: fieldName, type: baseType, isRelation, isList, isOptional, attributes: (attrs ?? "").trim(), }); } const comment = leadingComments ? leadingComments .split("\n") .map((l) => l.replace(/^\/\/\/\s?/, "").trim()) .filter(Boolean) .join(" ") : undefined; models.push({ name, block: body, fields, tableMap, comment }); } const enumRegex = /(?:^|\n)enum\s+(\w+)\s*\{([\s\S]*?)\n\}/g; while ((m = enumRegex.exec(source)) !== null) { const [, name, body] = m; const values = body .split("\n") .map((l) => l.trim()) .filter((l) => l && !l.startsWith("//")) .map((l) => l.split(/\s+/)[0]); enums.push({ name, values }); } return { models, enums }; } function groupOf(modelName: string): string { for (const [group, names] of Object.entries(MODEL_GROUPS)) { if (names.includes(modelName)) return group; } return "Other"; } function relationTargets(model: ParsedModel, scalarTypes: Set): string[] { const targets = new Set(); for (const f of model.fields) { if (!f.isRelation) continue; if (scalarTypes.has(f.type)) continue; targets.add(f.type); } return Array.from(targets).sort(); } function relationCount(model: ParsedModel, scalarTypes: Set): number { return model.fields.filter((f) => f.isRelation && !scalarTypes.has(f.type)).length; } function buildMermaid(group: string, models: ParsedModel[], allModelNames: Set): string { if (models.length === 0) return ""; const lines: string[] = ["```mermaid", "erDiagram"]; const seenEdges = new Set(); for (const model of models) { for (const f of model.fields) { if (!f.isRelation) continue; if (!allModelNames.has(f.type)) continue; const a = model.name; const b = f.type; if (a === b) continue; const edgeKey = [a, b].sort().join("--"); if (seenEdges.has(edgeKey)) continue; seenEdges.add(edgeKey); const cardinality = f.isList ? '"1" ||--o{ "many"' : '"1" ||--|| "1"'; lines.push(` ${a} ${cardinality} ${b} : ${f.name}`); } if ( !models.some((other) => other.fields.some((ff) => ff.isRelation && ff.type === model.name) ) && !model.fields.some((ff) => ff.isRelation && allModelNames.has(ff.type)) ) { lines.push(` ${model.name} {`); lines.push(` string id`); lines.push(` }`); } } lines.push("```"); return lines.join("\n"); } async function main() { const source = await fs.readFile(SCHEMA_PATH, "utf-8"); const { models, enums } = parseSchema(source); const allModelNames = new Set(models.map((m) => m.name)); const enumNames = new Set(enums.map((e) => e.name)); const scalarTypes = new Set([ "String", "Int", "Float", "Decimal", "Boolean", "DateTime", "Json", "Bytes", "BigInt", ...enumNames, ]); const grouped: Record = {}; for (const m of models) { const g = groupOf(m.name); grouped[g] ??= []; grouped[g].push(m); } const out: string[] = []; out.push("# Prisma Schema Map"); out.push(""); out.push( "_Auto-generated by `npm run schema:map` from [prisma/schema.prisma](../prisma/schema.prisma). Do not edit by hand._", ); out.push(""); out.push( `Models: **${models.length}** • Enums: **${enums.length}** • Groups: **${ Object.keys(grouped).length }**`, ); out.push(""); out.push("## Table of contents"); out.push(""); const orderedGroups = [ ...GROUP_ORDER.filter((g) => grouped[g]?.length), ...Object.keys(grouped).filter((g) => !GROUP_ORDER.includes(g)), ]; for (const g of orderedGroups) { const slug = g.toLowerCase().replace(/[^a-z0-9]+/g, "-"); out.push(`- [${g}](#${slug}) (${grouped[g].length})`); } out.push(""); for (const g of orderedGroups) { const models = grouped[g]; out.push(`## ${g}`); out.push(""); out.push("| Model | Table | Relations | Linked to |"); out.push("| --- | --- | ---: | --- |"); for (const m of models.sort((a, b) => a.name.localeCompare(b.name))) { const rels = relationCount(m, scalarTypes); const targets = relationTargets(m, scalarTypes); const targetList = targets.length ? targets .map((t) => allModelNames.has(t) ? `[${t}](#${groupOf(t).toLowerCase().replace(/[^a-z0-9]+/g, "-")})` : t, ) .slice(0, 10) .join(", ") + (targets.length > 10 ? ", …" : "") : "—"; out.push( `| **${m.name}** | \`${m.tableMap ?? "—"}\` | ${rels} | ${targetList} |`, ); } out.push(""); const mermaid = buildMermaid(g, models, allModelNames); if (mermaid) { out.push("
ER diagram"); out.push(""); out.push(mermaid); out.push(""); out.push("
"); out.push(""); } } if (enums.length) { out.push("## Enums"); out.push(""); out.push("| Enum | Values |"); out.push("| --- | --- |"); for (const e of enums.sort((a, b) => a.name.localeCompare(b.name))) { out.push(`| **${e.name}** | ${e.values.map((v) => `\`${v}\``).join(", ")} |`); } out.push(""); } out.push("---"); out.push(""); out.push( `Regenerate after schema changes: \`npm run schema:map\`. Curated groupings live in [scripts/generate-schema-map.ts](../scripts/generate-schema-map.ts) under \`MODEL_GROUPS\`.`, ); out.push(""); await fs.mkdir(path.dirname(OUTPUT_PATH), { recursive: true }); await fs.writeFile(OUTPUT_PATH, out.join("\n"), "utf-8"); // eslint-disable-next-line no-console console.log( `Wrote ${path.relative(REPO_ROOT, OUTPUT_PATH)} — ${models.length} models in ${ orderedGroups.length } groups, ${enums.length} enums.`, ); } main().catch((err) => { // eslint-disable-next-line no-console console.error(err); process.exit(1); });