dbtour 0.2.0 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/notes.js ADDED
@@ -0,0 +1,115 @@
1
+ // The notes file: what people add that the database cannot say. Merged into the model at render time and
2
+ // winning over it, so a production database nobody may alter still gets documented.
3
+ import { parse } from 'yaml';
4
+ import { findTable, parseRef, tableRef } from "./model.js";
5
+ export function parseNotes(text) {
6
+ const raw = parse(text) ?? {};
7
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
8
+ throw new Error('the notes file must be a map with tables, relations and groups');
9
+ const doc = raw;
10
+ const notes = { tables: {}, relations: [], groups: [] };
11
+ if (doc.tables !== undefined) {
12
+ if (typeof doc.tables !== 'object' || doc.tables === null || Array.isArray(doc.tables))
13
+ throw new Error('tables must be a map of schema.table to its notes');
14
+ for (const [ref, value] of Object.entries(doc.tables)) {
15
+ parseRef(ref);
16
+ if (typeof value !== 'object' || value === null)
17
+ throw new Error(`tables.${ref} must be a map with description and columns`);
18
+ const entry = value;
19
+ const table = {};
20
+ if (entry.description !== undefined)
21
+ table.description = String(entry.description);
22
+ if (entry.columns !== undefined) {
23
+ if (typeof entry.columns !== 'object' || entry.columns === null || Array.isArray(entry.columns))
24
+ throw new Error(`tables.${ref}.columns must be a map of column to description`);
25
+ table.columns = Object.fromEntries(Object.entries(entry.columns).map(([k, v]) => [k, String(v)]));
26
+ }
27
+ notes.tables[ref] = table;
28
+ }
29
+ }
30
+ if (doc.relations !== undefined) {
31
+ if (!Array.isArray(doc.relations))
32
+ throw new Error('relations must be a list of { from, to }');
33
+ doc.relations.forEach((value, i) => {
34
+ if (typeof value !== 'object' || value === null)
35
+ throw new Error(`relations[${i}] must be { from, to }`);
36
+ const { from, to } = value;
37
+ for (const [end, ref] of [['from', from], ['to', to]]) {
38
+ if (typeof ref !== 'string' || parseRef(ref).column === undefined)
39
+ throw new Error(`relations[${i}].${end} must be schema.table.column`);
40
+ }
41
+ notes.relations.push({ from: from, to: to });
42
+ });
43
+ }
44
+ if (doc.groups !== undefined) {
45
+ if (!Array.isArray(doc.groups))
46
+ throw new Error('groups must be a list of { name, description, tables }');
47
+ doc.groups.forEach((value, i) => {
48
+ if (typeof value !== 'object' || value === null)
49
+ throw new Error(`groups[${i}] must be { name, description, tables }`);
50
+ const entry = value;
51
+ if (typeof entry.name !== 'string' || entry.name === '')
52
+ throw new Error(`groups[${i}].name is required`);
53
+ if (!Array.isArray(entry.tables) || entry.tables.some((t) => typeof t !== 'string'))
54
+ throw new Error(`groups[${i}].tables must be a list of schema.table`);
55
+ entry.tables.forEach((t) => parseRef(t));
56
+ const group = { name: entry.name, tables: entry.tables };
57
+ if (entry.description !== undefined)
58
+ group.description = String(entry.description);
59
+ notes.groups.push(group);
60
+ });
61
+ }
62
+ return notes;
63
+ }
64
+ export function applyNotes(input, notes) {
65
+ const model = structuredClone(input);
66
+ const warnings = [];
67
+ for (const [ref, tableNotes] of Object.entries(notes.tables)) {
68
+ const found = findTable(model, ref);
69
+ if (!found) {
70
+ warnings.push(`notes: ${ref} is not a table in the model`);
71
+ continue;
72
+ }
73
+ if (tableNotes.description !== undefined)
74
+ found.table.description = tableNotes.description;
75
+ for (const [name, description] of Object.entries(tableNotes.columns ?? {})) {
76
+ const column = found.table.columns.find((c) => c.name === name);
77
+ if (!column) {
78
+ warnings.push(`notes: ${ref}.${name} is not a column in the model`);
79
+ continue;
80
+ }
81
+ column.description = description;
82
+ }
83
+ }
84
+ for (const note of notes.relations) {
85
+ const from = parseRef(note.from);
86
+ const to = parseRef(note.to);
87
+ const relation = {
88
+ from: { table: tableRef(from.schema, from.table), columns: [from.column] },
89
+ to: { table: tableRef(to.schema, to.table), columns: [to.column] },
90
+ virtual: true,
91
+ };
92
+ const fromTable = findTable(model, relation.from.table);
93
+ const toTable = findTable(model, relation.to.table);
94
+ if (!fromTable || !toTable) {
95
+ warnings.push(`notes: relation ${note.from} → ${note.to} names a table that is not in the model`);
96
+ continue;
97
+ }
98
+ const exists = fromTable.database.relations.some((r) => sameEnd(r.from, relation.from) && sameEnd(r.to, relation.to));
99
+ if (!exists)
100
+ fromTable.database.relations.push(relation);
101
+ }
102
+ for (const group of notes.groups) {
103
+ const tables = group.tables.filter((ref) => {
104
+ if (findTable(model, ref))
105
+ return true;
106
+ warnings.push(`notes: group ${group.name} lists ${ref}, which is not a table in the model`);
107
+ return false;
108
+ });
109
+ model.groups.push({ ...group, tables });
110
+ }
111
+ return { model, warnings };
112
+ }
113
+ function sameEnd(a, b) {
114
+ return a.table === b.table && a.columns.join(',') === b.columns.join(',');
115
+ }
@@ -0,0 +1,18 @@
1
+ import type { AddonConfig, SourceConfig } from './config.ts';
2
+ import type { Api, Database, Model } from './model.ts';
3
+ export type Extract = (config: SourceConfig) => Promise<Database | Api>;
4
+ export interface Source {
5
+ extract: Extract;
6
+ }
7
+ export type Importer = (specifier: string) => Promise<unknown>;
8
+ export declare function sourceModuleName(source: SourceConfig): string;
9
+ export declare function loadSource(source: SourceConfig, importer?: Importer): Promise<Source>;
10
+ /** Files to write under `out`, keyed by path, like renderMarkdown returns them. */
11
+ export type Files = Record<string, string>;
12
+ /** An add-on changes the model, writes files beside the Markdown, or both. */
13
+ export interface Addon {
14
+ enrich?: (model: Model) => Model | Promise<Model>;
15
+ render?: (model: Model) => Files | Promise<Files>;
16
+ }
17
+ export declare function addonModuleName(addon: AddonConfig): string;
18
+ export declare function loadAddon(addon: AddonConfig, importer?: Importer): Promise<Addon>;
@@ -0,0 +1,50 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ export function sourceModuleName(source) {
10
+ return source.module ?? `dbtour-source-${source.type}`;
11
+ }
12
+ export async function loadSource(source, importer = (s) => import(__rewriteRelativeImportExtension(s))) {
13
+ const name = sourceModuleName(source);
14
+ let loaded;
15
+ try {
16
+ loaded = await importer(name);
17
+ }
18
+ catch (error) {
19
+ const reason = error instanceof Error ? error.message : String(error);
20
+ throw new Error(`Source "${source.type}" is not installed: npm install ${name} (${reason})`, { cause: error });
21
+ }
22
+ const extract = loaded?.extract;
23
+ if (typeof extract !== 'function')
24
+ throw new Error(`${name} does not export extract(config), so it is not a dbtour source`);
25
+ return { extract: extract };
26
+ }
27
+ export function addonModuleName(addon) {
28
+ return addon.module ?? `dbtour-addon-${addon.type}`;
29
+ }
30
+ export async function loadAddon(addon, importer = (s) => import(__rewriteRelativeImportExtension(s))) {
31
+ const name = addonModuleName(addon);
32
+ let loaded;
33
+ try {
34
+ loaded = await importer(name);
35
+ }
36
+ catch (error) {
37
+ const reason = error instanceof Error ? error.message : String(error);
38
+ throw new Error(`Add-on "${addon.type}" is not installed: npm install ${name} (${reason})`, { cause: error });
39
+ }
40
+ const { enrich, render } = loaded ?? {};
41
+ if (typeof enrich !== 'function' && typeof render !== 'function') {
42
+ throw new Error(`${name} exports neither enrich(model) or render(model), so it is not a dbtour add-on`);
43
+ }
44
+ const result = {};
45
+ if (typeof enrich === 'function')
46
+ result.enrich = enrich;
47
+ if (typeof render === 'function')
48
+ result.render = render;
49
+ return result;
50
+ }
@@ -0,0 +1,8 @@
1
+ import { findTable, type Model } from '../model.ts';
2
+ export declare const wholeDiagramLimit = 40;
3
+ export declare function renderMarkdown(model: Model): Record<string, string>;
4
+ /** The product's name for a driver: mssql is SQL Server. */
5
+ export declare function driverName(driver: string): string;
6
+ /** One table's page on its own: what the page hands to an agent. */
7
+ export declare function renderTableMarkdown(model: Model, ref: string): string;
8
+ export { findTable };
@@ -0,0 +1,142 @@
1
+ // Markdown GitHub shows: an index with every table, and one page per table with its columns, keys,
2
+ // relations both ways, indexes, constraints and the diagram of its neighbourhood.
3
+ import { findTable, isUniqueColumn, relationsOf, tableRef } from "../model.js";
4
+ import { erDiagram, neighbourhood } from "./mermaid.js";
5
+ // Mermaid lays out a whole database only while it is small; above this many tables the index shows the
6
+ // groups and each table page shows its own neighbourhood instead.
7
+ export const wholeDiagramLimit = 40;
8
+ export function renderMarkdown(model) {
9
+ const files = { 'README.md': renderIndex(model) };
10
+ for (const database of model.databases) {
11
+ for (const schema of database.schemas) {
12
+ for (const table of schema.tables) {
13
+ const ref = tableRef(schema.name, table.name);
14
+ files[`tables/${ref}.md`] = renderTable(model, ref, table);
15
+ }
16
+ }
17
+ }
18
+ return files;
19
+ }
20
+ function renderIndex(model) {
21
+ const parts = [];
22
+ const single = model.databases.length === 1;
23
+ if (!single)
24
+ parts.push('# Databases\n');
25
+ for (const database of model.databases) {
26
+ parts.push(`${single ? '#' : '##'} ${database.name}\n`);
27
+ if (database.description)
28
+ parts.push(`${database.description}\n`);
29
+ parts.push(`${driverLine(database)}\n`);
30
+ parts.push('| Table | Kind | Columns | Description |\n|---|---|---|---|');
31
+ for (const schema of database.schemas) {
32
+ for (const table of schema.tables) {
33
+ const ref = tableRef(schema.name, table.name);
34
+ parts.push(`| [${ref}](tables/${ref}.md) | ${table.kind} | ${table.columns.length} | ${cell(table.description)} |`);
35
+ }
36
+ }
37
+ parts.push('');
38
+ }
39
+ for (const group of model.groups) {
40
+ parts.push(`## ${group.name}\n`);
41
+ if (group.description)
42
+ parts.push(`${group.description}\n`);
43
+ parts.push(group.tables.map((ref) => `- [${ref}](tables/${ref}.md)`).join('\n') + '\n');
44
+ parts.push(mermaid(erDiagram(model, group.tables)));
45
+ }
46
+ const refs = model.databases.flatMap((d) => d.schemas.flatMap((s) => s.tables.map((t) => tableRef(s.name, t.name))));
47
+ if (refs.length <= wholeDiagramLimit) {
48
+ parts.push('## Diagram\n');
49
+ parts.push(mermaid(erDiagram(model, refs)));
50
+ }
51
+ else {
52
+ parts.push(`## Diagram\n\n${refs.length} tables is too many for one diagram: each table's page draws its own neighbourhood.\n`);
53
+ }
54
+ return parts.join('\n');
55
+ }
56
+ /** The product's name for a driver: mssql is SQL Server. */
57
+ export function driverName(driver) {
58
+ const names = { mssql: 'SQL Server', postgres: 'PostgreSQL', mysql: 'MySQL', sqlite: 'SQLite' };
59
+ return names[driver] ?? driver;
60
+ }
61
+ function driverLine(database) {
62
+ const name = driverName(database.driver);
63
+ return database.version ? `${name} ${database.version}.` : `${name}.`;
64
+ }
65
+ /** One table's page on its own: what the page hands to an agent. */
66
+ export function renderTableMarkdown(model, ref) {
67
+ const found = findTable(model, ref);
68
+ if (!found)
69
+ throw new Error(`${ref} is not a table in the model`);
70
+ return renderTable(model, ref, found.table);
71
+ }
72
+ function renderTable(model, ref, table) {
73
+ const parts = [`# ${ref}\n`];
74
+ if (table.description)
75
+ parts.push(`${table.description}\n`);
76
+ const relations = relationsOf(model, ref);
77
+ parts.push('| Column | Type | Nullable | Default | Key | Description |\n|---|---|---|---|---|---|');
78
+ for (const column of table.columns) {
79
+ const keys = [];
80
+ if (table.primaryKey?.includes(column.name))
81
+ keys.push('PK');
82
+ for (const relation of relations.out) {
83
+ const i = relation.from.columns.indexOf(column.name);
84
+ if (i === -1)
85
+ continue;
86
+ const { table: toTable, columns } = relation.to;
87
+ keys.push(`FK → [${toTable}](${toTable}.md).${columns[i] ?? columns.join(',')}${relation.virtual ? ' (notes)' : ''}`);
88
+ }
89
+ if (isUniqueColumn(table, column.name))
90
+ keys.push('unique');
91
+ if (column.identity)
92
+ keys.push('identity');
93
+ if (column.computed !== undefined)
94
+ keys.push(`computed \`${column.computed}\``);
95
+ const dflt = column.default === undefined ? '' : `\`${column.default}\``;
96
+ parts.push(`| ${cell(column.name)} | ${cell(column.type)} | ${column.nullable ? 'yes' : 'no'} | ${cell(dflt)} | ${keys.join(', ')} | ${cell(column.description)} |`);
97
+ }
98
+ parts.push('');
99
+ if (relations.in.length > 0) {
100
+ parts.push('## Referenced by\n');
101
+ const lines = relations.in
102
+ .map((r) => `- [${r.from.table}](${r.from.table}.md).${r.from.columns.join(', ')}${r.virtual ? ' (notes)' : ''}`)
103
+ .sort();
104
+ parts.push(lines.join('\n') + '\n');
105
+ }
106
+ if (table.indexes.length > 0) {
107
+ parts.push('## Indexes\n');
108
+ parts.push('| Name | Columns | Unique |\n|---|---|---|');
109
+ for (const index of table.indexes) {
110
+ const columns = index.columns.join(', ') + (index.included?.length ? ` (includes ${index.included.join(', ')})` : '');
111
+ parts.push(`| ${cell(index.name)} | ${cell(columns)} | ${index.unique ? 'yes' : 'no'} |`);
112
+ }
113
+ parts.push('');
114
+ }
115
+ if (table.constraints.length > 0) {
116
+ parts.push('## Constraints\n');
117
+ parts.push('| Name | Kind | Columns | Definition |\n|---|---|---|---|');
118
+ for (const constraint of table.constraints) {
119
+ const definition = constraint.definition === undefined ? '' : `\`${constraint.definition}\``;
120
+ parts.push(`| ${cell(constraint.name)} | ${constraint.kind} | ${cell(constraint.columns.join(', '))} | ${cell(definition)} |`);
121
+ }
122
+ parts.push('');
123
+ }
124
+ if (table.definition !== undefined) {
125
+ parts.push('## Definition\n');
126
+ parts.push('```sql\n' + table.definition.trim() + '\n```\n');
127
+ }
128
+ const around = neighbourhood(model, ref);
129
+ if (around.length > 1) {
130
+ parts.push('## Diagram\n');
131
+ parts.push(mermaid(erDiagram(model, around)));
132
+ }
133
+ return parts.join('\n');
134
+ }
135
+ function mermaid(diagram) {
136
+ return '```mermaid\n' + diagram + '```\n';
137
+ }
138
+ /** A table cell: pipes and line breaks would end it. */
139
+ function cell(text) {
140
+ return (text ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
141
+ }
142
+ export { findTable };
@@ -0,0 +1,6 @@
1
+ import { type Model } from '../model.ts';
2
+ /** Mermaid takes letters, digits and underscores in an entity name. */
3
+ export declare function entityName(ref: string): string;
4
+ /** The table and every table one relation away, in the order the relations are listed. */
5
+ export declare function neighbourhood(model: Model, ref: string): string[];
6
+ export declare function erDiagram(model: Model, refs: string[]): string;
@@ -0,0 +1,72 @@
1
+ // Mermaid erDiagram text: GitHub draws it in the Markdown, no image service needed.
2
+ import { findTable, relationsOf } from "../model.js";
3
+ /** Mermaid takes letters, digits and underscores in an entity name. */
4
+ export function entityName(ref) {
5
+ return ref.replace(/[^A-Za-z0-9_]/g, '_');
6
+ }
7
+ /** The table and every table one relation away, in the order the relations are listed. */
8
+ export function neighbourhood(model, ref) {
9
+ const refs = [ref];
10
+ for (const relation of model.databases.flatMap((d) => d.relations)) {
11
+ const other = relation.from.table === ref ? relation.to.table : relation.to.table === ref ? relation.from.table : undefined;
12
+ if (other !== undefined && !refs.includes(other))
13
+ refs.push(other);
14
+ }
15
+ return refs;
16
+ }
17
+ export function erDiagram(model, refs) {
18
+ const lines = ['erDiagram'];
19
+ const shown = new Set(refs);
20
+ for (const ref of refs) {
21
+ const found = findTable(model, ref);
22
+ if (!found)
23
+ continue;
24
+ const foreign = new Set(relationsOf(model, ref).out.flatMap((r) => r.from.columns));
25
+ lines.push(` ${entityName(ref)} {`);
26
+ for (const column of found.table.columns) {
27
+ const keys = keyMarks(found.table, column.name, foreign);
28
+ lines.push(` ${attributeType(column.type)} ${attributeName(column.name)}${keys ? ' ' + keys : ''}`);
29
+ }
30
+ lines.push(' }');
31
+ }
32
+ for (const relation of model.databases.flatMap((d) => d.relations)) {
33
+ if (!shown.has(relation.from.table) || !shown.has(relation.to.table))
34
+ continue;
35
+ lines.push(` ${entityName(relation.from.table)} ${cardinality(model, relation)} ${entityName(relation.to.table)} : ${label(relation)}`);
36
+ }
37
+ return lines.join('\n') + '\n';
38
+ }
39
+ function keyMarks(table, column, foreign) {
40
+ const marks = [];
41
+ if (table.primaryKey?.includes(column))
42
+ marks.push('PK');
43
+ if (foreign.has(column))
44
+ marks.push('FK');
45
+ if (!marks.includes('PK') && table.constraints.some((c) => c.kind === 'unique' && c.columns.length === 1 && c.columns[0] === column))
46
+ marks.push('UK');
47
+ return marks.join(',');
48
+ }
49
+ // Mermaid 11 reads a type as one word of letters, digits, underscores, hyphens, brackets and the commas
50
+ // inside them (decimal(18,2) is fine), and a name as one word; it does not take quotes on either, so anything
51
+ // else becomes an underscore.
52
+ function attributeType(type) {
53
+ return type.replace(/^[^A-Za-z_]|[^A-Za-z0-9_,()[\]-]/g, '_');
54
+ }
55
+ function attributeName(name) {
56
+ return name.replace(/^[^A-Za-z_]|[^A-Za-z0-9_-]/g, '_');
57
+ }
58
+ /** many-to-one unless the referencing columns are themselves a key, then one-to-one. Dotted when virtual. */
59
+ function cardinality(model, relation) {
60
+ const from = findTable(model, relation.from.table)?.table;
61
+ const columns = relation.from.columns.join(',');
62
+ const isKey = from !== undefined &&
63
+ (from.primaryKey?.join(',') === columns ||
64
+ from.constraints.some((c) => c.kind === 'unique' && c.columns.join(',') === columns) ||
65
+ from.indexes.some((i) => i.unique && i.columns.join(',') === columns));
66
+ const line = relation.virtual ? '..' : '--';
67
+ return `${isKey ? '|o' : '}o'}${line}||`;
68
+ }
69
+ function label(relation) {
70
+ const text = relation.from.columns.join(', ') + (relation.virtual ? ' (notes)' : '');
71
+ return /^[A-Za-z0-9_]+$/.test(text) ? text : `"${text.replace(/"/g, "'")}"`;
72
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dbtour",
3
- "version": "0.2.0",
3
+ "version": "0.2.4",
4
4
  "description": "Tour your database and its API: searchable docs, relations and diagrams, generated from the schema, kept in the repo.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -17,14 +17,26 @@
17
17
  "dbtour": "bin/dbtour.mjs"
18
18
  },
19
19
  "exports": {
20
- ".": "./src/index.ts",
21
- "./model": "./src/model.ts",
22
- "./markdown": "./src/render/markdown.ts"
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./dist/index.js"
23
+ },
24
+ "./model": {
25
+ "types": "./dist/model.d.ts",
26
+ "default": "./dist/model.js"
27
+ },
28
+ "./markdown": {
29
+ "types": "./dist/render/markdown.d.ts",
30
+ "default": "./dist/render/markdown.js"
31
+ }
23
32
  },
