dbtour 0.2.0 → 0.2.2

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/src/config.ts DELETED
@@ -1,93 +0,0 @@
1
- // dbtour.yaml: where the model goes, where it comes from, where the notes are.
2
- import { parse } from 'yaml';
3
-
4
- export interface SourceConfig {
5
- type: string;
6
- name: string;
7
- /** `${NAME}`: the environment variable holding the connection string. Never the string itself. */
8
- connection?: string;
9
- /** A module to import instead of dbtour-source-<type>. */
10
- module?: string;
11
- [option: string]: unknown;
12
- }
13
-
14
- export interface AddonConfig {
15
- type: string;
16
- /** A module to import instead of dbtour-addon-<type>. */
17
- module?: string;
18
- [option: string]: unknown;
19
- }
20
-
21
- export interface Config {
22
- out: string;
23
- notes: string;
24
- sources: SourceConfig[];
25
- addons: AddonConfig[];
26
- }
27
-
28
- export const configFile = 'dbtour.yaml';
29
- export const modelFile = 'schema.json';
30
-
31
- export function parseConfig(text: string): Config {
32
- const raw: unknown = parse(text) ?? {};
33
- if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) throw new Error(`${configFile} must be a map with out, notes and sources`);
34
- const doc = raw as Record<string, unknown>;
35
- if (!Array.isArray(doc.sources) || doc.sources.length === 0) throw new Error(`${configFile} needs a sources list with at least one source`);
36
- const sources = doc.sources.map((value, i): SourceConfig => {
37
- if (typeof value !== 'object' || value === null) throw new Error(`sources[${i}] must be a map with type and name`);
38
- const entry = value as Record<string, unknown>;
39
- if (typeof entry.type !== 'string' || entry.type === '') throw new Error(`sources[${i}].type is required: mssql, for instance`);
40
- if (typeof entry.name !== 'string' || entry.name === '') throw new Error(`sources[${i}].name is required: the name the docs call this database`);
41
- return { ...entry, type: entry.type, name: entry.name };
42
- });
43
- return {
44
- out: typeof doc.out === 'string' ? doc.out : 'docs/dbtour',
45
- notes: typeof doc.notes === 'string' ? doc.notes : 'dbtour.notes.yaml',
46
- sources,
47
- addons: parseAddons(doc.addons),
48
- };
49
- }
50
-
51
- /** `addons: [explorer]` or `addons: [{ type: explorer, module: ./page.ts }]`; none when absent. */
52
- function parseAddons(value: unknown): AddonConfig[] {
53
- if (value === undefined) return [];
54
- if (!Array.isArray(value)) throw new Error('addons must be a list of add-on names, like [explorer]');
55
- return value.map((entry, i): AddonConfig => {
56
- if (typeof entry === 'string' && entry !== '') return { type: entry };
57
- if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) throw new Error(`addons[${i}] must be a name or a map with type`);
58
- const map = entry as Record<string, unknown>;
59
- if (typeof map.type !== 'string' || map.type === '') throw new Error(`addons[${i}].type is required: explorer, for instance`);
60
- return { ...map, type: map.type };
61
- });
62
- }
63
-
64
- export function defaultConfigText(type: string): string {
65
- const variable = `DBTOUR_${type.toUpperCase()}`;
66
- return `# dbtour: see https://github.com/victorsaly/dbtour
67
- out: docs/dbtour
68
- sources:
69
- - type: ${type}
70
- name: ${type === 'mssql' ? 'MyDatabase' : 'my-database'}
71
- # The name of the environment variable holding the connection string. Never the string itself.
72
- connection: \${${variable}}
73
- # Descriptions, relations and groups the database cannot say. Optional.
74
- notes: dbtour.notes.yaml
75
- # What else render writes. explorer is the page: npm install --save-dev dbtour-addon-explorer
76
- addons:
77
- - explorer
78
- `;
79
- }
80
-
81
- const variablePattern = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
82
-
83
- export function resolveConnection(value: string | undefined, env: Record<string, string | undefined>): string | undefined {
84
- if (value === undefined) return undefined;
85
- const match = variablePattern.exec(value);
86
- if (!match) throw new Error(`connection must name an environment variable, like \${DBTOUR_MSSQL}: never write the connection string into ${configFile}`);
87
- const resolved = env[match[1]];
88
- if (resolved === undefined || resolved === '') {
89
- // Exit 2: what is missing, not what failed.
90
- throw Object.assign(new Error(`Set ${match[1]} to the connection string, then run it again`), { exitCode: 2 });
91
- }
92
- return resolved;
93
- }
package/src/model.ts DELETED
@@ -1,318 +0,0 @@
1
- // The model: what every source writes and everything else reads. Kept to plain JSON so it can be committed,
2
- // diffed and handed to a page or an agent. Refs name tables as `schema.table` and columns as
3
- // `schema.table.column`.
4
-
5
- export const MODEL_VERSION = 1;
6
-
7
- export interface Column {
8
- name: string;
9
- /** As the database spells it, with its size: nvarchar(320), decimal(18,2). */
10
- type: string;
11
- nullable: boolean;
12
- default?: string;
13
- /** The expression of a computed column. */
14
- computed?: string;
15
- identity?: boolean;
16
- description?: string;
17
- }
18
-
19
- export interface Index {
20
- name: string;
21
- columns: string[];
22
- unique: boolean;
23
- included?: string[];
24
- }
25
-
26
- export interface Constraint {
27
- name: string;
28
- kind: 'primary' | 'unique' | 'check' | 'foreign';
29
- columns: string[];
30
- definition?: string;
31
- }
32
-
33
- export interface Table {
34
- name: string;
35
- kind: 'table' | 'view';
36
- description?: string;
37
- columns: Column[];
38
- primaryKey?: string[];
39
- indexes: Index[];
40
- constraints: Constraint[];
41
- /** The SQL of a view. */
42
- definition?: string;
43
- }
44
-
45
- export interface Schema {
46
- name: string;
47
- tables: Table[];
48
- }
49
-
50
- export interface RelationEnd {
51
- table: string;
52
- columns: string[];
53
- }
54
-
55
- export interface Relation {
56
- name?: string;
57
- from: RelationEnd;
58
- to: RelationEnd;
59
- /** Declared in the notes, not in the database. */
60
- virtual?: boolean;
61
- onDelete?: string;
62
- onUpdate?: string;
63
- }
64
-
65
- export interface Database {
66
- name: string;
67
- driver: string;
68
- version?: string;
69
- description?: string;
70
- schemas: Schema[];
71
- relations: Relation[];
72
- }
73
-
74
- export interface Endpoint {
75
- method: string;
76
- path: string;
77
- summary?: string;
78
- description?: string;
79
- tags?: string[];
80
- }
81
-
82
- export interface Api {
83
- name: string;
84
- description?: string;
85
- endpoints: Endpoint[];
86
- }
87
-
88
- /** An endpoint and the tables it touches. */
89
- export interface Link {
90
- api: string;
91
- method: string;
92
- path: string;
93
- tables: string[];
94
- }
95
-
96
- export interface Group {
97
- name: string;
98
- description?: string;
99
- tables: string[];
100
- }
101
-
102
- export interface Model {
103
- dbtour: typeof MODEL_VERSION;
104
- generatedAt?: string;
105
- databases: Database[];
106
- apis: Api[];
107
- links: Link[];
108
- groups: Group[];
109
- }
110
-
111
- export function emptyModel(): Model {
112
- return { dbtour: MODEL_VERSION, databases: [], apis: [], links: [], groups: [] };
113
- }
114
-
115
- export interface TableRef {
116
- schema: string;
117
- table: string;
118
- column?: string;
119
- }
120
-
121
- export function tableRef(schema: string, table: string): string {
122
- return `${schema}.${table}`;
123
- }
124
-
125
- export function parseRef(ref: string): TableRef {
126
- const parts = ref.split('.');
127
- if (parts.length < 2 || parts.length > 3 || parts.some((p) => p === '')) {
128
- throw new Error(`"${ref}" is not a ref: write schema.table, or schema.table.column`);
129
- }
130
- const [schema, table, column] = parts;
131
- return column === undefined ? { schema, table } : { schema, table, column };
132
- }
133
-
134
- export interface Found {
135
- database: Database;
136
- schema: Schema;
137
- table: Table;
138
- }
139
-
140
- export function findTable(model: Model, ref: string): Found | undefined {
141
- const { schema: schemaName, table: tableName } = parseRef(ref);
142
- for (const database of model.databases) {
143
- for (const schema of database.schemas) {
144
- if (schema.name !== schemaName) continue;
145
- const table = schema.tables.find((t) => t.name === tableName);
146
- if (table) return { database, schema, table };
147
- }
148
- }
149
- return undefined;
150
- }
151
-
152
- /** Unique on its own, by a constraint or an index, and not the primary key, which says so already. */
153
- export function isUniqueColumn(table: Table, column: string): boolean {
154
- if (table.primaryKey?.includes(column)) return false;
155
- return (
156
- table.constraints.some((c) => c.kind === 'unique' && c.columns.length === 1 && c.columns[0] === column) ||
157
- table.indexes.some((i) => i.unique && i.columns.length === 1 && i.columns[0] === column)
158
- );
159
- }
160
-
161
- export function relationsOf(model: Model, ref: string): { out: Relation[]; in: Relation[] } {
162
- const out: Relation[] = [];
163
- const inward: Relation[] = [];
164
- for (const database of model.databases) {
165
- for (const relation of database.relations) {
166
- if (relation.from.table === ref) out.push(relation);
167
- if (relation.to.table === ref) inward.push(relation);
168
- }
169
- }
170
- return { out, in: inward };
171
- }
172
-
173
- export type Validation = { ok: true; model: Model } | { ok: false; errors: string[] };
174
-
175
- /** Checks the shape, then that every relation points at a table in its database. */
176
- export function validate(value: unknown): Validation {
177
- const errors: string[] = [];
178
- const check = new Checker(errors);
179
- if (!check.object(value, 'model')) return { ok: false, errors };
180
- const model = value as Record<string, unknown>;
181
- if (model.dbtour !== MODEL_VERSION) errors.push(`model.dbtour must be ${MODEL_VERSION}, got ${JSON.stringify(model.dbtour)}`);
182
- check.optionalString(model.generatedAt, 'generatedAt');
183
- check.list(model.databases, 'databases', (d, p) => checkDatabase(check, d, p));
184
- check.list(model.apis, 'apis', (a, p) => {
185
- if (!check.object(a, p)) return;
186
- check.string(a.name, `${p}.name`);
187
- check.optionalString(a.description, `${p}.description`);
188
- check.list(a.endpoints, `${p}.endpoints`, (e, ep) => {
189
- if (!check.object(e, ep)) return;
190
- check.string(e.method, `${ep}.method`);
191
- check.string(e.path, `${ep}.path`);
192
- check.optionalString(e.summary, `${ep}.summary`);
193
- check.optionalString(e.description, `${ep}.description`);
194
- if (e.tags !== undefined) check.strings(e.tags, `${ep}.tags`);
195
- });
196
- });
197
- check.list(model.links, 'links', (l, p) => {
198
- if (!check.object(l, p)) return;
199
- check.string(l.api, `${p}.api`);
200
- check.string(l.method, `${p}.method`);
201
- check.string(l.path, `${p}.path`);
202
- check.strings(l.tables, `${p}.tables`);
203
- });
204
- check.list(model.groups, 'groups', (g, p) => {
205
- if (!check.object(g, p)) return;
206
- check.string(g.name, `${p}.name`);
207
- check.optionalString(g.description, `${p}.description`);
208
- check.strings(g.tables, `${p}.tables`);
209
- });
210
- if (errors.length > 0) return { ok: false, errors };
211
-
212
- const typed = value as Model;
213
- typed.databases.forEach((database, i) => {
214
- const refs = new Set(database.schemas.flatMap((s) => s.tables.map((t) => tableRef(s.name, t.name))));
215
- database.relations.forEach((relation, j) => {
216
- for (const end of ['from', 'to'] as const) {
217
- if (!refs.has(relation[end].table)) {
218
- errors.push(`databases[${i}].relations[${j}].${end}.table "${relation[end].table}" is not a table of ${database.name}`);
219
- }
220
- }
221
- });
222
- });
223
- return errors.length > 0 ? { ok: false, errors } : { ok: true, model: typed };
224
- }
225
-
226
- function checkDatabase(check: Checker, d: unknown, p: string): void {
227
- if (!check.object(d, p)) return;
228
- check.string(d.name, `${p}.name`);
229
- check.string(d.driver, `${p}.driver`);
230
- check.optionalString(d.version, `${p}.version`);
231
- check.optionalString(d.description, `${p}.description`);
232
- check.list(d.schemas, `${p}.schemas`, (s, sp) => {
233
- if (!check.object(s, sp)) return;
234
- check.string(s.name, `${sp}.name`);
235
- check.list(s.tables, `${sp}.tables`, (t, tp) => checkTable(check, t, tp));
236
- });
237
- check.list(d.relations, `${p}.relations`, (r, rp) => {
238
- if (!check.object(r, rp)) return;
239
- check.optionalString(r.name, `${rp}.name`);
240
- for (const end of ['from', 'to']) {
241
- const e = r[end];
242
- if (!check.object(e, `${rp}.${end}`)) continue;
243
- check.string(e.table, `${rp}.${end}.table`);
244
- check.strings(e.columns, `${rp}.${end}.columns`);
245
- }
246
- check.optionalBoolean(r.virtual, `${rp}.virtual`);
247
- });
248
- }
249
-
250
- function checkTable(check: Checker, t: unknown, p: string): void {
251
- if (!check.object(t, p)) return;
252
- check.string(t.name, `${p}.name`);
253
- if (t.kind !== 'table' && t.kind !== 'view') check.errors.push(`${p}.kind must be "table" or "view"`);
254
- check.optionalString(t.description, `${p}.description`);
255
- check.optionalString(t.definition, `${p}.definition`);
256
- if (t.primaryKey !== undefined) check.strings(t.primaryKey, `${p}.primaryKey`);
257
- check.list(t.columns, `${p}.columns`, (c, cp) => {
258
- if (!check.object(c, cp)) return;
259
- check.string(c.name, `${cp}.name`);
260
- check.string(c.type, `${cp}.type`);
261
- if (typeof c.nullable !== 'boolean') check.errors.push(`${cp}.nullable must be true or false`);
262
- check.optionalString(c.default, `${cp}.default`);
263
- check.optionalString(c.computed, `${cp}.computed`);
264
- check.optionalBoolean(c.identity, `${cp}.identity`);
265
- check.optionalString(c.description, `${cp}.description`);
266
- });
267
- check.list(t.indexes, `${p}.indexes`, (x, xp) => {
268
- if (!check.object(x, xp)) return;
269
- check.string(x.name, `${xp}.name`);
270
- check.strings(x.columns, `${xp}.columns`);
271
- if (typeof x.unique !== 'boolean') check.errors.push(`${xp}.unique must be true or false`);
272
- });
273
- check.list(t.constraints, `${p}.constraints`, (c, cp) => {
274
- if (!check.object(c, cp)) return;
275
- check.string(c.name, `${cp}.name`);
276
- if (!['primary', 'unique', 'check', 'foreign'].includes(String(c.kind))) check.errors.push(`${cp}.kind is not a constraint kind`);
277
- check.strings(c.columns, `${cp}.columns`);
278
- check.optionalString(c.definition, `${cp}.definition`);
279
- });
280
- }
281
-
282
- class Checker {
283
- errors: string[];
284
-
285
- constructor(errors: string[]) {
286
- this.errors = errors;
287
- }
288
-
289
- object(value: unknown, path: string): value is Record<string, any> { // eslint-disable-line @typescript-eslint/no-explicit-any
290
- if (typeof value === 'object' && value !== null && !Array.isArray(value)) return true;
291
- this.errors.push(`${path} must be an object`);
292
- return false;
293
- }
294
-
295
- string(value: unknown, path: string): void {
296
- if (typeof value !== 'string' || value === '') this.errors.push(`${path} must be a non-empty string`);
297
- }
298
-
299
- optionalString(value: unknown, path: string): void {
300
- if (value !== undefined && typeof value !== 'string') this.errors.push(`${path} must be a string when given`);
301
- }
302
-
303
- optionalBoolean(value: unknown, path: string): void {
304
- if (value !== undefined && typeof value !== 'boolean') this.errors.push(`${path} must be true or false when given`);
305
- }
306
-
307
- strings(value: unknown, path: string): void {
308
- if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) this.errors.push(`${path} must be a list of strings`);
309
- }
310
-
311
- list(value: unknown, path: string, each: (item: unknown, path: string) => void): void {
312
- if (!Array.isArray(value)) {
313
- this.errors.push(`${path} must be a list`);
314
- return;
315
- }
316
- value.forEach((item, i) => each(item, `${path}[${i}]`));
317
- }
318
- }
package/src/notes.ts DELETED
@@ -1,130 +0,0 @@
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, type Group, type Model, type Relation } from './model.ts';
5
-
6
- export interface TableNotes {
7
- description?: string;
8
- columns?: Record<string, string>;
9
- }
10
-
11
- export interface RelationNote {
12
- from: string;
13
- to: string;
14
- }
15
-
16
- export interface Notes {
17
- tables: Record<string, TableNotes>;
18
- relations: RelationNote[];
19
- groups: Group[];
20
- }
21
-
22
- export function parseNotes(text: string): Notes {
23
- const raw: unknown = parse(text) ?? {};
24
- if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) throw new Error('the notes file must be a map with tables, relations and groups');
25
- const doc = raw as Record<string, unknown>;
26
- const notes: Notes = { tables: {}, relations: [], groups: [] };
27
-
28
- if (doc.tables !== undefined) {
29
- if (typeof doc.tables !== 'object' || doc.tables === null || Array.isArray(doc.tables)) throw new Error('tables must be a map of schema.table to its notes');
30
- for (const [ref, value] of Object.entries(doc.tables as Record<string, unknown>)) {
31
- parseRef(ref);
32
- if (typeof value !== 'object' || value === null) throw new Error(`tables.${ref} must be a map with description and columns`);
33
- const entry = value as Record<string, unknown>;
34
- const table: TableNotes = {};
35
- if (entry.description !== undefined) table.description = String(entry.description);
36
- if (entry.columns !== undefined) {
37
- if (typeof entry.columns !== 'object' || entry.columns === null || Array.isArray(entry.columns)) throw new Error(`tables.${ref}.columns must be a map of column to description`);
38
- table.columns = Object.fromEntries(Object.entries(entry.columns as Record<string, unknown>).map(([k, v]) => [k, String(v)]));
39
- }
40
- notes.tables[ref] = table;
41
- }
42
- }
43
-
44
- if (doc.relations !== undefined) {
45
- if (!Array.isArray(doc.relations)) throw new Error('relations must be a list of { from, to }');
46
- doc.relations.forEach((value, i) => {
47
- if (typeof value !== 'object' || value === null) throw new Error(`relations[${i}] must be { from, to }`);
48
- const { from, to } = value as Record<string, unknown>;
49
- for (const [end, ref] of [['from', from], ['to', to]] as const) {
50
- if (typeof ref !== 'string' || parseRef(ref).column === undefined) throw new Error(`relations[${i}].${end} must be schema.table.column`);
51
- }
52
- notes.relations.push({ from: from as string, to: to as string });
53
- });
54
- }
55
-
56
- if (doc.groups !== undefined) {
57
- if (!Array.isArray(doc.groups)) throw new Error('groups must be a list of { name, description, tables }');
58
- doc.groups.forEach((value, i) => {
59
- if (typeof value !== 'object' || value === null) throw new Error(`groups[${i}] must be { name, description, tables }`);
60
- const entry = value as Record<string, unknown>;
61
- if (typeof entry.name !== 'string' || entry.name === '') throw new Error(`groups[${i}].name is required`);
62
- if (!Array.isArray(entry.tables) || entry.tables.some((t) => typeof t !== 'string')) throw new Error(`groups[${i}].tables must be a list of schema.table`);
63
- entry.tables.forEach((t) => parseRef(t));
64
- const group: Group = { name: entry.name, tables: entry.tables as string[] };
65
- if (entry.description !== undefined) group.description = String(entry.description);
66
- notes.groups.push(group);
67
- });
68
- }
69
- return notes;
70
- }
71
-
72
- export interface Applied {
73
- model: Model;
74
- /** What the notes name that the model does not have; said, then skipped. */
75
- warnings: string[];
76
- }
77
-
78
- export function applyNotes(input: Model, notes: Notes): Applied {
79
- const model = structuredClone(input);
80
- const warnings: string[] = [];
81
-
82
- for (const [ref, tableNotes] of Object.entries(notes.tables)) {
83
- const found = findTable(model, ref);
84
- if (!found) {
85
- warnings.push(`notes: ${ref} is not a table in the model`);
86
- continue;
87
- }
88
- if (tableNotes.description !== undefined) found.table.description = tableNotes.description;
89
- for (const [name, description] of Object.entries(tableNotes.columns ?? {})) {
90
- const column = found.table.columns.find((c) => c.name === name);
91
- if (!column) {
92
- warnings.push(`notes: ${ref}.${name} is not a column in the model`);
93
- continue;
94
- }
95
- column.description = description;
96
- }
97
- }
98
-
99
- for (const note of notes.relations) {
100
- const from = parseRef(note.from);
101
- const to = parseRef(note.to);
102
- const relation: Relation = {
103
- from: { table: tableRef(from.schema, from.table), columns: [from.column!] },
104
- to: { table: tableRef(to.schema, to.table), columns: [to.column!] },
105
- virtual: true,
106
- };
107
- const fromTable = findTable(model, relation.from.table);
108
- const toTable = findTable(model, relation.to.table);
109
- if (!fromTable || !toTable) {
110
- warnings.push(`notes: relation ${note.from} → ${note.to} names a table that is not in the model`);
111
- continue;
112
- }
113
- const exists = fromTable.database.relations.some((r) => sameEnd(r.from, relation.from) && sameEnd(r.to, relation.to));
114
- if (!exists) fromTable.database.relations.push(relation);
115
- }
116
-
117
- for (const group of notes.groups) {
118
- const tables = group.tables.filter((ref) => {
119
- if (findTable(model, ref)) return true;
120
- warnings.push(`notes: group ${group.name} lists ${ref}, which is not a table in the model`);
121
- return false;
122
- });
123
- model.groups.push({ ...group, tables });
124
- }
125
- return { model, warnings };
126
- }
127
-
128
- function sameEnd(a: { table: string; columns: string[] }, b: { table: string; columns: string[] }): boolean {
129
- return a.table === b.table && a.columns.join(',') === b.columns.join(',');
130
- }
package/src/plugins.ts DELETED
@@ -1,63 +0,0 @@
1
- // Sources and add-ons are packages named after their type: `type: mssql` loads dbtour-source-mssql, the
2
- // add-on `explorer` loads dbtour-addon-explorer. A `module` in the config loads that instead, for one kept
3
- // in the project.
4
- import type { AddonConfig, SourceConfig } from './config.ts';
5
- import type { Api, Database, Model } from './model.ts';
6
-
7
- export type Extract = (config: SourceConfig) => Promise<Database | Api>;
8
-
9
- export interface Source {
10
- extract: Extract;
11
- }
12
-
13
- export type Importer = (specifier: string) => Promise<unknown>;
14
-
15
- export function sourceModuleName(source: SourceConfig): string {
16
- return source.module ?? `dbtour-source-${source.type}`;
17
- }
18
-
19
- export async function loadSource(source: SourceConfig, importer: Importer = (s) => import(s)): Promise<Source> {
20
- const name = sourceModuleName(source);
21
- let loaded: unknown;
22
- try {
23
- loaded = await importer(name);
24
- } catch (error) {
25
- const reason = error instanceof Error ? error.message : String(error);
26
- throw new Error(`Source "${source.type}" is not installed: npm install ${name} (${reason})`, { cause: error });
27
- }
28
- const extract = (loaded as { extract?: unknown } | null)?.extract;
29
- if (typeof extract !== 'function') throw new Error(`${name} does not export extract(config), so it is not a dbtour source`);
30
- return { extract: extract as Extract };
31
- }
32
-
33
- /** Files to write under `out`, keyed by path, like renderMarkdown returns them. */
34
- export type Files = Record<string, string>;
35
-
36
- /** An add-on changes the model, writes files beside the Markdown, or both. */
37
- export interface Addon {
38
- enrich?: (model: Model) => Model | Promise<Model>;
39
- render?: (model: Model) => Files | Promise<Files>;
40
- }
41
-
42
- export function addonModuleName(addon: AddonConfig): string {
43
- return addon.module ?? `dbtour-addon-${addon.type}`;
44
- }
45
-
46
- export async function loadAddon(addon: AddonConfig, importer: Importer = (s) => import(s)): Promise<Addon> {
47
- const name = addonModuleName(addon);
48
- let loaded: unknown;
49
- try {
50
- loaded = await importer(name);
51
- } catch (error) {
52
- const reason = error instanceof Error ? error.message : String(error);
53
- throw new Error(`Add-on "${addon.type}" is not installed: npm install ${name} (${reason})`, { cause: error });
54
- }
55
- const { enrich, render } = (loaded as { enrich?: unknown; render?: unknown } | null) ?? {};
56
- if (typeof enrich !== 'function' && typeof render !== 'function') {
57
- throw new Error(`${name} exports neither enrich(model) or render(model), so it is not a dbtour add-on`);
58
- }
59
- const result: Addon = {};
60
- if (typeof enrich === 'function') result.enrich = enrich as Addon['enrich'];
61
- if (typeof render === 'function') result.render = render as Addon['render'];
62
- return result;
63
- }