dbtour 0.6.0 → 0.7.1

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/cli.js CHANGED
@@ -14,7 +14,7 @@ import { dirname, join, resolve } from 'node:path';
14
14
  import { pathToFileURL } from 'node:url';
15
15
  import { parseArgs } from 'node:util';
16
16
  import { configFile, defaultConfigText, modelFile, parseConfig, resolveConnection } from "./config.js";
17
- import { emptyModel, validate } from "./model.js";
17
+ import { emptyModel, scope, validate } from "./model.js";
18
18
  import { applyNotes, parseNotes } from "./notes.js";
19
19
  import { answerLine } from "./mcp.js";
20
20
  import { loadAddon, loadSource } from "./plugins.js";
@@ -119,7 +119,10 @@ async function extract(io) {
119
119
  function importer(cwd) {
120
120
  return (specifier) => import(__rewriteRelativeImportExtension(specifier.startsWith('.') || specifier.startsWith('/') ? pathToFileURL(resolve(cwd, specifier)).href : specifier));
121
121
  }
122
- /** <out>/schema.json with the notes merged in, as render and mcp read it. */
122
+ /**
123
+ * <out>/schema.json with the notes merged in, as render and mcp read it: scoped first, so with several
124
+ * databases the notes may name a table with its database or, where only one has it, without.
125
+ */
123
126
  function readModel(io, config) {
124
127
  const modelPath = join(io.cwd, config.out, modelFile);
125
128
  if (!existsSync(modelPath))
@@ -127,10 +130,11 @@ function readModel(io, config) {
127
130
  const checked = validate(JSON.parse(readFileSync(modelPath, 'utf8')));
128
131
  if (!checked.ok)
129
132
  throw new Error(`${join(config.out, modelFile)} is not a model:\n ${checked.errors.join('\n ')}`);
133
+ const model = scope(checked.model);
130
134
  const notesPath = join(io.cwd, config.notes);
131
135
  if (!existsSync(notesPath))
132
- return checked.model;
133
- const applied = applyNotes(checked.model, parseNotes(readFileSync(notesPath, 'utf8')));
136
+ return model;
137
+ const applied = applyNotes(model, parseNotes(readFileSync(notesPath, 'utf8')));
134
138
  for (const warning of applied.warnings)
135
139
  io.error(warning);
136
140
  return applied.model;
package/dist/mcp.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // table, read it, join two) instead of being handed all of it. JSON-RPC, one message per line; written here
3
3
  // rather than taken from the SDK, since four methods are all it needs and core keeps its one dependency.
4
4
  // Read-only and structure only, like the model: there are no rows to give.
5
- import { findTable, tableRef } from "./model.js";
5
+ import { findTables, localRef, tableRef } from "./model.js";
6
6
  import { driverName, formatCount, renderTableMarkdown } from "./render/markdown.js";
7
7
  import { buildIndex, search } from "./search.js";
8
8
  // The versions this server speaks, newest first. It uses only initialize, ping, tools/list and tools/call,
@@ -14,7 +14,7 @@ export const overviewLimit = 100;
14
14
  // Enough hits to choose from without reading past the good ones; the agent can ask for up to the maximum.
15
15
  const searchDefault = 20;
16
16
  const searchMaximum = 100;
17
- const instructions = `dbtour knows the structure of this project's databases: tables, views, columns, keys, relations, indexes and the descriptions people wrote. It never holds rows. Start with overview for the databases and their largest tables; search finds a table or column by any part of its name or description; table gives one table's columns, keys and relations as Markdown; path gives the joins between two tables. Name tables as schema.table, and read a table before writing SQL against it.`;
17
+ const instructions = `dbtour knows the structure of this project's databases: tables, views, columns, keys, relations, indexes and the descriptions people wrote. It never holds rows. Start with overview for the databases and their largest tables; search finds a table or column by any part of its name or description; table gives one table's columns, keys and relations as Markdown; path gives the joins between two tables. Name tables as overview and search do (schema.table, or Database:schema.table when there are several databases), and read a table before writing SQL against it.`;
18
18
  const readOnly = { readOnlyHint: true, openWorldHint: false };
19
19
  export const toolList = [
20
20
  {
@@ -44,7 +44,7 @@ export const toolList = [
44
44
  description: 'One table or view as Markdown: its columns with types, keys and descriptions, the relations both ways, indexes, constraints, a view\'s SQL and a diagram of its neighbours. Read it before writing SQL against the table.',
45
45
  inputSchema: {
46
46
  type: 'object',
47
- properties: { ref: { type: 'string', description: 'schema.table, like dbo.Orders. A bare name works when only one schema has it.' } },
47
+ properties: { ref: { type: 'string', description: 'schema.table, like dbo.Orders, or Sales:dbo.Orders with several databases. A shorter name works when only one table has it.' } },
48
48
  required: ['ref'],
49
49
  },
50
50
  annotations: readOnly,
@@ -56,8 +56,8 @@ export const toolList = [
56
56
  inputSchema: {
57
57
  type: 'object',
58
58
  properties: {
59
- from: { type: 'string', description: 'The table to start from, schema.table.' },
60
- to: { type: 'string', description: 'The table to reach, schema.table.' },
59
+ from: { type: 'string', description: 'The table to start from, named as table takes it.' },
60
+ to: { type: 'string', description: 'The table to reach, named as table takes it.' },
61
61
  },
62
62
  required: ['from', 'to'],
63
63
  },
@@ -226,20 +226,34 @@ function searchText(model, query, limit) {
226
226
  return `Nothing matches "${query}". Every word must match: try fewer words, or part of a name.`;
227
227
  return hits.map((h) => `- ${h.kind} ${h.title}: ${h.detail}`).join('\n');
228
228
  }
229
- /** What an agent wrote, as the table it means: exact, then ignoring case, then a bare name in one schema. */
229
+ /**
230
+ * What an agent wrote, as the table it means: exact, then ignoring case, as the whole name, the name without
231
+ * its database, or a bare table name. A name several tables answer to is a problem that lists them.
232
+ */
230
233
  function resolve(model, written) {
231
234
  const parts = written.trim().split('.');
232
235
  // schema.table.column is the table; a bare name is looked up by name alone.
233
236
  const wanted = parts.length === 3 ? parts.slice(0, 2).join('.') : parts.join('.');
234
- if (parts.length >= 2 && parts.length <= 3 && !parts.includes('') && findTable(model, wanted))
235
- return { ref: wanted };
236
- const all = model.databases.flatMap((d) => d.schemas.flatMap((s) => s.tables.map((t) => tableRef(s.name, t.name))));
237
+ const several = (refs) => ({ problem: `${refs.slice(0, -1).join(', ')} and ${refs.at(-1)} are ${refs.length === 2 ? 'both' : 'all'} called ${wanted}: ask for one by the name given here.` });
238
+ if (parts.length >= 2 && parts.length <= 3 && !parts.includes('')) {
239
+ const exact = findTables(model, wanted).map((f) => f.ref);
240
+ if (exact.length === 1)
241
+ return { ref: exact[0] };
242
+ if (exact.length > 1)
243
+ return several(exact);
244
+ }
245
+ const all = [...new Set(model.databases.flatMap((d) => d.schemas.flatMap((s) => s.tables.map((t) => tableRef(s.name, t.name)))))];
237
246
  const lower = wanted.toLowerCase();
238
- const matches = [...new Set(all)].filter((ref) => ref.toLowerCase() === lower || (!lower.includes('.') && ref.slice(ref.indexOf('.') + 1).toLowerCase() === lower));
247
+ // The whole name, the name an agent writes when it does not know there are several databases, the table's.
248
+ const forms = (ref) => {
249
+ const local = localRef(model, ref);
250
+ return [ref, local, local.slice(local.indexOf('.') + 1)];
251
+ };
252
+ const matches = all.filter((ref) => forms(ref).some((form) => form.toLowerCase() === lower));
239
253
  if (matches.length === 1)
240
254
  return { ref: matches[0] };
241
255
  if (matches.length > 1)
242
- return { problem: `${matches.slice(0, -1).join(', ')} and ${matches.at(-1)} are ${matches.length === 2 ? 'both' : 'all'} called ${wanted}: ask for one as schema.table.` };
256
+ return several(matches);
243
257
  const name = wanted.slice(wanted.lastIndexOf('.') + 1);
244
258
  const near = [...new Set(hitsFor(model, name, searchDefault).filter((h) => h.kind === 'table' || h.kind === 'view').map((h) => h.ref))].slice(0, 5);
245
259
  return { problem: `"${written}" is not a table. ${near.length > 0 ? `Did you mean ${near.join(', ')}?` : 'overview lists them, and search finds one by any part of its name.'}` };
@@ -258,9 +272,11 @@ function quote(driver, name) {
258
272
  return `\`${name.replace(/`/g, '``')}\``;
259
273
  return `"${name.replace(/"/g, '""')}"`;
260
274
  }
261
- function quoteRef(driver, ref, column) {
262
- const dot = ref.indexOf('.');
263
- const names = [ref.slice(0, dot), ref.slice(dot + 1), ...(column === undefined ? [] : [column])];
275
+ /** schema.table as its database names it, quoted: the SQL runs in the database the path was found in. */
276
+ function quoteRef(model, driver, ref, column) {
277
+ const local = localRef(model, ref);
278
+ const dot = local.indexOf('.');
279
+ const names = [local.slice(0, dot), local.slice(dot + 1), ...(column === undefined ? [] : [column])];
264
280
  return names.map((name) => quote(driver, name)).join('.');
265
281
  }
266
282
  function pathText(model, from, to) {
@@ -283,13 +299,13 @@ function pathText(model, from, to) {
283
299
  return `No relation connects ${from} and ${to}, declared or in the notes. If they are related all the same, add the relation to dbtour.notes.yaml so the docs and this path know it.`;
284
300
  }
285
301
  const { driver } = best.database;
286
- const lines = [`FROM ${quoteRef(driver, from)}`];
302
+ const lines = [`FROM ${quoteRef(model, driver, from)}`];
287
303
  let at = from;
288
304
  for (const step of best.steps) {
289
305
  const mine = step.forward ? step.relation.to.columns : step.relation.from.columns;
290
306
  const theirs = step.forward ? step.relation.from.columns : step.relation.to.columns;
291
- const on = mine.map((column, i) => `${quoteRef(driver, step.table, column)} = ${quoteRef(driver, at, theirs[i] ?? theirs[0])}`).join(' AND ');
292
- lines.push(`JOIN ${quoteRef(driver, step.table)} ON ${on}${step.relation.virtual ? ' -- from the notes, not a declared key' : ''}`);
307
+ const on = mine.map((column, i) => `${quoteRef(model, driver, step.table, column)} = ${quoteRef(model, driver, at, theirs[i] ?? theirs[0])}`).join(' AND ');
308
+ lines.push(`JOIN ${quoteRef(model, driver, step.table)} ON ${on}${step.relation.virtual ? ' -- from the notes, not a declared key' : ''}`);
293
309
  at = step.table;
294
310
  }
295
311
  const where = model.databases.length > 1 ? `In ${best.database.name}, ` : '';
package/dist/model.d.ts CHANGED
@@ -201,11 +201,35 @@ export interface TableRef {
201
201
  }
202
202
  export declare function tableRef(schema: string, table: string): string;
203
203
  export declare function parseRef(ref: string): TableRef;
204
+ /** Between a database and its schema in a ref: not a dot, which already parts schema, table and column. */
205
+ export declare const databaseSeparator = ":";
206
+ /**
207
+ * The model as everything reads it. Refs carry no database, so two databases with a dbo.Customers would
208
+ * share one name; with several databases, each schema is named `Database:schema` and every relation, group
209
+ * and link, and every history table, names its tables that way. Whatever builds a ref from a schema and a table then builds a name
210
+ * that is unique, and one database keeps the names it has always had. A group or link naming a table in
211
+ * both databases is left for the notes to report. It runs once, as the model is read: a schema a source
212
+ * itself named Sales:dbo becomes Sales:Sales:dbo, apart from its dbo, so nothing is taken as scoped already.
213
+ */
214
+ export declare function scope(model: Model): Model;
215
+ /**
216
+ * A ref as its own database names it, for SQL that runs there: Sales:dbo.Orders is dbo.Orders. Only a
217
+ * scoped model's database is taken off, so with one database a colon in a name stays part of it.
218
+ */
219
+ export declare function localRef(model: Model, ref: string): string;
204
220
  export interface Found {
205
221
  database: Database;
206
222
  schema: Schema;
207
223
  table: Table;
224
+ /** The table's name as the model writes it, with its database when the model is scoped. */
225
+ ref: string;
208
226
  }
227
+ /**
228
+ * Every table a ref can mean. One written with its database, or in a model of one database, means at most
229
+ * one; `dbo.Customers` in a scoped model means each database's dbo.Customers.
230
+ */
231
+ export declare function findTables(model: Model, ref: string): Found[];
232
+ /** The one table a ref means; nothing when it means none, or several. */
209
233
  export declare function findTable(model: Model, ref: string): Found | undefined;
210
234
  /** Unique on its own, by a constraint or an index, and not the primary key, which says so already. */
211
235
  export declare function isUniqueColumn(table: Table, column: string): boolean;
package/dist/model.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // The model: what every source writes and everything else reads. Kept to plain JSON so it can be committed,
2
2
  // diffed and handed to a page or an agent. Refs name tables as `schema.table` and columns as
3
- // `schema.table.column`.
3
+ // `schema.table.column`; read through scope(), a model with several databases names them
4
+ // `Database:schema.table`.
4
5
  export const MODEL_VERSION = 1;
5
6
  export function emptyModel() {
6
7
  return { dbtour: MODEL_VERSION, databases: [], apis: [], links: [], groups: [] };
@@ -16,18 +17,77 @@ export function parseRef(ref) {
16
17
  const [schema, table, column] = parts;
17
18
  return column === undefined ? { schema, table } : { schema, table, column };
18
19
  }
19
- export function findTable(model, ref) {
20
- const { schema: schemaName, table: tableName } = parseRef(ref);
21
- for (const database of model.databases) {
20
+ /** Between a database and its schema in a ref: not a dot, which already parts schema, table and column. */
21
+ export const databaseSeparator = ':';
22
+ /**
23
+ * The model as everything reads it. Refs carry no database, so two databases with a dbo.Customers would
24
+ * share one name; with several databases, each schema is named `Database:schema` and every relation, group
25
+ * and link, and every history table, names its tables that way. Whatever builds a ref from a schema and a table then builds a name
26
+ * that is unique, and one database keeps the names it has always had. A group or link naming a table in
27
+ * both databases is left for the notes to report. It runs once, as the model is read: a schema a source
28
+ * itself named Sales:dbo becomes Sales:Sales:dbo, apart from its dbo, so nothing is taken as scoped already.
29
+ */
30
+ export function scope(model) {
31
+ if (model.databases.length < 2)
32
+ return model;
33
+ const scoped = structuredClone(model);
34
+ for (const database of scoped.databases) {
35
+ const prefix = database.name + databaseSeparator;
22
36
  for (const schema of database.schemas) {
23
- if (schema.name !== schemaName)
24
- continue;
25
- const table = schema.tables.find((t) => t.name === tableName);
26
- if (table)
27
- return { database, schema, table };
37
+ schema.name = prefix + schema.name;
38
+ for (const table of schema.tables) {
39
+ if (table.temporal)
40
+ table.temporal.history = prefix + table.temporal.history;
41
+ if (table.historyOf)
42
+ table.historyOf = prefix + table.historyOf;
43
+ }
28
44
  }
45
+ for (const relation of database.relations)
46
+ for (const end of [relation.from, relation.to])
47
+ end.table = prefix + end.table;
29
48
  }
30
- return undefined;
49
+ const qualify = (ref) => findTable(scoped, ref)?.ref ?? ref;
50
+ for (const group of scoped.groups)
51
+ group.tables = group.tables.map(qualify);
52
+ for (const link of scoped.links)
53
+ link.tables = link.tables.map(qualify);
54
+ return scoped;
55
+ }
56
+ /**
57
+ * A ref as its own database names it, for SQL that runs there: Sales:dbo.Orders is dbo.Orders. Only a
58
+ * scoped model's database is taken off, so with one database a colon in a name stays part of it.
59
+ */
60
+ export function localRef(model, ref) {
61
+ if (model.databases.length < 2)
62
+ return ref;
63
+ const database = model.databases.find((d) => ref.startsWith(d.name + databaseSeparator));
64
+ return database ? ref.slice(database.name.length + databaseSeparator.length) : ref;
65
+ }
66
+ /**
67
+ * Every table a ref can mean. One written with its database, or in a model of one database, means at most
68
+ * one; `dbo.Customers` in a scoped model means each database's dbo.Customers.
69
+ */
70
+ export function findTables(model, ref) {
71
+ const { schema: schemaName, table: tableName } = parseRef(ref);
72
+ const found = [];
73
+ for (const exact of [true, false]) {
74
+ for (const database of model.databases) {
75
+ for (const schema of database.schemas) {
76
+ const named = exact ? schema.name === schemaName : schema.name === database.name + databaseSeparator + schemaName;
77
+ const table = named ? schema.tables.find((t) => t.name === tableName) : undefined;
78
+ if (table)
79
+ found.push({ database, schema, table, ref: tableRef(schema.name, table.name) });
80
+ }
81
+ }
82
+ if (found.length > 0)
83
+ break;
84
+ }
85
+ return found;
86
+ }
87
+ /** The one table a ref means; nothing when it means none, or several. */
88
+ export function findTable(model, ref) {
89
+ const found = findTables(model, ref);
90
+ return found.length === 1 ? found[0] : undefined;
31
91
  }
32
92
  /** Unique on its own, by a constraint or an index, and not the primary key, which says so already. */
33
93
  export function isUniqueColumn(table, column) {
@@ -94,6 +154,18 @@ export function validate(value) {
94
154
  if (errors.length > 0)
95
155
  return { ok: false, errors };
96
156
  const typed = value;
157
+ // Several databases are told apart by name in their refs (see scope), so each needs one of its own that a
158
+ // ref can carry.
159
+ if (typed.databases.length > 1) {
160
+ const seen = new Set();
161
+ for (const { name } of typed.databases) {
162
+ if (seen.has(name))
163
+ errors.push(`two databases are named ${name}: give each source its own name in dbtour.yaml`);
164
+ seen.add(name);
165
+ if (/[.:/\\]/.test(name))
166
+ errors.push(`the database name ${name} has a dot, a colon or a slash, which a ref or a file name would read as a separator: give its source another name in dbtour.yaml`);
167
+ }
168
+ }
97
169
  typed.databases.forEach((database, i) => {
98
170
  const refs = new Set(database.schemas.flatMap((s) => s.tables.map((t) => tableRef(s.name, t.name))));
99
171
  database.relations.forEach((relation, j) => {
package/dist/notes.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // The notes file: what people add that the database cannot say. Merged into the model at render time and
2
2
  // winning over it, so a production database nobody may alter still gets documented.
3
3
  import { parse } from 'yaml';
4
- import { findTable, parseRef, tableRef } from "./model.js";
4
+ import { findTables, parseRef } from "./model.js";
5
5
  export function parseNotes(text) {
6
6
  const raw = parse(text) ?? {};
7
7
  if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
@@ -64,12 +64,21 @@ export function parseNotes(text) {
64
64
  export function applyNotes(input, notes) {
65
65
  const model = structuredClone(input);
66
66
  const warnings = [];
67
+ // The one table a name in the notes means, or a warning: a name two databases share wants its database.
68
+ const find = (ref, what) => {
69
+ const found = findTables(model, ref);
70
+ if (found.length === 1)
71
+ return found[0];
72
+ if (found.length === 0)
73
+ warnings.push(`notes: ${what}`);
74
+ else
75
+ warnings.push(`notes: ${parseRef(ref).schema}.${parseRef(ref).table} is in ${found.map((f) => f.database.name).join(' and ')}: write ${found.map((f) => f.ref).join(' or ')}`);
76
+ return undefined;
77
+ };
67
78
  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`);
79
+ const found = find(ref, `${ref} is not a table in the model`);
80
+ if (!found)
71
81
  continue;
72
- }
73
82
  if (tableNotes.description !== undefined)
74
83
  found.table.description = tableNotes.description;
75
84
  for (const [name, description] of Object.entries(tableNotes.columns ?? {})) {
@@ -82,30 +91,26 @@ export function applyNotes(input, notes) {
82
91
  }
83
92
  }
84
93
  for (const note of notes.relations) {
85
- const from = parseRef(note.from);
86
- const to = parseRef(note.to);
94
+ const missing = `relation ${note.from} → ${note.to} names a table that is not in the model`;
95
+ const fromTable = find(note.from, missing);
96
+ const toTable = fromTable && find(note.to, missing);
97
+ if (!fromTable || !toTable)
98
+ continue;
99
+ if (fromTable.database !== toTable.database) {
100
+ warnings.push(`notes: relation ${note.from} → ${note.to} joins ${fromTable.database.name} to ${toTable.database.name}: relations stay inside one database`);
101
+ continue;
102
+ }
87
103
  const relation = {
88
- from: { table: tableRef(from.schema, from.table), columns: [from.column] },
89
- to: { table: tableRef(to.schema, to.table), columns: [to.column] },
104
+ from: { table: fromTable.ref, columns: [parseRef(note.from).column] },
105
+ to: { table: toTable.ref, columns: [parseRef(note.to).column] },
90
106
  virtual: true,
91
107
  };
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
108
  const exists = fromTable.database.relations.some((r) => sameEnd(r.from, relation.from) && sameEnd(r.to, relation.to));
99
109
  if (!exists)
100
110
  fromTable.database.relations.push(relation);
101
111
  }
102
112
  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
- });
113
+ const tables = group.tables.flatMap((ref) => find(ref, `group ${group.name} lists ${ref}, which is not a table in the model`)?.ref ?? []);
109
114
  model.groups.push({ ...group, tables });
110
115
  }
111
116
  return { model, warnings };
@@ -18,6 +18,8 @@ export declare function formatCount(n: number): string;
18
18
  export declare function formatBytes(bytes: number): string;
19
19
  /** The sentence under a table's title: the count is the database's estimate, so it says about, and when. */
20
20
  export declare function statsLine(stats: TableStats, readAt?: string): string;
21
+ /** A table's page under tables/: Sales:dbo.Orders is in a folder for its database, dbo.Orders is not. */
22
+ export declare function tableFile(ref: string): string;
21
23
  /** One table's page on its own: what the page hands to an agent. */
22
24
  export declare function renderTableMarkdown(model: Model, ref: string): string;
23
25
  export { findTable };
@@ -1,6 +1,6 @@
1
1
  // Markdown GitHub shows: an index with every table, and one page per table with its columns, keys,
2
2
  // relations both ways, indexes, constraints and the diagram of its neighbourhood.
3
- import { findTable, isUniqueColumn, relationsOf, tableRef } from "../model.js";
3
+ import { databaseSeparator, findTable, isUniqueColumn, relationsOf, tableRef } from "../model.js";
4
4
  import { erDiagram, neighbourhood } from "./mermaid.js";
5
5
  // Mermaid lays out a whole database only while it is small; above this many tables the index shows the
6
6
  // groups and each table page shows its own neighbourhood instead.
@@ -11,7 +11,7 @@ export function renderMarkdown(model) {
11
11
  for (const schema of database.schemas) {
12
12
  for (const table of schema.tables) {
13
13
  const ref = tableRef(schema.name, table.name);
14
- files[`tables/${ref}.md`] = renderTable(model, ref, table, database);
14
+ files[`tables/${tableFile(ref)}`] = renderTable(model, ref, table, database);
15
15
  }
16
16
  }
17
17
  }
@@ -36,7 +36,7 @@ function renderIndex(model) {
36
36
  for (const table of schema.tables) {
37
37
  const ref = tableRef(schema.name, table.name);
38
38
  const size = !sized ? '' : table.stats ? ` ${formatCount(table.stats.rows)} | ${formatBytes(table.stats.dataBytes + table.stats.indexBytes)} |` : ' | |';
39
- parts.push(`| [${ref}](tables/${ref}.md) | ${table.kind} | ${table.columns.length} |${size} ${cell(table.description)} |`);
39
+ parts.push(`| [${ref}](${href(`tables/${tableFile(ref)}`)}) | ${table.kind} | ${table.columns.length} |${size} ${cell(table.description)} |`);
40
40
  }
41
41
  }
42
42
  parts.push('');
@@ -49,7 +49,7 @@ function renderIndex(model) {
49
49
  parts.push(`## ${group.name}\n`);
50
50
  if (group.description)
51
51
  parts.push(`${group.description}\n`);
52
- parts.push(group.tables.map((ref) => `- [${ref}](tables/${ref}.md)`).join('\n') + '\n');
52
+ parts.push(group.tables.map((ref) => `- [${ref}](${href(`tables/${tableFile(ref)}`)})`).join('\n') + '\n');
53
53
  parts.push(mermaid(erDiagram(model, group.tables)));
54
54
  }
55
55
  const refs = model.databases.flatMap((d) => d.schemas.flatMap((s) => s.tables.map((t) => tableRef(s.name, t.name))));
@@ -166,12 +166,28 @@ export function statsLine(stats, readAt) {
166
166
  const when = readAt ? `, as the database counted them on ${readAt.slice(0, 10)}` : '';
167
167
  return `About ${formatCount(stats.rows)} rows · ${formatBytes(stats.dataBytes)} of data · ${formatBytes(stats.indexBytes)} of indexes${when}.`;
168
168
  }
169
+ /** A table's page under tables/: Sales:dbo.Orders is in a folder for its database, dbo.Orders is not. */
170
+ export function tableFile(ref) {
171
+ return ref.replace(databaseSeparator, '/') + '.md';
172
+ }
173
+ /** A link from one table's page to another's: the file beside it, or up and across to another folder. */
174
+ function pageLink(from, to) {
175
+ const folder = (ref) => (ref.includes(databaseSeparator) ? ref.slice(0, ref.indexOf(databaseSeparator)) : '');
176
+ const file = tableFile(to);
177
+ if (folder(from) === folder(to))
178
+ return href(file.slice(file.indexOf('/') + 1));
179
+ return href((folder(from) ? '../' : '') + file);
180
+ }
181
+ /** A path as a link's destination: each part encoded, so a space or a # in a name still reaches its file. */
182
+ function href(path) {
183
+ return path.split('/').map((part) => (part === '..' ? part : encodeURIComponent(part))).join('/');
184
+ }
169
185
  /** One table's page on its own: what the page hands to an agent. */
170
186
  export function renderTableMarkdown(model, ref) {
171
187
  const found = findTable(model, ref);
172
188
  if (!found)
173
189
  throw new Error(`${ref} is not a table in the model`);
174
- return renderTable(model, ref, found.table, found.database);
190
+ return renderTable(model, found.ref, found.table, found.database);
175
191
  }
176
192
  function renderTable(model, ref, table, database) {
177
193
  const parts = [`# ${ref}\n`];
@@ -183,7 +199,7 @@ function renderTable(model, ref, table, database) {
183
199
  if (listed.length > 0) {
184
200
  parts.push('## Features\n');
185
201
  // A table the feature names gets a link when it is in the model, as a relation's would.
186
- const linked = (f) => (f.ref && f.detail && findTable(model, f.ref) ? f.detail.replace(f.ref, `[${f.ref}](${f.ref}.md)`) : f.detail);
202
+ const linked = (f) => (f.ref && f.detail && findTable(model, f.ref) ? f.detail.replace(f.ref, `[${f.ref}](${pageLink(ref, f.ref)})`) : f.detail);
187
203
  parts.push(listed.map((f) => `- **${f.name}**${f.detail ? `: ${linked(f)}` : ''}`).join('\n') + '\n');
188
204
  }
189
205
  const relations = relationsOf(model, ref);
@@ -198,7 +214,7 @@ function renderTable(model, ref, table, database) {
198
214
  continue;
199
215
  const { table: toTable, columns } = relation.to;
200
216
  const state = [relation.virtual ? 'notes' : '', ...enforcement(relation)].filter(Boolean);
201
- keys.push(`FK → [${toTable}](${toTable}.md).${columns[i] ?? columns.join(',')}${state.length ? ` (${state.join(', ')})` : ''}`);
217
+ keys.push(`FK → [${toTable}](${pageLink(ref, toTable)}).${columns[i] ?? columns.join(',')}${state.length ? ` (${state.join(', ')})` : ''}`);
202
218
  }
203
219
  if (isUniqueColumn(table, column.name))
204
220
  keys.push('unique');
@@ -210,7 +226,7 @@ function renderTable(model, ref, table, database) {
210
226
  if (relations.in.length > 0) {
211
227
  parts.push('## Referenced by\n');
212
228
  const lines = relations.in
213
- .map((r) => `- [${r.from.table}](${r.from.table}.md).${r.from.columns.join(', ')}${r.virtual ? ' (notes)' : ''}`)
229
+ .map((r) => `- [${r.from.table}](${pageLink(ref, r.from.table)}).${r.from.columns.join(', ')}${r.virtual ? ' (notes)' : ''}`)
214
230
  .sort();
215
231
  parts.push(lines.join('\n') + '\n');
216
232
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dbtour",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
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",