From bf7a1d5fd03eaf9a2fa73a458c4fd25bef1f92f8 Mon Sep 17 00:00:00 2001 From: achneerov Date: Wed, 8 Apr 2026 00:08:47 -0400 Subject: [PATCH 1/4] feat(db): add Stripe catalog fields and profile_service_discounts table --- lib/db/schema.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 07ff6d3..3cf6270 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -7,6 +7,7 @@ import { integer, boolean, jsonb, + unique, } from "drizzle-orm/pg-core"; export const roleEnum = pgEnum("role", ["user", "admin", "coach"]); @@ -45,6 +46,8 @@ export const services = pgTable("services", { scheduledAt: jsonb("scheduled_at"), durationMinutes: integer("duration_minutes").notNull(), price: integer("price").notNull().default(0), + stripeProductId: text("stripe_product_id"), + stripeDefaultPriceId: text("stripe_default_price_id"), isActive: boolean("is_active").notNull().default(true), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), @@ -127,3 +130,27 @@ export const purchases = pgTable("purchases", { createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); + +export const profileServiceDiscounts = pgTable( + "profile_service_discounts", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .references(() => profiles.id, { onDelete: "cascade" }) + .notNull(), + serviceId: uuid("service_id") + .references(() => services.id, { onDelete: "cascade" }) + .notNull(), + stripeCouponId: text("stripe_coupon_id").notNull(), + maxUses: integer("max_uses"), + useCount: integer("use_count").notNull().default(0), + expiresAt: timestamp("expires_at"), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [ + unique("profile_service_discounts_user_service_unique").on( + table.userId, + table.serviceId, + ), + ], +); From 80a5de48ea106aea30c6f9690ec31f7e5540ffe9 Mon Sep 17 00:00:00 2001 From: achneerov Date: Wed, 8 Apr 2026 00:19:47 -0400 Subject: [PATCH 2/4] feat(schema): add subscription model and Stripe fields to schema overview --- docs/schema-overview.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/schema-overview.md b/docs/schema-overview.md index cc388eb..c0b9070 100644 --- a/docs/schema-overview.md +++ b/docs/schema-overview.md @@ -19,6 +19,8 @@ erDiagram jsonb scheduled_at "null for coaching_session" int duration_minutes int price + text stripe_product_id + text stripe_default_price_id boolean is_active timestamp created_at timestamp updated_at @@ -62,11 +64,25 @@ erDiagram timestamp updated_at } + subscriptions { + uuid id PK + uuid user_id FK + text stripe_subscription_id + text status + text stripe_price_id + boolean cancel_at_period_end + text payment_method_brand + text payment_method_last4 + timestamp created_at + timestamp updated_at + } + profiles ||--o{ service_bookings : "books" services ||--o{ service_bookings : "booked via" profiles ||--o{ coaching_sessions : "coaches" profiles ||--o{ coaching_sessions : "attends" services ||--o{ coaching_sessions : "fulfilled by" + profiles ||--o| subscriptions : "at most one" ``` ## Enums From 0874d362418605e836ffb73a458c96c0236b0f59 Mon Sep 17 00:00:00 2001 From: achneerov Date: Wed, 8 Apr 2026 00:33:35 -0400 Subject: [PATCH 3/4] feat(schema): update schema overview with new coaching_sessions and profile_service_discounts models; add npm script for schema generation --- AGENTS.md | 2 + docs/schema-overview.md | 102 +++++++----- package.json | 3 +- scripts/generate-schema-overview.ts | 248 ++++++++++++++++++++++++++++ scripts/schema-overview.config.json | 17 ++ 5 files changed, 331 insertions(+), 41 deletions(-) create mode 100644 scripts/generate-schema-overview.ts create mode 100644 scripts/schema-overview.config.json diff --git a/AGENTS.md b/AGENTS.md index 8bd0e39..1e66311 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,3 +3,5 @@ This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + +When you change `lib/db/schema.ts`, adjust `scripts/schema-overview.config.json` if needed (relationship lines or column quotes), then run `npm run docs:schema`. diff --git a/docs/schema-overview.md b/docs/schema-overview.md index c0b9070..06be2b1 100644 --- a/docs/schema-overview.md +++ b/docs/schema-overview.md @@ -1,31 +1,53 @@ # MCLD Platform — Full Schema Overview + + ```mermaid erDiagram + coaching_sessions { + uuid id PK + uuid service_id FK + uuid coach_id FK + uuid user_id FK + timestamp scheduled_at + int duration_minutes + session_status status + text meeting_url + text notes + jsonb selected_time_slots "array of {start, end} objects" + timestamp created_at + timestamp updated_at + } + profile_service_discounts { + uuid id PK + uuid user_id FK + uuid service_id FK + text stripe_coupon_id + int max_uses + int use_count + timestamp expires_at + timestamp created_at + } profiles { uuid id PK text first_name text last_name role role + text stripe_customer_id timestamp created_at timestamp updated_at } - - services { + purchases { uuid id PK - text title - text description - service_type type - jsonb scheduled_at "null for coaching_session" - int duration_minutes - int price - text stripe_product_id - text stripe_default_price_id - boolean is_active + uuid user_id FK + text stripe_price_id + text stripe_session_id + text product_name + int amount + text currency timestamp created_at timestamp updated_at } - service_bookings { uuid id PK uuid user_id FK @@ -36,34 +58,20 @@ erDiagram timestamp created_at timestamp updated_at } - - webinars { + services { uuid id PK text title text description - webinar_tier tier + service_type type + jsonb scheduled_at "null for coaching_session" int duration_minutes - text youtube_url + int price + text stripe_product_id + text stripe_default_price_id boolean is_active timestamp created_at timestamp updated_at } - - coaching_sessions { - uuid id PK - uuid service_id FK - uuid coach_id FK - uuid user_id FK - timestamp scheduled_at - int duration_minutes - session_status status - text meeting_url - text notes - jsonb selected_time_slots "array of {start, end} objects" - timestamp created_at - timestamp updated_at - } - subscriptions { uuid id PK uuid user_id FK @@ -76,21 +84,35 @@ erDiagram timestamp created_at timestamp updated_at } + webinars { + uuid id PK + text title + text description + webinar_tier tier + int duration_minutes + text youtube_url + boolean is_active + timestamp created_at + timestamp updated_at + } - profiles ||--o{ service_bookings : "books" - services ||--o{ service_bookings : "booked via" - profiles ||--o{ coaching_sessions : "coaches" - profiles ||--o{ coaching_sessions : "attends" - services ||--o{ coaching_sessions : "fulfilled by" - profiles ||--o| subscriptions : "at most one" + profiles ||--o{ service_bookings : "user" + services ||--o{ service_bookings : "service" + profiles ||--o{ coaching_sessions : "coach" + profiles ||--o{ coaching_sessions : "user" + services ||--o{ coaching_sessions : "service" + profiles ||--o| subscriptions : "0..1" + profiles ||--o{ purchases : "user" + profiles ||--o{ profile_service_discounts : "user" + services ||--o{ profile_service_discounts : "service" ``` ## Enums | Enum | Values | |---|---| +| `booking_status` | `pending`, `confirmed`, `cancelled` | | `role` | `user`, `admin`, `coach` | | `service_type` | `coaching_session`, `booking` | -| `booking_status` | `pending`, `confirmed`, `cancelled` | -| `webinar_tier` | `free`, `premium` | | `session_status` | `pending`, `confirmed`, `cancelled`, `completed` | +| `webinar_tier` | `free`, `premium` | diff --git a/package.json b/package.json index 8144fd4..bd6e310 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "db:migrate": "drizzle-kit migrate", "db:studio": "drizzle-kit studio", "format": "prettier --write .", - "format:check": "prettier --check ." + "format:check": "prettier --check .", + "docs:schema": "tsx scripts/generate-schema-overview.ts" }, "dependencies": { "@supabase/ssr": "^0.9.0", diff --git a/scripts/generate-schema-overview.ts b/scripts/generate-schema-overview.ts new file mode 100644 index 0000000..2c127d9 --- /dev/null +++ b/scripts/generate-schema-overview.ts @@ -0,0 +1,248 @@ +/** + * Regenerates docs/schema-overview.md from lib/db/schema.ts (Drizzle runtime introspection). + * + * Usage: + * npm run docs:schema + * npm run docs:schema -- --config scripts/schema-overview.config.json + * + * Config (default: scripts/schema-overview.config.json): + * - relationshipLines: optional string[] — mermaid ER edges (one line each, no leading spaces). + * If omitted or empty, edges are auto-derived from FK metadata (labels = column name without _id). + * - columnQuotes: optional Record<"table.column", "annotation"> — extra quoted text on columns + * (schema code cannot carry these human notes). + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as schema from "../lib/db/schema"; +import { getTableName, isTable, type Table } from "drizzle-orm"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.join(__dirname, ".."); +const OUT = path.join(ROOT, "docs/schema-overview.md"); +const DEFAULT_CONFIG = path.join(__dirname, "schema-overview.config.json"); + +/** Drizzle stores these on PgTable; not on the public `Table` type. */ +function pgColumns(table: Table): Record { + return (table as unknown as Record)[ + Symbol.for("drizzle:Columns") + ] as Record; +} + +function pgInlineForeignKeys(table: Table): unknown[] | undefined { + return (table as unknown as Record)[ + Symbol.for("drizzle:PgInlineForeignKeys") + ] as unknown[] | undefined; +} + +type OverviewConfig = { + relationshipLines?: string[]; + columnQuotes?: Record; +}; + +function parseArgs(): { configPath: string } { + const args = process.argv.slice(2); + let configPath = DEFAULT_CONFIG; + for (let i = 0; i < args.length; i++) { + if (args[i] === "--config" && args[i + 1]) { + configPath = path.resolve(ROOT, args[i + 1]!); + i++; + } + if (args[i] === "--help" || args[i] === "-h") { + console.log(`Usage: tsx scripts/generate-schema-overview.ts [--config path/to/config.json]`); + process.exit(0); + } + } + return { configPath }; +} + +function loadConfig(configPath: string): OverviewConfig { + if (!fs.existsSync(configPath)) { + console.warn(`generate-schema-overview: no config at ${configPath}, using FK auto-lines only.`); + return {}; + } + const raw = fs.readFileSync(configPath, "utf8"); + return JSON.parse(raw) as OverviewConfig; +} + +function collectTables(): Table[] { + const tables: Table[] = []; + for (const v of Object.values(schema)) { + if (isTable(v)) tables.push(v); + } + tables.sort((a, b) => getTableName(a).localeCompare(getTableName(b))); + return tables; +} + +function mapMermaidType(col: { + name: string; + columnType: string; + enum?: { enumName: string }; +}): string { + if (col.columnType === "PgEnumColumn" && col.enum?.enumName) { + return `${col.enum.enumName} ${col.name}`; + } + const map: Record = { + PgUUID: "uuid", + PgText: "text", + PgTimestamp: "timestamp", + PgInteger: "int", + PgBoolean: "boolean", + PgJsonb: "jsonb", + }; + const t = map[col.columnType] ?? col.columnType.replace(/^Pg/, "").toLowerCase(); + return `${t} ${col.name}`; +} + +function collectFkColumns(tables: Table[]): Set { + const keys = new Set(); + for (const table of tables) { + const childName = getTableName(table); + const fks = pgInlineForeignKeys(table) as + | { reference: () => { columns: { name: string }[] } }[] + | undefined; + if (!fks?.length) continue; + for (const fk of fks) { + const ref = fk.reference(); + for (const c of ref.columns) { + keys.add(`${childName}.${c.name}`); + } + } + } + return keys; +} + +function fkLabel(columnName: string): string { + return columnName.replace(/_id$/, "") || columnName; +} + +function autoRelationshipLines(tables: Table[]): string[] { + const lines: string[] = []; + for (const table of tables) { + const childName = getTableName(table); + const fks = pgInlineForeignKeys(table) as + | { + reference: () => { + columns: { name: string; isUnique: boolean }[]; + foreignTable: Table; + }; + table: Table; + }[] + | undefined; + if (!fks?.length) continue; + for (const fk of fks) { + const ref = fk.reference(); + const parentName = getTableName(ref.foreignTable); + const col = ref.columns[0]!; + const label = fkLabel(col.name); + const conn = col.isUnique ? "||--o|" : "||--o{"; + lines.push(` ${parentName} ${conn} ${childName} : "${label}"`); + } + } + lines.sort((a, b) => a.localeCompare(b)); + return lines; +} + +function buildEntityBlock( + table: Table, + fkCols: Set, + columnQuotes: Record, +): string { + const sqlName = getTableName(table); + const cols = pgColumns(table); + const lines: string[] = [` ${sqlName} {`]; + for (const col of Object.values(cols)) { + const c = col as { + name: string; + primary: boolean; + columnType: string; + enum?: { enumName: string }; + }; + const key = `${sqlName}.${c.name}`; + const base = mapMermaidType(c); + let field = ` ${base}`; + if (c.primary) field += " PK"; + else if (fkCols.has(key)) field += " FK"; + const q = columnQuotes[key]; + if (q) field += ` "${q}"`; + lines.push(field); + } + lines.push(" }"); + return lines.join("\n"); +} + +function collectEnums(): { name: string; values: string[] }[] { + const out: { name: string; values: string[] }[] = []; + for (const v of Object.values(schema)) { + if ( + typeof v === "function" && + "enumName" in v && + Array.isArray((v as { enumValues?: string[] }).enumValues) + ) { + const fn = v as { enumName: string; enumValues: string[] }; + out.push({ name: fn.enumName, values: [...fn.enumValues] }); + } + } + out.sort((a, b) => a.name.localeCompare(b.name)); + return out; +} + +function normalizeRelationshipLine(line: string): string { + const t = line.trim(); + return t.endsWith("\n") ? t.slice(0, -1) : t; +} + +function main() { + const { configPath } = parseArgs(); + const config = loadConfig(configPath); + const tables = collectTables(); + const fkCols = collectFkColumns(tables); + const columnQuotes = config.columnQuotes ?? {}; + + const entityBlocks = tables.map((t) => + buildEntityBlock(t, fkCols, columnQuotes), + ); + + let relLines = + config.relationshipLines?.length && config.relationshipLines.length > 0 + ? config.relationshipLines!.map((l) => + ` ${normalizeRelationshipLine(l)}`, + ) + : autoRelationshipLines(tables); + + const enums = collectEnums(); + const enumTable = + "| Enum | Values |\n|---|---|\n" + + enums + .map( + (e) => + `| \`${e.name}\` | ${e.values.map((v) => `\`${v}\``).join(", ")} |`, + ) + .join("\n"); + + const mermaid = [ + "erDiagram", + ...entityBlocks, + "", + ...relLines, + ].join("\n"); + + const md = `# MCLD Platform — Full Schema Overview + + + +\`\`\`mermaid +${mermaid} +\`\`\` + +## Enums + +${enumTable} +`; + + fs.writeFileSync(OUT, md, "utf8"); + console.log(`Wrote ${path.relative(ROOT, OUT)}`); +} + +main(); diff --git a/scripts/schema-overview.config.json b/scripts/schema-overview.config.json new file mode 100644 index 0000000..12db51a --- /dev/null +++ b/scripts/schema-overview.config.json @@ -0,0 +1,17 @@ +{ + "relationshipLines": [ + "profiles ||--o{ service_bookings : \"user\"", + "services ||--o{ service_bookings : \"service\"", + "profiles ||--o{ coaching_sessions : \"coach\"", + "profiles ||--o{ coaching_sessions : \"user\"", + "services ||--o{ coaching_sessions : \"service\"", + "profiles ||--o| subscriptions : \"0..1\"", + "profiles ||--o{ purchases : \"user\"", + "profiles ||--o{ profile_service_discounts : \"user\"", + "services ||--o{ profile_service_discounts : \"service\"" + ], + "columnQuotes": { + "services.scheduled_at": "null for coaching_session", + "coaching_sessions.selected_time_slots": "array of {start, end} objects" + } +} From cebdfbe5139f2319e6b858075b5d337d12d7d349 Mon Sep 17 00:00:00 2001 From: achneerov Date: Wed, 8 Apr 2026 00:36:12 -0400 Subject: [PATCH 4/4] refactor(schema): streamline schema overview generation script and update documentation comments --- docs/schema-overview.md | 2 +- scripts/generate-schema-overview.ts | 292 +++++++++++----------------- 2 files changed, 118 insertions(+), 176 deletions(-) diff --git a/docs/schema-overview.md b/docs/schema-overview.md index 06be2b1..250375d 100644 --- a/docs/schema-overview.md +++ b/docs/schema-overview.md @@ -1,6 +1,6 @@ # MCLD Platform — Full Schema Overview - + ```mermaid erDiagram diff --git a/scripts/generate-schema-overview.ts b/scripts/generate-schema-overview.ts index 2c127d9..02b4873 100644 --- a/scripts/generate-schema-overview.ts +++ b/scripts/generate-schema-overview.ts @@ -1,16 +1,6 @@ -/** - * Regenerates docs/schema-overview.md from lib/db/schema.ts (Drizzle runtime introspection). - * - * Usage: - * npm run docs:schema - * npm run docs:schema -- --config scripts/schema-overview.config.json - * - * Config (default: scripts/schema-overview.config.json): - * - relationshipLines: optional string[] — mermaid ER edges (one line each, no leading spaces). - * If omitted or empty, edges are auto-derived from FK metadata (labels = column name without _id). - * - columnQuotes: optional Record<"table.column", "annotation"> — extra quoted text on columns - * (schema code cannot carry these human notes). - */ +// Writes docs/schema-overview.md from lib/db/schema.ts. +// Optional JSON: scripts/schema-overview.config.json — relationshipLines, columnQuotes. +// npm run docs:schema | npm run docs:schema -- --config path/to.json import * as fs from "node:fs"; import * as path from "node:path"; @@ -18,72 +8,46 @@ import { fileURLToPath } from "node:url"; import * as schema from "../lib/db/schema"; import { getTableName, isTable, type Table } from "drizzle-orm"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const ROOT = path.join(__dirname, ".."); -const OUT = path.join(ROOT, "docs/schema-overview.md"); -const DEFAULT_CONFIG = path.join(__dirname, "schema-overview.config.json"); +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); +const outFile = path.join(root, "docs/schema-overview.md"); +const defaultConfig = path.join(root, "scripts/schema-overview.config.json"); -/** Drizzle stores these on PgTable; not on the public `Table` type. */ -function pgColumns(table: Table): Record { - return (table as unknown as Record)[ - Symbol.for("drizzle:Columns") - ] as Record; -} - -function pgInlineForeignKeys(table: Table): unknown[] | undefined { - return (table as unknown as Record)[ - Symbol.for("drizzle:PgInlineForeignKeys") - ] as unknown[] | undefined; -} +const symCols = Symbol.for("drizzle:Columns"); +const symFks = Symbol.for("drizzle:PgInlineForeignKeys"); -type OverviewConfig = { +type Config = { relationshipLines?: string[]; columnQuotes?: Record; }; -function parseArgs(): { configPath: string } { - const args = process.argv.slice(2); - let configPath = DEFAULT_CONFIG; - for (let i = 0; i < args.length; i++) { - if (args[i] === "--config" && args[i + 1]) { - configPath = path.resolve(ROOT, args[i + 1]!); - i++; - } - if (args[i] === "--help" || args[i] === "-h") { - console.log(`Usage: tsx scripts/generate-schema-overview.ts [--config path/to/config.json]`); - process.exit(0); - } - } - return { configPath }; -} +type Col = { + name: string; + primary: boolean; + columnType: string; + enum?: { enumName: string }; +}; -function loadConfig(configPath: string): OverviewConfig { - if (!fs.existsSync(configPath)) { - console.warn(`generate-schema-overview: no config at ${configPath}, using FK auto-lines only.`); +function readConfig(p: string): Config { + if (!fs.existsSync(p)) { + console.warn(`no config at ${p}, using auto FK lines only`); return {}; } - const raw = fs.readFileSync(configPath, "utf8"); - return JSON.parse(raw) as OverviewConfig; + return JSON.parse(fs.readFileSync(p, "utf8")) as Config; } -function collectTables(): Table[] { - const tables: Table[] = []; - for (const v of Object.values(schema)) { - if (isTable(v)) tables.push(v); +function tables(): Table[] { + const t: Table[] = []; + for (const x of Object.values(schema)) { + if (isTable(x)) t.push(x); } - tables.sort((a, b) => getTableName(a).localeCompare(getTableName(b))); - return tables; + return t.sort((a, b) => getTableName(a).localeCompare(getTableName(b))); } -function mapMermaidType(col: { - name: string; - columnType: string; - enum?: { enumName: string }; -}): string { - if (col.columnType === "PgEnumColumn" && col.enum?.enumName) { - return `${col.enum.enumName} ${col.name}`; +function mermaidType(c: Col): string { + if (c.columnType === "PgEnumColumn" && c.enum?.enumName) { + return `${c.enum.enumName} ${c.name}`; } - const map: Record = { + const pg: Record = { PgUUID: "uuid", PgText: "text", PgTimestamp: "timestamp", @@ -91,158 +55,136 @@ function mapMermaidType(col: { PgBoolean: "boolean", PgJsonb: "jsonb", }; - const t = map[col.columnType] ?? col.columnType.replace(/^Pg/, "").toLowerCase(); - return `${t} ${col.name}`; + const t = + pg[c.columnType] ?? c.columnType.replace(/^Pg/, "").toLowerCase(); + return `${t} ${c.name}`; } -function collectFkColumns(tables: Table[]): Set { - const keys = new Set(); - for (const table of tables) { - const childName = getTableName(table); - const fks = pgInlineForeignKeys(table) as +function fkColumnKeys(ts: Table[]): Set { + const s = new Set(); + for (const tbl of ts) { + const child = getTableName(tbl); + const fks = (tbl as unknown as Record)[symFks] as | { reference: () => { columns: { name: string }[] } }[] | undefined; - if (!fks?.length) continue; + if (!fks) continue; for (const fk of fks) { - const ref = fk.reference(); - for (const c of ref.columns) { - keys.add(`${childName}.${c.name}`); + for (const c of fk.reference().columns) { + s.add(`${child}.${c.name}`); } } } - return keys; -} - -function fkLabel(columnName: string): string { - return columnName.replace(/_id$/, "") || columnName; + return s; } -function autoRelationshipLines(tables: Table[]): string[] { - const lines: string[] = []; - for (const table of tables) { - const childName = getTableName(table); - const fks = pgInlineForeignKeys(table) as +function edgesFromFks(ts: Table[]): string[] { + const out: string[] = []; + for (const tbl of ts) { + const child = getTableName(tbl); + const fks = (tbl as unknown as Record)[symFks] as | { reference: () => { columns: { name: string; isUnique: boolean }[]; foreignTable: Table; }; - table: Table; }[] | undefined; - if (!fks?.length) continue; + if (!fks) continue; for (const fk of fks) { - const ref = fk.reference(); - const parentName = getTableName(ref.foreignTable); - const col = ref.columns[0]!; - const label = fkLabel(col.name); - const conn = col.isUnique ? "||--o|" : "||--o{"; - lines.push(` ${parentName} ${conn} ${childName} : "${label}"`); + const r = fk.reference(); + const col = r.columns[0]!; + const parent = getTableName(r.foreignTable); + const label = col.name.replace(/_id$/, "") || col.name; + const mid = col.isUnique ? "||--o|" : "||--o{"; + out.push(` ${parent} ${mid} ${child} : "${label}"`); } } - lines.sort((a, b) => a.localeCompare(b)); - return lines; + return out.sort((a, b) => a.localeCompare(b)); } -function buildEntityBlock( - table: Table, +function entity( + tbl: Table, fkCols: Set, - columnQuotes: Record, + quotes: Record, ): string { - const sqlName = getTableName(table); - const cols = pgColumns(table); - const lines: string[] = [` ${sqlName} {`]; - for (const col of Object.values(cols)) { - const c = col as { - name: string; - primary: boolean; - columnType: string; - enum?: { enumName: string }; - }; - const key = `${sqlName}.${c.name}`; - const base = mapMermaidType(c); - let field = ` ${base}`; - if (c.primary) field += " PK"; - else if (fkCols.has(key)) field += " FK"; - const q = columnQuotes[key]; - if (q) field += ` "${q}"`; - lines.push(field); + const name = getTableName(tbl); + const cols = (tbl as unknown as Record)[symCols] as Record< + string, + Col + >; + const lines = [` ${name} {`]; + for (const c of Object.values(cols)) { + const k = `${name}.${c.name}`; + let row = ` ${mermaidType(c)}`; + if (c.primary) row += " PK"; + else if (fkCols.has(k)) row += " FK"; + const q = quotes[k]; + if (q) row += ` "${q}"`; + lines.push(row); } lines.push(" }"); return lines.join("\n"); } -function collectEnums(): { name: string; values: string[] }[] { - const out: { name: string; values: string[] }[] = []; - for (const v of Object.values(schema)) { +function pgEnums(): { name: string; values: string[] }[] { + const rows: { name: string; values: string[] }[] = []; + for (const x of Object.values(schema)) { if ( - typeof v === "function" && - "enumName" in v && - Array.isArray((v as { enumValues?: string[] }).enumValues) + typeof x === "function" && + "enumName" in x && + Array.isArray((x as { enumValues?: string[] }).enumValues) ) { - const fn = v as { enumName: string; enumValues: string[] }; - out.push({ name: fn.enumName, values: [...fn.enumValues] }); + const e = x as { enumName: string; enumValues: string[] }; + rows.push({ name: e.enumName, values: [...e.enumValues] }); } } - out.sort((a, b) => a.name.localeCompare(b.name)); - return out; + return rows.sort((a, b) => a.name.localeCompare(b.name)); } -function normalizeRelationshipLine(line: string): string { - const t = line.trim(); - return t.endsWith("\n") ? t.slice(0, -1) : t; +let configPath = defaultConfig; +for (let i = 2; i < process.argv.length; i++) { + if (process.argv[i] === "--config" && process.argv[i + 1]) { + configPath = path.resolve(root, process.argv[++i]!); + } } -function main() { - const { configPath } = parseArgs(); - const config = loadConfig(configPath); - const tables = collectTables(); - const fkCols = collectFkColumns(tables); - const columnQuotes = config.columnQuotes ?? {}; - - const entityBlocks = tables.map((t) => - buildEntityBlock(t, fkCols, columnQuotes), - ); - - let relLines = - config.relationshipLines?.length && config.relationshipLines.length > 0 - ? config.relationshipLines!.map((l) => - ` ${normalizeRelationshipLine(l)}`, - ) - : autoRelationshipLines(tables); - - const enums = collectEnums(); - const enumTable = - "| Enum | Values |\n|---|---|\n" + - enums - .map( - (e) => - `| \`${e.name}\` | ${e.values.map((v) => `\`${v}\``).join(", ")} |`, - ) - .join("\n"); - - const mermaid = [ - "erDiagram", - ...entityBlocks, - "", - ...relLines, - ].join("\n"); - - const md = `# MCLD Platform — Full Schema Overview - - +const cfg = readConfig(configPath); +const ts = tables(); +const fkCols = fkColumnKeys(ts); +const quotes = cfg.columnQuotes ?? {}; + +const blocks = ts.map((t) => entity(t, fkCols, quotes)); +const rel = + cfg.relationshipLines?.length + ? cfg.relationshipLines.map((l) => ` ${l.trim()}`) + : edgesFromFks(ts); + +const enumMd = + "| Enum | Values |\n|---|---|\n" + + pgEnums() + .map( + (e) => + `| \`${e.name}\` | ${e.values.map((v) => `\`${v}\``).join(", ")} |`, + ) + .join("\n"); + +const body = ["erDiagram", ...blocks, "", ...rel].join("\n"); + +fs.writeFileSync( + outFile, + `# MCLD Platform — Full Schema Overview + + \`\`\`mermaid -${mermaid} +${body} \`\`\` ## Enums -${enumTable} -`; - - fs.writeFileSync(OUT, md, "utf8"); - console.log(`Wrote ${path.relative(ROOT, OUT)}`); -} +${enumMd} +`, + "utf8", +); -main(); +console.log(path.relative(process.cwd(), outFile));