24
33
  "files": [
25
34
  "bin",
26
- "src"
35
+ "dist"
27
36
  ],
37
+ "scripts": {
38
+ "prepack": "node ../../scripts/build.mjs"
39
+ },
28
40
  "keywords": [
29
41
  "database",
30
42
  "documentation",
package/src/cli.ts DELETED
@@ -1,156 +0,0 @@
1
- // The command. Reads dbtour.yaml in the working directory, writes under its `out`. Exit 1 for what was
2
- // asked and failed, 2 for what is missing, 0 otherwise.
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
- import { dirname, join, resolve } from 'node:path';
5
- import { pathToFileURL } from 'node:url';
6
- import { parseArgs } from 'node:util';
7
- import { configFile, defaultConfigText, modelFile, parseConfig, resolveConnection, type AddonConfig, type Config, type SourceConfig } from './config.ts';
8
- import { emptyModel, validate, type Api, type Database, type Model } from './model.ts';
9
- import { applyNotes, parseNotes } from './notes.ts';
10
- import { loadAddon, loadSource, type Addon, type Importer, type Source } from './plugins.ts';
11
- import { renderMarkdown } from './render/markdown.ts';
12
-
13
- export interface Io {
14
- cwd: string;
15
- log: (line: string) => void;
16
- error: (line: string) => void;
17
- env?: Record<string, string | undefined>;
18
- /** How a source is loaded; tests hand in their own. */
19
- load?: (source: SourceConfig) => Promise<Source>;
20
- /** How an add-on is loaded; tests hand in their own. */
21
- loadAddon?: (addon: AddonConfig) => Promise<Addon>;
22
- }
23
-
24
- const help = `Usage: dbtour <command>
25
-
26
- init [--source mssql] write ${configFile} here
27
- extract read every source in ${configFile}, write <out>/${modelFile}
28
- render read <out>/${modelFile} and the notes, write the Markdown and what the
29
- add-ons in ${configFile} render (explorer: index.html) under <out>
30
-
31
- Set the connection string in the environment variable ${configFile} names, never in the file.`;
32
-
33
- export async function main(argv: string[], io: Io = { cwd: process.cwd(), log: console.log, error: console.error }): Promise<number> {
34
- const { values, positionals } = parseArgs({ args: argv, options: { help: { type: 'boolean', short: 'h' }, source: { type: 'string' } }, allowPositionals: true, strict: false });
35
- const command = positionals[0];
36
- if (values.help || command === undefined) {
37
- io.log(help);
38
- return 0;
39
- }
40
- try {
41
- switch (command) {
42
- case 'init':
43
- return init(io, typeof values.source === 'string' ? values.source : 'mssql');
44
- case 'extract':
45
- return await extract(io);
46
- case 'render':
47
- return await render(io);
48
- default:
49
- io.error(`"${command}" is not a dbtour command.\n\n${help}`);
50
- return 1;
51
- }
52
- } catch (error) {
53
- io.error(error instanceof Error ? error.message : String(error));
54
- return (error as { exitCode?: number }).exitCode ?? 1;
55
- }
56
- }
57
-
58
- function missing(message: string): Error {
59
- return Object.assign(new Error(message), { exitCode: 2 });
60
- }
61
-
62
- function init(io: Io, source: string): number {
63
- const path = join(io.cwd, configFile);
64
- if (existsSync(path)) {
65
- io.error(`${configFile} already exists here. Edit it, or delete it and run dbtour init again.`);
66
- return 1;
67
- }
68
- writeFileSync(path, defaultConfigText(source));
69
- io.log(`Wrote ${configFile}. Set the connection string in DBTOUR_${source.toUpperCase()}, then run dbtour extract.`);
70
- return 0;
71
- }
72
-
73
- function readConfig(io: Io): Config {
74
- const path = join(io.cwd, configFile);
75
- if (!existsSync(path)) throw missing(`No ${configFile} here. Run dbtour init to write one.`);
76
- return parseConfig(readFileSync(path, 'utf8'));
77
- }
78
-
79
- async function extract(io: Io): Promise<number> {
80
- const config = readConfig(io);
81
- const env = io.env ?? process.env;
82
- const load = io.load ?? ((source: SourceConfig) => loadSource(source, importer(io.cwd)));
83
- const model: Model = { ...emptyModel(), generatedAt: new Date().toISOString() };
84
- for (const source of config.sources) {
85
- const connection = resolveConnection(source.connection, env);
86
- const { extract } = await load(source);
87
- let result: Database | Api;
88
- try {
89
- result = await extract({ ...source, connection });
90
- } catch (error) {
91
- io.error(`${source.name} (${source.type}): ${error instanceof Error ? error.message : String(error)}`);
92
- return 1;
93
- }
94
- if ('schemas' in result) {
95
- model.databases.push(result);
96
- const tables = result.schemas.reduce((n, s) => n + s.tables.length, 0);
97
- io.log(`${result.name}: ${tables} tables, ${result.relations.length} relations`);
98
- } else {
99
- model.apis.push(result);
100
- io.log(`${result.name}: ${result.endpoints.length} endpoints`);
101
- }
102
- }
103
- const checked = validate(model);
104
- if (!checked.ok) {
105
- io.error(`A source returned something that is not a model:\n ${checked.errors.join('\n ')}`);
106
- return 1;
107
- }
108
- const path = join(io.cwd, config.out, modelFile);
109
- mkdirSync(dirname(path), { recursive: true });
110
- writeFileSync(path, JSON.stringify(model, null, 2) + '\n');
111
- io.log(`Wrote ${join(config.out, modelFile)}. Run dbtour render to write the docs.`);
112
- return 0;
113
- }
114
-
115
- /** A module given as a path is relative to the project, not to dbtour. */
116
- function importer(cwd: string): Importer {
117
- return (specifier) => import(specifier.startsWith('.') || specifier.startsWith('/') ? pathToFileURL(resolve(cwd, specifier)).href : specifier);
118
- }
119
-
120
- async function render(io: Io): Promise<number> {
121
- const config = readConfig(io);
122
- const modelPath = join(io.cwd, config.out, modelFile);
123
- if (!existsSync(modelPath)) throw missing(`No ${join(config.out, modelFile)} yet. Run dbtour extract to write it.`);
124
- const checked = validate(JSON.parse(readFileSync(modelPath, 'utf8')));
125
- if (!checked.ok) {
126
- io.error(`${join(config.out, modelFile)} is not a model:\n ${checked.errors.join('\n ')}`);
127
- return 1;
128
- }
129
- let model = checked.model;
130
- const notesPath = join(io.cwd, config.notes);
131
- if (existsSync(notesPath)) {
132
- const applied = applyNotes(model, parseNotes(readFileSync(notesPath, 'utf8')));
133
- for (const warning of applied.warnings) io.error(warning);
134
- model = applied.model;
135
- }
136
- // Every add-on is loaded before anything is written, so a missing one stops the run with nothing half done.
137
- const load = io.loadAddon ?? ((addon: AddonConfig) => loadAddon(addon, importer(io.cwd)));
138
- const addons: Addon[] = [];
139
- for (const addon of config.addons) {
140
- try {
141
- addons.push(await load(addon));
142
- } catch (error) {
143
- throw missing(error instanceof Error ? error.message : String(error));
144
- }
145
- }
146
- for (const addon of addons) if (addon.enrich) model = await addon.enrich(model);
147
- const files = renderMarkdown(model);
148
- for (const addon of addons) if (addon.render) Object.assign(files, await addon.render(model));
149
- for (const [name, text] of Object.entries(files)) {
150
- const path = join(io.cwd, config.out, name);
151
- mkdirSync(dirname(path), { recursive: true });
152
- writeFileSync(path, text);
153
- }
154
- io.log(`Wrote ${Object.keys(files).length} files to ${config.out}.`);
155
- return 0;
156
- }