dbtour 0.3.0 → 0.5.0
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.d.ts +4 -0
- package/dist/cli.js +52 -14
- package/dist/mcp.d.ts +33 -0
- package/dist/mcp.js +320 -0
- package/dist/model.d.ts +89 -0
- package/dist/model.js +84 -0
- package/dist/render/markdown.d.ts +10 -1
- package/dist/render/markdown.js +124 -10
- package/dist/search.d.ts +18 -0
- package/dist/search.js +64 -0
- package/package.json +5 -1
package/dist/cli.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Readable, Writable } from 'node:stream';
|
|
1
2
|
import { type AddonConfig, type SourceConfig } from './config.ts';
|
|
2
3
|
import { type Addon, type Source } from './plugins.ts';
|
|
3
4
|
export interface Io {
|
|
@@ -9,5 +10,8 @@ export interface Io {
|
|
|
9
10
|
load?: (source: SourceConfig) => Promise<Source>;
|
|
10
11
|
/** How an add-on is loaded; tests hand in their own. */
|
|
11
12
|
loadAddon?: (addon: AddonConfig) => Promise<Addon>;
|
|
13
|
+
/** Where mcp reads requests and writes answers; process.stdin and process.stdout unless a test hands in its own. */
|
|
14
|
+
stdin?: Readable;
|
|
15
|
+
stdout?: Writable;
|
|
12
16
|
}
|
|
13
17
|
export declare function main(argv: string[], io?: Io): Promise<number>;
|
package/dist/cli.js
CHANGED
|
@@ -8,13 +8,15 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
8
8
|
};
|
|
9
9
|
// The command. Reads dbtour.yaml in the working directory, writes under its `out`. Exit 1 for what was
|
|
10
10
|
// asked and failed, 2 for what is missing, 0 otherwise.
|
|
11
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { createInterface } from 'node:readline';
|
|
12
13
|
import { dirname, join, resolve } from 'node:path';
|
|
13
14
|
import { pathToFileURL } from 'node:url';
|
|
14
15
|
import { parseArgs } from 'node:util';
|
|
15
16
|
import { configFile, defaultConfigText, modelFile, parseConfig, resolveConnection } from "./config.js";
|
|
16
17
|
import { emptyModel, validate } from "./model.js";
|
|
17
18
|
import { applyNotes, parseNotes } from "./notes.js";
|
|
19
|
+
import { answerLine } from "./mcp.js";
|
|
18
20
|
import { loadAddon, loadSource } from "./plugins.js";
|
|
19
21
|
import { renderMarkdown } from "./render/markdown.js";
|
|
20
22
|
const help = `Usage: dbtour <command>
|
|
@@ -23,6 +25,8 @@ const help = `Usage: dbtour <command>
|
|
|
23
25
|
extract read every source in ${configFile}, write <out>/${modelFile}
|
|
24
26
|
render read <out>/${modelFile} and the notes, write the Markdown and what the
|
|
25
27
|
add-ons in ${configFile} render (explorer: index.html) under <out>
|
|
28
|
+
mcp [project] answer an agent's questions about <out>/${modelFile} and the notes over MCP,
|
|
29
|
+
on stdin and stdout: claude mcp add dbtour -- npx dbtour mcp
|
|
26
30
|
|
|
27
31
|
Set the connection string in the environment variable ${configFile} names, never in the file.`;
|
|
28
32
|
export async function main(argv, io = { cwd: process.cwd(), log: console.log, error: console.error }) {
|
|
@@ -40,6 +44,8 @@ export async function main(argv, io = { cwd: process.cwd(), log: console.log, er
|
|
|
40
44
|
return await extract(io);
|
|
41
45
|
case 'render':
|
|
42
46
|
return await render(io);
|
|
47
|
+
case 'mcp':
|
|
48
|
+
return await mcp(positionals[1] === undefined ? io : { ...io, cwd: resolve(io.cwd, positionals[1]) });
|
|
43
49
|
default:
|
|
44
50
|
io.error(`"${command}" is not a dbtour command.\n\n${help}`);
|
|
45
51
|
return 1;
|
|
@@ -89,6 +95,9 @@ async function extract(io) {
|
|
|
89
95
|
model.databases.push(result);
|
|
90
96
|
const tables = result.schemas.reduce((n, s) => n + s.tables.length, 0);
|
|
91
97
|
io.log(`${result.name}: ${tables} tables, ${result.relations.length} relations`);
|
|
98
|
+
// Not a failure: the rest was read, and an absence the model records is not taken for a no.
|
|
99
|
+
for (const reason of result.unread ?? [])
|
|
100
|
+
io.log(`${result.name}: not read: ${reason}. Grant it to read them, or leave them out.`);
|
|
92
101
|
}
|
|
93
102
|
else {
|
|
94
103
|
model.apis.push(result);
|
|
@@ -110,24 +119,25 @@ async function extract(io) {
|
|
|
110
119
|
function importer(cwd) {
|
|
111
120
|
return (specifier) => import(__rewriteRelativeImportExtension(specifier.startsWith('.') || specifier.startsWith('/') ? pathToFileURL(resolve(cwd, specifier)).href : specifier));
|
|
112
121
|
}
|
|
113
|
-
|
|
114
|
-
|
|
122
|
+
/** <out>/schema.json with the notes merged in, as render and mcp read it. */
|
|
123
|
+
function readModel(io, config) {
|
|
115
124
|
const modelPath = join(io.cwd, config.out, modelFile);
|
|
116
125
|
if (!existsSync(modelPath))
|
|
117
126
|
throw missing(`No ${join(config.out, modelFile)} yet. Run dbtour extract to write it.`);
|
|
118
127
|
const checked = validate(JSON.parse(readFileSync(modelPath, 'utf8')));
|
|
119
|
-
if (!checked.ok)
|
|
120
|
-
|
|
121
|
-
return 1;
|
|
122
|
-
}
|
|
123
|
-
let model = checked.model;
|
|
128
|
+
if (!checked.ok)
|
|
129
|
+
throw new Error(`${join(config.out, modelFile)} is not a model:\n ${checked.errors.join('\n ')}`);
|
|
124
130
|
const notesPath = join(io.cwd, config.notes);
|
|
125
|
-
if (existsSync(notesPath))
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
+
if (!existsSync(notesPath))
|
|
132
|
+
return checked.model;
|
|
133
|
+
const applied = applyNotes(checked.model, parseNotes(readFileSync(notesPath, 'utf8')));
|
|
134
|
+
for (const warning of applied.warnings)
|
|
135
|
+
io.error(warning);
|
|
136
|
+
return applied.model;
|
|
137
|
+
}
|
|
138
|
+
async function render(io) {
|
|
139
|
+
const config = readConfig(io);
|
|
140
|
+
let model = readModel(io, config);
|
|
131
141
|
// Every add-on is loaded before anything is written, so a missing one stops the run with nothing half done.
|
|
132
142
|
const load = io.loadAddon ?? ((addon) => loadAddon(addon, importer(io.cwd)));
|
|
133
143
|
const addons = [];
|
|
@@ -154,3 +164,31 @@ async function render(io) {
|
|
|
154
164
|
io.log(`Wrote ${Object.keys(files).length} files to ${config.out}.`);
|
|
155
165
|
return 0;
|
|
156
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Serves the model to an agent until stdin closes. Read once now, so a missing model stops it with the
|
|
169
|
+
* command to run, then again whenever schema.json or the notes change, so a render while the agent is
|
|
170
|
+
* connected is seen at its next question. Everything but answers goes to stderr: stdout is the protocol.
|
|
171
|
+
*/
|
|
172
|
+
async function mcp(io) {
|
|
173
|
+
const config = readConfig(io);
|
|
174
|
+
const files = [join(io.cwd, config.out, modelFile), join(io.cwd, config.notes)];
|
|
175
|
+
const stamp = () => files.map((f) => (existsSync(f) ? statSync(f).mtimeMs : 0)).join();
|
|
176
|
+
let model = readModel(io, config);
|
|
177
|
+
let read = stamp();
|
|
178
|
+
const current = () => {
|
|
179
|
+
const now = stamp();
|
|
180
|
+
if (now !== read) {
|
|
181
|
+
model = readModel(io, config);
|
|
182
|
+
read = now;
|
|
183
|
+
}
|
|
184
|
+
return model;
|
|
185
|
+
};
|
|
186
|
+
const version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
187
|
+
const output = io.stdout ?? process.stdout;
|
|
188
|
+
for await (const line of createInterface({ input: io.stdin ?? process.stdin, crlfDelay: Infinity })) {
|
|
189
|
+
const reply = answerLine(line, { model: current, version });
|
|
190
|
+
if (reply !== undefined)
|
|
191
|
+
output.write(reply + '\n');
|
|
192
|
+
}
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type Model } from './model.ts';
|
|
2
|
+
export declare const protocolVersions: string[];
|
|
3
|
+
export declare const overviewLimit = 100;
|
|
4
|
+
export interface Context {
|
|
5
|
+
/** The model with its notes, read again when the files change; throws when there is none. */
|
|
6
|
+
model: () => Model;
|
|
7
|
+
version: string;
|
|
8
|
+
}
|
|
9
|
+
export interface ToolResult {
|
|
10
|
+
content: {
|
|
11
|
+
type: 'text';
|
|
12
|
+
text: string;
|
|
13
|
+
}[];
|
|
14
|
+
isError?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare const toolList: {
|
|
17
|
+
name: string;
|
|
18
|
+
title: string;
|
|
19
|
+
description: string;
|
|
20
|
+
inputSchema: {
|
|
21
|
+
type: string;
|
|
22
|
+
properties: Record<string, unknown>;
|
|
23
|
+
required: string[];
|
|
24
|
+
};
|
|
25
|
+
annotations: {
|
|
26
|
+
readOnlyHint: boolean;
|
|
27
|
+
openWorldHint: boolean;
|
|
28
|
+
};
|
|
29
|
+
}[];
|
|
30
|
+
/** One line in, at most one line out: a notification and a blank line get none. */
|
|
31
|
+
export declare function answerLine(line: string, context: Context): string | undefined;
|
|
32
|
+
export declare function answer(message: unknown, context: Context): object | undefined;
|
|
33
|
+
export declare function callTool(model: Model, name: string, args: Record<string, unknown>): ToolResult;
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// The model over the Model Context Protocol, so an agent such as Claude asks about the database (find a
|
|
2
|
+
// table, read it, join two) instead of being handed all of it. JSON-RPC, one message per line; written here
|
|
3
|
+
// rather than taken from the SDK, since four methods are all it needs and core keeps its one dependency.
|
|
4
|
+
// Read-only and structure only, like the model: there are no rows to give.
|
|
5
|
+
import { findTable, tableRef } from "./model.js";
|
|
6
|
+
import { driverName, formatCount, renderTableMarkdown } from "./render/markdown.js";
|
|
7
|
+
import { buildIndex, search } from "./search.js";
|
|
8
|
+
// The versions this server speaks, newest first. It uses only initialize, ping, tools/list and tools/call,
|
|
9
|
+
// which none of them changed, so it agrees to whichever the client asks for and offers the newest otherwise.
|
|
10
|
+
export const protocolVersions = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
|
|
11
|
+
// Above this many tables the overview lists the largest and leaves the rest to search: a list of thousands
|
|
12
|
+
// would spend the agent's context on tables the question is not about.
|
|
13
|
+
export const overviewLimit = 100;
|
|
14
|
+
// Enough hits to choose from without reading past the good ones; the agent can ask for up to the maximum.
|
|
15
|
+
const searchDefault = 20;
|
|
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.`;
|
|
18
|
+
const readOnly = { readOnlyHint: true, openWorldHint: false };
|
|
19
|
+
export const toolList = [
|
|
20
|
+
{
|
|
21
|
+
name: 'overview',
|
|
22
|
+
title: 'Overview',
|
|
23
|
+
description: `The databases, how many tables and relations each has, and their tables largest first by rows (all of them up to ${overviewLimit}), with the groups people defined. Call it first, to know what there is.`,
|
|
24
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
25
|
+
annotations: readOnly,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'search',
|
|
29
|
+
title: 'Search',
|
|
30
|
+
description: 'Find tables, views, columns and groups by any part of their name, type or description. Every word must match. Use it to find where something is kept before reading the table.',
|
|
31
|
+
inputSchema: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: {
|
|
34
|
+
query: { type: 'string', description: 'Words to look for, like "customer email".' },
|
|
35
|
+
limit: { type: 'integer', minimum: 1, maximum: searchMaximum, default: searchDefault },
|
|
36
|
+
},
|
|
37
|
+
required: ['query'],
|
|
38
|
+
},
|
|
39
|
+
annotations: readOnly,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'table',
|
|
43
|
+
title: 'Table',
|
|
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
|
+
inputSchema: {
|
|
46
|
+
type: 'object',
|
|
47
|
+
properties: { ref: { type: 'string', description: 'schema.table, like dbo.Orders. A bare name works when only one schema has it.' } },
|
|
48
|
+
required: ['ref'],
|
|
49
|
+
},
|
|
50
|
+
annotations: readOnly,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'path',
|
|
54
|
+
title: 'Join path',
|
|
55
|
+
description: 'The shortest chain of relations between two tables, written as the JOINs that follow them, through any tables between. Use it to join tables that are not related directly.',
|
|
56
|
+
inputSchema: {
|
|
57
|
+
type: 'object',
|
|
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.' },
|
|
61
|
+
},
|
|
62
|
+
required: ['from', 'to'],
|
|
63
|
+
},
|
|
64
|
+
annotations: readOnly,
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
const toolNames = toolList.map((t) => t.name);
|
|
68
|
+
/** One line in, at most one line out: a notification and a blank line get none. */
|
|
69
|
+
export function answerLine(line, context) {
|
|
70
|
+
if (line.trim() === '')
|
|
71
|
+
return undefined;
|
|
72
|
+
let message;
|
|
73
|
+
try {
|
|
74
|
+
message = JSON.parse(line);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return JSON.stringify(failure(null, -32700, 'That line is not JSON: send one JSON-RPC message per line.'));
|
|
78
|
+
}
|
|
79
|
+
const response = answer(message, context);
|
|
80
|
+
return response === undefined ? undefined : JSON.stringify(response);
|
|
81
|
+
}
|
|
82
|
+
export function answer(message, context) {
|
|
83
|
+
if (typeof message !== 'object' || message === null || Array.isArray(message))
|
|
84
|
+
return failure(null, -32600, 'Send one JSON-RPC request as an object; batches are not accepted.');
|
|
85
|
+
const request = message;
|
|
86
|
+
// An id that is neither a string nor a number cannot be echoed back, so its error carries null.
|
|
87
|
+
const validId = typeof request.id === 'string' || typeof request.id === 'number';
|
|
88
|
+
const id = validId ? request.id : null;
|
|
89
|
+
if (request.jsonrpc !== '2.0')
|
|
90
|
+
return failure(id, -32600, 'A request must say "jsonrpc": "2.0".');
|
|
91
|
+
if (typeof request.method !== 'string')
|
|
92
|
+
return failure(id, -32600, 'A request needs a method.');
|
|
93
|
+
// A notification wants no answer, whatever it says.
|
|
94
|
+
if (!('id' in request))
|
|
95
|
+
return undefined;
|
|
96
|
+
if (!validId)
|
|
97
|
+
return failure(null, -32600, 'A request id must be a string or a number.');
|
|
98
|
+
const params = typeof request.params === 'object' && request.params !== null ? request.params : {};
|
|
99
|
+
switch (request.method) {
|
|
100
|
+
case 'initialize': {
|
|
101
|
+
const asked = params.protocolVersion;
|
|
102
|
+
return success(id, {
|
|
103
|
+
protocolVersion: typeof asked === 'string' && protocolVersions.includes(asked) ? asked : protocolVersions[0],
|
|
104
|
+
capabilities: { tools: {} },
|
|
105
|
+
serverInfo: { name: 'dbtour', version: context.version },
|
|
106
|
+
instructions,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
case 'ping':
|
|
110
|
+
return success(id, {});
|
|
111
|
+
case 'tools/list':
|
|
112
|
+
return success(id, { tools: toolList });
|
|
113
|
+
case 'tools/call': {
|
|
114
|
+
const name = params.name;
|
|
115
|
+
if (typeof name !== 'string' || !toolNames.includes(name))
|
|
116
|
+
return failure(id, -32602, `There is no tool "${String(name)}": the tools are ${toolNames.join(', ')}.`);
|
|
117
|
+
const args = typeof params.arguments === 'object' && params.arguments !== null ? params.arguments : {};
|
|
118
|
+
let model;
|
|
119
|
+
try {
|
|
120
|
+
model = context.model();
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
return success(id, problem(error instanceof Error ? error.message : String(error)));
|
|
124
|
+
}
|
|
125
|
+
return success(id, callTool(model, name, args));
|
|
126
|
+
}
|
|
127
|
+
default:
|
|
128
|
+
return failure(id, -32601, `dbtour does not answer ${request.method}: it offers tools only.`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function success(id, result) {
|
|
132
|
+
return { jsonrpc: '2.0', id, result };
|
|
133
|
+
}
|
|
134
|
+
function failure(id, code, message) {
|
|
135
|
+
return { jsonrpc: '2.0', id, error: { code, message } };
|
|
136
|
+
}
|
|
137
|
+
function text(value) {
|
|
138
|
+
return { content: [{ type: 'text', text: value }] };
|
|
139
|
+
}
|
|
140
|
+
/** A failed call goes back to the agent as text it can act on, not as a protocol error. */
|
|
141
|
+
function problem(value) {
|
|
142
|
+
return { content: [{ type: 'text', text: value }], isError: true };
|
|
143
|
+
}
|
|
144
|
+
export function callTool(model, name, args) {
|
|
145
|
+
switch (name) {
|
|
146
|
+
case 'overview':
|
|
147
|
+
return text(overview(model));
|
|
148
|
+
case 'search': {
|
|
149
|
+
if (typeof args.query !== 'string' || args.query.trim() === '')
|
|
150
|
+
return problem('search needs a query: the words to look for, like "customer email".');
|
|
151
|
+
const limit = typeof args.limit === 'number' ? Math.min(Math.max(1, Math.floor(args.limit)), searchMaximum) : searchDefault;
|
|
152
|
+
return text(searchText(model, args.query, limit));
|
|
153
|
+
}
|
|
154
|
+
case 'table': {
|
|
155
|
+
if (typeof args.ref !== 'string')
|
|
156
|
+
return problem('table needs a ref: schema.table, like dbo.Orders.');
|
|
157
|
+
const found = resolve(model, args.ref);
|
|
158
|
+
return 'problem' in found ? problem(found.problem) : text(renderTableMarkdown(model, found.ref));
|
|
159
|
+
}
|
|
160
|
+
case 'path': {
|
|
161
|
+
if (typeof args.from !== 'string' || typeof args.to !== 'string')
|
|
162
|
+
return problem('path needs from and to: two tables as schema.table.');
|
|
163
|
+
const from = resolve(model, args.from);
|
|
164
|
+
if ('problem' in from)
|
|
165
|
+
return problem(from.problem);
|
|
166
|
+
const to = resolve(model, args.to);
|
|
167
|
+
if ('problem' in to)
|
|
168
|
+
return problem(to.problem);
|
|
169
|
+
return text(pathText(model, from.ref, to.ref));
|
|
170
|
+
}
|
|
171
|
+
default:
|
|
172
|
+
return problem(`There is no tool "${name}": the tools are ${toolNames.join(', ')}.`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function plural(n, one, many) {
|
|
176
|
+
return `${formatCount(n)} ${n === 1 ? one : many}`;
|
|
177
|
+
}
|
|
178
|
+
function overview(model) {
|
|
179
|
+
const parts = [];
|
|
180
|
+
for (const database of model.databases)
|
|
181
|
+
parts.push(overviewOf(database));
|
|
182
|
+
for (const group of model.groups) {
|
|
183
|
+
parts.push(`## ${group.name}\n`);
|
|
184
|
+
if (group.description)
|
|
185
|
+
parts.push(`${group.description}\n`);
|
|
186
|
+
parts.push(`${group.tables.join(', ')}\n`);
|
|
187
|
+
}
|
|
188
|
+
parts.push('Read a table with table, find one with search, join two with path.');
|
|
189
|
+
return parts.join('\n');
|
|
190
|
+
}
|
|
191
|
+
function overviewOf(database) {
|
|
192
|
+
const entries = database.schemas.flatMap((s) => s.tables.map((table) => ({ ref: tableRef(s.name, table.name), table })));
|
|
193
|
+
const sized = entries.some((e) => e.table.stats);
|
|
194
|
+
// Largest first, so the tables everything hangs off come before the lookups; a view or a table the source
|
|
195
|
+
// could not count keeps its catalogue place after them. Array.sort is stable, which keeps that order.
|
|
196
|
+
const ordered = sized ? [...entries].sort((a, b) => (b.table.stats?.rows ?? -1) - (a.table.stats?.rows ?? -1)) : entries;
|
|
197
|
+
const parts = [`# ${database.name}\n`];
|
|
198
|
+
if (database.description)
|
|
199
|
+
parts.push(`${database.description}\n`);
|
|
200
|
+
const driver = database.version ? `${driverName(database.driver)} ${database.version}` : driverName(database.driver);
|
|
201
|
+
parts.push(`${driver}. ${plural(entries.length, 'table or view', 'tables and views')} in ${plural(database.schemas.length, 'schema', 'schemas')}, ${plural(database.relations.length, 'relation', 'relations')}.\n`);
|
|
202
|
+
if (sized)
|
|
203
|
+
parts.push(`Largest first, by the rows the database counted${database.statsReadAt ? ` on ${database.statsReadAt.slice(0, 10)}` : ''}; its own estimate.\n`);
|
|
204
|
+
parts.push(sized ? '| Table | Kind | Columns | Rows | Description |\n|---|---|---|---|---|' : '| Table | Kind | Columns | Description |\n|---|---|---|---|');
|
|
205
|
+
for (const { ref, table } of ordered.slice(0, overviewLimit)) {
|
|
206
|
+
const rows = !sized ? '' : ` ${table.stats ? formatCount(table.stats.rows) : ''} |`;
|
|
207
|
+
parts.push(`| ${ref} | ${table.kind} | ${table.columns.length} |${rows} ${(table.description ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ')} |`);
|
|
208
|
+
}
|
|
209
|
+
if (entries.length > overviewLimit)
|
|
210
|
+
parts.push(`\n${formatCount(entries.length - overviewLimit)} more: search finds them.`);
|
|
211
|
+
parts.push('');
|
|
212
|
+
return parts.join('\n');
|
|
213
|
+
}
|
|
214
|
+
const indexes = new WeakMap();
|
|
215
|
+
function hitsFor(model, query, limit) {
|
|
216
|
+
let index = indexes.get(model);
|
|
217
|
+
if (!index) {
|
|
218
|
+
index = buildIndex(model);
|
|
219
|
+
indexes.set(model, index);
|
|
220
|
+
}
|
|
221
|
+
return search(index, query, limit);
|
|
222
|
+
}
|
|
223
|
+
function searchText(model, query, limit) {
|
|
224
|
+
const hits = hitsFor(model, query, limit);
|
|
225
|
+
if (hits.length === 0)
|
|
226
|
+
return `Nothing matches "${query}". Every word must match: try fewer words, or part of a name.`;
|
|
227
|
+
return hits.map((h) => `- ${h.kind} ${h.title}: ${h.detail}`).join('\n');
|
|
228
|
+
}
|
|
229
|
+
/** What an agent wrote, as the table it means: exact, then ignoring case, then a bare name in one schema. */
|
|
230
|
+
function resolve(model, written) {
|
|
231
|
+
const parts = written.trim().split('.');
|
|
232
|
+
// schema.table.column is the table; a bare name is looked up by name alone.
|
|
233
|
+
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 lower = wanted.toLowerCase();
|
|
238
|
+
const matches = [...new Set(all)].filter((ref) => ref.toLowerCase() === lower || (!lower.includes('.') && ref.slice(ref.indexOf('.') + 1).toLowerCase() === lower));
|
|
239
|
+
if (matches.length === 1)
|
|
240
|
+
return { ref: matches[0] };
|
|
241
|
+
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.` };
|
|
243
|
+
const name = wanted.slice(wanted.lastIndexOf('.') + 1);
|
|
244
|
+
const near = [...new Set(hitsFor(model, name, searchDefault).filter((h) => h.kind === 'table' || h.kind === 'view').map((h) => h.ref))].slice(0, 5);
|
|
245
|
+
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.'}` };
|
|
246
|
+
}
|
|
247
|
+
/** The databases that have a table of this name: refs carry no database, so two can share one. */
|
|
248
|
+
function databasesWith(model, ref) {
|
|
249
|
+
return model.databases.filter((d) => d.schemas.some((s) => s.tables.some((t) => tableRef(s.name, t.name) === ref)));
|
|
250
|
+
}
|
|
251
|
+
// Each database quotes names its own way: brackets in SQL Server, backticks in MySQL, the standard double
|
|
252
|
+
// quotes elsewhere. Every name is quoted, since a reserved word (User, Order) or a space breaks a bare one,
|
|
253
|
+
// and the closing quote inside a name is doubled.
|
|
254
|
+
function quote(driver, name) {
|
|
255
|
+
if (driver === 'mssql')
|
|
256
|
+
return `[${name.replace(/]/g, ']]')}]`;
|
|
257
|
+
if (driver === 'mysql')
|
|
258
|
+
return `\`${name.replace(/`/g, '``')}\``;
|
|
259
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
260
|
+
}
|
|
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])];
|
|
264
|
+
return names.map((name) => quote(driver, name)).join('.');
|
|
265
|
+
}
|
|
266
|
+
function pathText(model, from, to) {
|
|
267
|
+
if (from === to)
|
|
268
|
+
return `${from} is the same table at both ends: no join is needed.`;
|
|
269
|
+
// Relations stay inside their database, so the search runs in each database that has both tables.
|
|
270
|
+
const toDatabases = databasesWith(model, to);
|
|
271
|
+
const shared = databasesWith(model, from).filter((d) => toDatabases.includes(d));
|
|
272
|
+
if (shared.length === 0) {
|
|
273
|
+
const names = (ref) => databasesWith(model, ref).map((d) => d.name).join(' and ');
|
|
274
|
+
return `${from} is in ${names(from)} and ${to} in ${names(to)}: relations do not cross databases, so no join connects them.`;
|
|
275
|
+
}
|
|
276
|
+
let best;
|
|
277
|
+
for (const database of shared) {
|
|
278
|
+
const steps = shortestPath(database.relations, from, to);
|
|
279
|
+
if (steps && (!best || steps.length < best.steps.length))
|
|
280
|
+
best = { database, steps };
|
|
281
|
+
}
|
|
282
|
+
if (!best) {
|
|
283
|
+
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
|
+
}
|
|
285
|
+
const { driver } = best.database;
|
|
286
|
+
const lines = [`FROM ${quoteRef(driver, from)}`];
|
|
287
|
+
let at = from;
|
|
288
|
+
for (const step of best.steps) {
|
|
289
|
+
const mine = step.forward ? step.relation.to.columns : step.relation.from.columns;
|
|
290
|
+
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' : ''}`);
|
|
293
|
+
at = step.table;
|
|
294
|
+
}
|
|
295
|
+
const where = model.databases.length > 1 ? `In ${best.database.name}, ` : '';
|
|
296
|
+
return `${where}${from} to ${to}: ${plural(best.steps.length, 'join', 'joins')}.\n\n\`\`\`sql\n${lines.join('\n')}\n\`\`\`\n\nEach JOIN follows a relation as the docs record it; read the tables to choose the columns and whether a LEFT JOIN suits.`;
|
|
297
|
+
}
|
|
298
|
+
/** Breadth first over one database's relations both ways, so the first path found is a shortest one. */
|
|
299
|
+
function shortestPath(relations, from, to) {
|
|
300
|
+
const reached = new Map([[from, []]]);
|
|
301
|
+
const queue = [from];
|
|
302
|
+
while (queue.length > 0) {
|
|
303
|
+
const at = queue.shift();
|
|
304
|
+
const path = reached.get(at);
|
|
305
|
+
for (const relation of relations) {
|
|
306
|
+
// A table that references itself leads nowhere new.
|
|
307
|
+
if (relation.from.table === relation.to.table)
|
|
308
|
+
continue;
|
|
309
|
+
const step = relation.from.table === at ? { relation, table: relation.to.table, forward: true } : relation.to.table === at ? { relation, table: relation.from.table, forward: false } : undefined;
|
|
310
|
+
if (!step || reached.has(step.table))
|
|
311
|
+
continue;
|
|
312
|
+
const next = [...path, step];
|
|
313
|
+
if (step.table === to)
|
|
314
|
+
return next;
|
|
315
|
+
reached.set(step.table, next);
|
|
316
|
+
queue.push(step.table);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
package/dist/model.d.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
export declare const MODEL_VERSION = 1;
|
|
2
|
+
/**
|
|
3
|
+
* Extended properties other than MS_Description (which is `description`), by name: what a team records about
|
|
4
|
+
* an object in the database itself, such as its owner, source or retention.
|
|
5
|
+
*/
|
|
6
|
+
export type Properties = Record<string, string>;
|
|
7
|
+
/** A column's sensitivity classification, as SQL Server's data discovery or a team set it. */
|
|
8
|
+
export interface Sensitivity {
|
|
9
|
+
label?: string;
|
|
10
|
+
informationType?: string;
|
|
11
|
+
rank?: string;
|
|
12
|
+
}
|
|
2
13
|
export interface Column {
|
|
3
14
|
name: string;
|
|
4
15
|
/** As the database spells it, with its size: nvarchar(320), decimal(18,2). */
|
|
@@ -8,19 +19,62 @@ export interface Column {
|
|
|
8
19
|
/** The expression of a computed column. */
|
|
9
20
|
computed?: string;
|
|
10
21
|
identity?: boolean;
|
|
22
|
+
/** An identity's seed and increment, as the database spells them. */
|
|
23
|
+
identitySeed?: string;
|
|
24
|
+
identityIncrement?: string;
|
|
11
25
|
description?: string;
|
|
26
|
+
/** Only where it differs from the database's collation. */
|
|
27
|
+
collation?: string;
|
|
28
|
+
sparse?: boolean;
|
|
29
|
+
/** The dynamic data masking function: default(), email(), partial(2,"XX",0). */
|
|
30
|
+
masked?: string;
|
|
31
|
+
/** Always Encrypted: how, and under which column encryption key. */
|
|
32
|
+
encrypted?: {
|
|
33
|
+
type: 'deterministic' | 'randomized';
|
|
34
|
+
key: string;
|
|
35
|
+
};
|
|
36
|
+
sensitivity?: Sensitivity;
|
|
37
|
+
properties?: Properties;
|
|
12
38
|
}
|
|
13
39
|
export interface Index {
|
|
14
40
|
name: string;
|
|
15
41
|
columns: string[];
|
|
16
42
|
unique: boolean;
|
|
17
43
|
included?: string[];
|
|
44
|
+
/** As the database names the kind: nonclustered, clustered columnstore, xml, spatial. */
|
|
45
|
+
type?: string;
|
|
46
|
+
/** A filtered index's WHERE. */
|
|
47
|
+
filter?: string;
|
|
48
|
+
disabled?: boolean;
|
|
49
|
+
properties?: Properties;
|
|
18
50
|
}
|
|
19
51
|
export interface Constraint {
|
|
20
52
|
name: string;
|
|
21
53
|
kind: 'primary' | 'unique' | 'check' | 'foreign';
|
|
22
54
|
columns: string[];
|
|
23
55
|
definition?: string;
|
|
56
|
+
/** A foreign key or check the database no longer enforces. */
|
|
57
|
+
disabled?: boolean;
|
|
58
|
+
/** Enforced, but not checked against the rows already there, so the optimiser cannot rely on it. */
|
|
59
|
+
untrusted?: boolean;
|
|
60
|
+
properties?: Properties;
|
|
61
|
+
}
|
|
62
|
+
export interface Trigger {
|
|
63
|
+
name: string;
|
|
64
|
+
timing: 'after' | 'instead of';
|
|
65
|
+
/** insert, update, delete. */
|
|
66
|
+
events: string[];
|
|
67
|
+
enabled: boolean;
|
|
68
|
+
definition?: string;
|
|
69
|
+
}
|
|
70
|
+
/** A row-level security predicate on the table: a filter hides rows, a block refuses writes. */
|
|
71
|
+
export interface SecurityPredicate {
|
|
72
|
+
policy: string;
|
|
73
|
+
kind: 'filter' | 'block';
|
|
74
|
+
/** For a block predicate: after insert, before update, and so on. */
|
|
75
|
+
operation?: string;
|
|
76
|
+
predicate: string;
|
|
77
|
+
enabled: boolean;
|
|
24
78
|
}
|
|
25
79
|
/**
|
|
26
80
|
* How big a table is, as the database keeps it about itself: counted from its catalogue, never by reading
|
|
@@ -44,10 +98,37 @@ export interface Table {
|
|
|
44
98
|
/** The SQL of a view. */
|
|
45
99
|
definition?: string;
|
|
46
100
|
stats?: TableStats;
|
|
101
|
+
properties?: Properties;
|
|
102
|
+
/** When the table was created and its definition last altered, as ISO timestamps. */
|
|
103
|
+
created?: string;
|
|
104
|
+
altered?: string;
|
|
105
|
+
/** A system-versioned table: its history table (a ref) and the columns that bound each row's period. */
|
|
106
|
+
temporal?: {
|
|
107
|
+
history: string;
|
|
108
|
+
start: string;
|
|
109
|
+
end: string;
|
|
110
|
+
};
|
|
111
|
+
/** The history table of a system-versioned table, as its ref. */
|
|
112
|
+
historyOf?: string;
|
|
113
|
+
memoryOptimised?: 'schema and data' | 'schema only';
|
|
114
|
+
ledger?: 'updatable' | 'append-only' | 'history';
|
|
115
|
+
partitioning?: {
|
|
116
|
+
scheme: string;
|
|
117
|
+
function: string;
|
|
118
|
+
column: string;
|
|
119
|
+
partitions: number;
|
|
120
|
+
};
|
|
121
|
+
/** The table's own compression, when it has any: row, page, columnstore, columnstore archive. */
|
|
122
|
+
compression?: string;
|
|
123
|
+
changeTracking?: boolean;
|
|
124
|
+
cdc?: boolean;
|
|
125
|
+
triggers?: Trigger[];
|
|
126
|
+
security?: SecurityPredicate[];
|
|
47
127
|
}
|
|
48
128
|
export interface Schema {
|
|
49
129
|
name: string;
|
|
50
130
|
tables: Table[];
|
|
131
|
+
properties?: Properties;
|
|
51
132
|
}
|
|
52
133
|
export interface RelationEnd {
|
|
53
134
|
table: string;
|
|
@@ -61,6 +142,9 @@ export interface Relation {
|
|
|
61
142
|
virtual?: boolean;
|
|
62
143
|
onDelete?: string;
|
|
63
144
|
onUpdate?: string;
|
|
145
|
+
/** As on its constraint: not enforced, or not checked against the rows already there. */
|
|
146
|
+
disabled?: boolean;
|
|
147
|
+
untrusted?: boolean;
|
|
64
148
|
}
|
|
65
149
|
export interface Database {
|
|
66
150
|
name: string;
|
|
@@ -69,6 +153,11 @@ export interface Database {
|
|
|
69
153
|
description?: string;
|
|
70
154
|
/** When the source read its tables' stats, as an ISO timestamp: they change, so they carry their moment. */
|
|
71
155
|
statsReadAt?: string;
|
|
156
|
+
/** The default collation; a column says its own only where it differs. */
|
|
157
|
+
collation?: string;
|
|
158
|
+
properties?: Properties;
|
|
159
|
+
/** What the source could not read and why, so a reader knows an absence is not a no. */
|
|
160
|
+
unread?: string[];
|
|
72
161
|
schemas: Schema[];
|
|
73
162
|
relations: Relation[];
|
|
74
163
|
}
|
package/dist/model.js
CHANGED
|
@@ -114,10 +114,15 @@ function checkDatabase(check, d, p) {
|
|
|
114
114
|
check.optionalString(d.version, `${p}.version`);
|
|
115
115
|
check.optionalString(d.description, `${p}.description`);
|
|
116
116
|
check.optionalString(d.statsReadAt, `${p}.statsReadAt`);
|
|
117
|
+
check.optionalString(d.collation, `${p}.collation`);
|
|
118
|
+
check.properties(d.properties, `${p}.properties`);
|
|
119
|
+
if (d.unread !== undefined)
|
|
120
|
+
check.strings(d.unread, `${p}.unread`);
|
|
117
121
|
check.list(d.schemas, `${p}.schemas`, (s, sp) => {
|
|
118
122
|
if (!check.object(s, sp))
|
|
119
123
|
return;
|
|
120
124
|
check.string(s.name, `${sp}.name`);
|
|
125
|
+
check.properties(s.properties, `${sp}.properties`);
|
|
121
126
|
check.list(s.tables, `${sp}.tables`, (t, tp) => checkTable(check, t, tp));
|
|
122
127
|
});
|
|
123
128
|
check.list(d.relations, `${p}.relations`, (r, rp) => {
|
|
@@ -132,6 +137,8 @@ function checkDatabase(check, d, p) {
|
|
|
132
137
|
check.strings(e.columns, `${rp}.${end}.columns`);
|
|
133
138
|
}
|
|
134
139
|
check.optionalBoolean(r.virtual, `${rp}.virtual`);
|
|
140
|
+
check.optionalBoolean(r.disabled, `${rp}.disabled`);
|
|
141
|
+
check.optionalBoolean(r.untrusted, `${rp}.untrusted`);
|
|
135
142
|
});
|
|
136
143
|
}
|
|
137
144
|
function checkTable(check, t, p) {
|
|
@@ -148,6 +155,7 @@ function checkTable(check, t, p) {
|
|
|
148
155
|
}
|
|
149
156
|
if (t.primaryKey !== undefined)
|
|
150
157
|
check.strings(t.primaryKey, `${p}.primaryKey`);
|
|
158
|
+
checkCatalogue(check, t, p);
|
|
151
159
|
check.list(t.columns, `${p}.columns`, (c, cp) => {
|
|
152
160
|
if (!check.object(c, cp))
|
|
153
161
|
return;
|
|
@@ -159,6 +167,18 @@ function checkTable(check, t, p) {
|
|
|
159
167
|
check.optionalString(c.computed, `${cp}.computed`);
|
|
160
168
|
check.optionalBoolean(c.identity, `${cp}.identity`);
|
|
161
169
|
check.optionalString(c.description, `${cp}.description`);
|
|
170
|
+
for (const field of ['identitySeed', 'identityIncrement', 'collation', 'masked'])
|
|
171
|
+
check.optionalString(c[field], `${cp}.${field}`);
|
|
172
|
+
check.optionalBoolean(c.sparse, `${cp}.sparse`);
|
|
173
|
+
check.properties(c.properties, `${cp}.properties`);
|
|
174
|
+
if (c.encrypted !== undefined && check.object(c.encrypted, `${cp}.encrypted`)) {
|
|
175
|
+
check.oneOf(c.encrypted.type, ['deterministic', 'randomized'], `${cp}.encrypted.type`);
|
|
176
|
+
check.string(c.encrypted.key, `${cp}.encrypted.key`);
|
|
177
|
+
}
|
|
178
|
+
if (c.sensitivity !== undefined && check.object(c.sensitivity, `${cp}.sensitivity`)) {
|
|
179
|
+
for (const field of ['label', 'informationType', 'rank'])
|
|
180
|
+
check.optionalString(c.sensitivity[field], `${cp}.sensitivity.${field}`);
|
|
181
|
+
}
|
|
162
182
|
});
|
|
163
183
|
check.list(t.indexes, `${p}.indexes`, (x, xp) => {
|
|
164
184
|
if (!check.object(x, xp))
|
|
@@ -167,6 +187,10 @@ function checkTable(check, t, p) {
|
|
|
167
187
|
check.strings(x.columns, `${xp}.columns`);
|
|
168
188
|
if (typeof x.unique !== 'boolean')
|
|
169
189
|
check.errors.push(`${xp}.unique must be true or false`);
|
|
190
|
+
check.optionalString(x.type, `${xp}.type`);
|
|
191
|
+
check.optionalString(x.filter, `${xp}.filter`);
|
|
192
|
+
check.optionalBoolean(x.disabled, `${xp}.disabled`);
|
|
193
|
+
check.properties(x.properties, `${xp}.properties`);
|
|
170
194
|
});
|
|
171
195
|
check.list(t.constraints, `${p}.constraints`, (c, cp) => {
|
|
172
196
|
if (!check.object(c, cp))
|
|
@@ -176,8 +200,56 @@ function checkTable(check, t, p) {
|
|
|
176
200
|
check.errors.push(`${cp}.kind is not a constraint kind`);
|
|
177
201
|
check.strings(c.columns, `${cp}.columns`);
|
|
178
202
|
check.optionalString(c.definition, `${cp}.definition`);
|
|
203
|
+
check.optionalBoolean(c.disabled, `${cp}.disabled`);
|
|
204
|
+
check.optionalBoolean(c.untrusted, `${cp}.untrusted`);
|
|
205
|
+
check.properties(c.properties, `${cp}.properties`);
|
|
179
206
|
});
|
|
180
207
|
}
|
|
208
|
+
/** What the catalogue says about a table beyond its columns, keys and indexes. */
|
|
209
|
+
function checkCatalogue(check, t, p) {
|
|
210
|
+
check.properties(t.properties, `${p}.properties`);
|
|
211
|
+
for (const field of ['created', 'altered', 'historyOf', 'compression'])
|
|
212
|
+
check.optionalString(t[field], `${p}.${field}`);
|
|
213
|
+
for (const field of ['changeTracking', 'cdc'])
|
|
214
|
+
check.optionalBoolean(t[field], `${p}.${field}`);
|
|
215
|
+
if (t.memoryOptimised !== undefined)
|
|
216
|
+
check.oneOf(t.memoryOptimised, ['schema and data', 'schema only'], `${p}.memoryOptimised`);
|
|
217
|
+
if (t.ledger !== undefined)
|
|
218
|
+
check.oneOf(t.ledger, ['updatable', 'append-only', 'history'], `${p}.ledger`);
|
|
219
|
+
if (t.temporal !== undefined && check.object(t.temporal, `${p}.temporal`)) {
|
|
220
|
+
for (const field of ['history', 'start', 'end'])
|
|
221
|
+
check.string(t.temporal[field], `${p}.temporal.${field}`);
|
|
222
|
+
}
|
|
223
|
+
if (t.partitioning !== undefined && check.object(t.partitioning, `${p}.partitioning`)) {
|
|
224
|
+
for (const field of ['scheme', 'function', 'column'])
|
|
225
|
+
check.string(t.partitioning[field], `${p}.partitioning.${field}`);
|
|
226
|
+
check.count(t.partitioning.partitions, `${p}.partitioning.partitions`);
|
|
227
|
+
}
|
|
228
|
+
if (t.triggers !== undefined) {
|
|
229
|
+
check.list(t.triggers, `${p}.triggers`, (g, gp) => {
|
|
230
|
+
if (!check.object(g, gp))
|
|
231
|
+
return;
|
|
232
|
+
check.string(g.name, `${gp}.name`);
|
|
233
|
+
check.oneOf(g.timing, ['after', 'instead of'], `${gp}.timing`);
|
|
234
|
+
check.strings(g.events, `${gp}.events`);
|
|
235
|
+
if (typeof g.enabled !== 'boolean')
|
|
236
|
+
check.errors.push(`${gp}.enabled must be true or false`);
|
|
237
|
+
check.optionalString(g.definition, `${gp}.definition`);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if (t.security !== undefined) {
|
|
241
|
+
check.list(t.security, `${p}.security`, (r, rp) => {
|
|
242
|
+
if (!check.object(r, rp))
|
|
243
|
+
return;
|
|
244
|
+
check.string(r.policy, `${rp}.policy`);
|
|
245
|
+
check.oneOf(r.kind, ['filter', 'block'], `${rp}.kind`);
|
|
246
|
+
check.optionalString(r.operation, `${rp}.operation`);
|
|
247
|
+
check.string(r.predicate, `${rp}.predicate`);
|
|
248
|
+
if (typeof r.enabled !== 'boolean')
|
|
249
|
+
check.errors.push(`${rp}.enabled must be true or false`);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
181
253
|
class Checker {
|
|
182
254
|
errors;
|
|
183
255
|
constructor(errors) {
|
|
@@ -201,6 +273,18 @@ class Checker {
|
|
|
201
273
|
if (value !== undefined && typeof value !== 'boolean')
|
|
202
274
|
this.errors.push(`${path} must be true or false when given`);
|
|
203
275
|
}
|
|
276
|
+
properties(value, path) {
|
|
277
|
+
if (value === undefined || !this.object(value, path))
|
|
278
|
+
return;
|
|
279
|
+
for (const [name, text] of Object.entries(value)) {
|
|
280
|
+
if (typeof text !== 'string')
|
|
281
|
+
this.errors.push(`${path}.${name} must be a string`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
oneOf(value, allowed, path) {
|
|
285
|
+
if (!allowed.includes(value))
|
|
286
|
+
this.errors.push(`${path} must be ${allowed.map((a) => `"${a}"`).join(' or ')}`);
|
|
287
|
+
}
|
|
204
288
|
count(value, path) {
|
|
205
289
|
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0)
|
|
206
290
|
this.errors.push(`${path} must be a number of 0 or more`);
|
|
@@ -1,8 +1,17 @@
|
|
|
1
|
-
import { findTable, type Model, type TableStats } from '../model.ts';
|
|
1
|
+
import { findTable, type Model, type Table, type TableStats } from '../model.ts';
|
|
2
2
|
export declare const wholeDiagramLimit = 40;
|
|
3
3
|
export declare function renderMarkdown(model: Model): Record<string, string>;
|
|
4
4
|
/** The product's name for a driver: mssql is SQL Server. */
|
|
5
5
|
export declare function driverName(driver: string): string;
|
|
6
|
+
/** A thing the catalogue says about a table beyond its columns and keys: a name, and its particulars. */
|
|
7
|
+
export interface Feature {
|
|
8
|
+
name: string;
|
|
9
|
+
detail?: string;
|
|
10
|
+
/** A table the detail names, for a link. */
|
|
11
|
+
ref?: string;
|
|
12
|
+
}
|
|
13
|
+
/** What the catalogue says about a table beyond its columns and keys, in the order a reader weighs it. */
|
|
14
|
+
export declare function features(table: Table): Feature[];
|
|
6
15
|
/** 1,204,377: thousands grouped the same way wherever it runs, so the docs do not change with the machine. */
|
|
7
16
|
export declare function formatCount(n: number): string;
|
|
8
17
|
/** 480 KB, 7.1 MB, 102 MB: binary units, as the database counts its pages, with one decimal below ten. */
|
package/dist/render/markdown.js
CHANGED
|
@@ -27,6 +27,8 @@ function renderIndex(model) {
|
|
|
27
27
|
if (database.description)
|
|
28
28
|
parts.push(`${database.description}\n`);
|
|
29
29
|
parts.push(`${driverLine(database)}\n`);
|
|
30
|
+
if (database.unread?.length)
|
|
31
|
+
parts.push(`Not read: ${database.unread.join('; ')}.\n`);
|
|
30
32
|
// Rows and size only when the source read them; a view, which keeps no rows of its own, leaves them blank.
|
|
31
33
|
const sized = database.schemas.some((s) => s.tables.some((t) => t.stats));
|
|
32
34
|
parts.push(sized ? '| Table | Kind | Columns | Rows | Size | Description |\n|---|---|---|---|---|---|' : '| Table | Kind | Columns | Description |\n|---|---|---|---|');
|
|
@@ -39,6 +41,10 @@ function renderIndex(model) {
|
|
|
39
41
|
}
|
|
40
42
|
parts.push('');
|
|
41
43
|
}
|
|
44
|
+
const owned = model.databases.flatMap((d) => [[d.name, d.properties], ...d.schemas.map((s) => [s.name, s.properties])]);
|
|
45
|
+
const properties = propertiesTable(owned);
|
|
46
|
+
if (properties)
|
|
47
|
+
parts.push(`## Properties\n\n${properties}`);
|
|
42
48
|
for (const group of model.groups) {
|
|
43
49
|
parts.push(`## ${group.name}\n`);
|
|
44
50
|
if (group.description)
|
|
@@ -63,7 +69,81 @@ export function driverName(driver) {
|
|
|
63
69
|
}
|
|
64
70
|
function driverLine(database) {
|
|
65
71
|
const name = driverName(database.driver);
|
|
66
|
-
|
|
72
|
+
const collation = database.collation ? `, collation ${database.collation}` : '';
|
|
73
|
+
return `${database.version ? `${name} ${database.version}` : name}${collation}.`;
|
|
74
|
+
}
|
|
75
|
+
/** What the catalogue says about a table beyond its columns and keys, in the order a reader weighs it. */
|
|
76
|
+
export function features(table) {
|
|
77
|
+
const list = [];
|
|
78
|
+
if (table.temporal) {
|
|
79
|
+
const { history, start, end } = table.temporal;
|
|
80
|
+
list.push({ name: 'System-versioned', detail: `history in ${history}, each row valid from ${start} to ${end}`, ref: history });
|
|
81
|
+
}
|
|
82
|
+
if (table.historyOf)
|
|
83
|
+
list.push({ name: 'History', detail: `of ${table.historyOf}`, ref: table.historyOf });
|
|
84
|
+
if (table.memoryOptimised)
|
|
85
|
+
list.push({ name: 'Memory-optimised', detail: table.memoryOptimised });
|
|
86
|
+
if (table.ledger)
|
|
87
|
+
list.push({ name: 'Ledger', detail: table.ledger });
|
|
88
|
+
if (table.partitioning) {
|
|
89
|
+
const { column, scheme, function: fn, partitions } = table.partitioning;
|
|
90
|
+
list.push({ name: 'Partitioned', detail: `on ${column} by ${scheme} (function ${fn}), ${partitions} partitions` });
|
|
91
|
+
}
|
|
92
|
+
// A list is a table whose partitions are compressed differently.
|
|
93
|
+
if (table.compression)
|
|
94
|
+
list.push({ name: 'Compressed', detail: table.compression.includes(',') ? `${table.compression}, by partition` : table.compression });
|
|
95
|
+
if (table.changeTracking)
|
|
96
|
+
list.push({ name: 'Change tracking' });
|
|
97
|
+
if (table.cdc)
|
|
98
|
+
list.push({ name: 'Change data capture' });
|
|
99
|
+
if (table.triggers?.length)
|
|
100
|
+
list.push({ name: 'Triggers', detail: String(table.triggers.length) });
|
|
101
|
+
if (table.security?.length)
|
|
102
|
+
list.push({ name: 'Row-level security', detail: [...new Set(table.security.map((s) => s.policy))].join(', ') });
|
|
103
|
+
if (table.created) {
|
|
104
|
+
const altered = table.altered && table.altered.slice(0, 10) !== table.created.slice(0, 10) ? `, last altered ${table.altered.slice(0, 10)}` : '';
|
|
105
|
+
list.push({ name: 'Created', detail: `${table.created.slice(0, 10)}${altered}` });
|
|
106
|
+
}
|
|
107
|
+
return list;
|
|
108
|
+
}
|
|
109
|
+
/** A column's type, with its collation where it differs from the database's. */
|
|
110
|
+
function columnType(column) {
|
|
111
|
+
return column.collation ? `${column.type}, collation ${column.collation}` : column.type;
|
|
112
|
+
}
|
|
113
|
+
/** What else the catalogue says about a column, for the Key cell: identity, sparse, masking, encryption. */
|
|
114
|
+
function columnTraits(column) {
|
|
115
|
+
const traits = [];
|
|
116
|
+
if (column.identity)
|
|
117
|
+
traits.push(column.identitySeed !== undefined ? `identity(${column.identitySeed},${column.identityIncrement ?? '1'})` : 'identity');
|
|
118
|
+
if (column.computed !== undefined)
|
|
119
|
+
traits.push(`computed \`${column.computed}\``);
|
|
120
|
+
if (column.sparse)
|
|
121
|
+
traits.push('sparse');
|
|
122
|
+
if (column.masked)
|
|
123
|
+
traits.push(`masked \`${column.masked}\``);
|
|
124
|
+
if (column.encrypted)
|
|
125
|
+
traits.push(`encrypted (${column.encrypted.type}, ${column.encrypted.key})`);
|
|
126
|
+
return traits;
|
|
127
|
+
}
|
|
128
|
+
/** The description cell: the description, then the sensitivity classification when there is one. */
|
|
129
|
+
function columnDescription(column) {
|
|
130
|
+
const parts = column.description ? [column.description] : [];
|
|
131
|
+
const s = column.sensitivity;
|
|
132
|
+
if (s)
|
|
133
|
+
parts.push(`Sensitivity: ${[s.label, s.informationType, s.rank && `rank ${s.rank}`].filter(Boolean).join(', ')}.`);
|
|
134
|
+
return parts.join(' ');
|
|
135
|
+
}
|
|
136
|
+
/** Not enforced, or not checked against the rows already there. */
|
|
137
|
+
function enforcement(item) {
|
|
138
|
+
return [item.disabled ? 'disabled' : '', item.untrusted ? 'not trusted' : ''].filter(Boolean);
|
|
139
|
+
}
|
|
140
|
+
function indexKind(index) {
|
|
141
|
+
return [index.type ?? '', index.filter ? `filtered \`${index.filter}\`` : '', index.disabled ? 'disabled' : ''].filter(Boolean).join(', ');
|
|
142
|
+
}
|
|
143
|
+
/** Every extended property of the things listed, one row each; empty when there are none. */
|
|
144
|
+
function propertiesTable(owners) {
|
|
145
|
+
const rows = owners.flatMap(([on, properties]) => Object.entries(properties ?? {}).map(([name, value]) => `| ${cell(on)} | ${cell(name)} | ${cell(value)} |`));
|
|
146
|
+
return rows.length === 0 ? '' : `| On | Name | Value |\n|---|---|---|\n${rows.join('\n')}\n`;
|
|
67
147
|
}
|
|
68
148
|
/** 1,204,377: thousands grouped the same way wherever it runs, so the docs do not change with the machine. */
|
|
69
149
|
export function formatCount(n) {
|
|
@@ -99,6 +179,13 @@ function renderTable(model, ref, table, database) {
|
|
|
99
179
|
parts.push(`${table.description}\n`);
|
|
100
180
|
if (table.stats)
|
|
101
181
|
parts.push(`${statsLine(table.stats, database.statsReadAt)}\n`);
|
|
182
|
+
const listed = features(table);
|
|
183
|
+
if (listed.length > 0) {
|
|
184
|
+
parts.push('## Features\n');
|
|
185
|
+
// 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);
|
|
187
|
+
parts.push(listed.map((f) => `- **${f.name}**${f.detail ? `: ${linked(f)}` : ''}`).join('\n') + '\n');
|
|
188
|
+
}
|
|
102
189
|
const relations = relationsOf(model, ref);
|
|
103
190
|
parts.push('| Column | Type | Nullable | Default | Key | Description |\n|---|---|---|---|---|---|');
|
|
104
191
|
for (const column of table.columns) {
|
|
@@ -110,16 +197,14 @@ function renderTable(model, ref, table, database) {
|
|
|
110
197
|
if (i === -1)
|
|
111
198
|
continue;
|
|
112
199
|
const { table: toTable, columns } = relation.to;
|
|
113
|
-
|
|
200
|
+
const state = [relation.virtual ? 'notes' : '', ...enforcement(relation)].filter(Boolean);
|
|
201
|
+
keys.push(`FK → [${toTable}](${toTable}.md).${columns[i] ?? columns.join(',')}${state.length ? ` (${state.join(', ')})` : ''}`);
|
|
114
202
|
}
|
|
115
203
|
if (isUniqueColumn(table, column.name))
|
|
116
204
|
keys.push('unique');
|
|
117
|
-
|
|
118
|
-
keys.push('identity');
|
|
119
|
-
if (column.computed !== undefined)
|
|
120
|
-
keys.push(`computed \`${column.computed}\``);
|
|
205
|
+
keys.push(...columnTraits(column));
|
|
121
206
|
const dflt = column.default === undefined ? '' : `\`${column.default}\``;
|
|
122
|
-
parts.push(`| ${cell(column.name)} | ${cell(column
|
|
207
|
+
parts.push(`| ${cell(column.name)} | ${cell(columnType(column))} | ${column.nullable ? 'yes' : 'no'} | ${cell(dflt)} | ${cell(keys.join(', '))} | ${cell(columnDescription(column))} |`);
|
|
123
208
|
}
|
|
124
209
|
parts.push('');
|
|
125
210
|
if (relations.in.length > 0) {
|
|
@@ -131,10 +216,12 @@ function renderTable(model, ref, table, database) {
|
|
|
131
216
|
}
|
|
132
217
|
if (table.indexes.length > 0) {
|
|
133
218
|
parts.push('## Indexes\n');
|
|
134
|
-
|
|
219
|
+
// The Kind column only when the source said what kind; a model without it renders as it always did.
|
|
220
|
+
const kinds = table.indexes.some((i) => indexKind(i) !== '');
|
|
221
|
+
parts.push(kinds ? '| Name | Columns | Unique | Kind |\n|---|---|---|---|' : '| Name | Columns | Unique |\n|---|---|---|');
|
|
135
222
|
for (const index of table.indexes) {
|
|
136
223
|
const columns = index.columns.join(', ') + (index.included?.length ? ` (includes ${index.included.join(', ')})` : '');
|
|
137
|
-
parts.push(`| ${cell(index.name)} | ${cell(columns)} | ${index.unique ? 'yes' : 'no'} |`);
|
|
224
|
+
parts.push(`| ${cell(index.name)} | ${cell(columns)} | ${index.unique ? 'yes' : 'no'} |${kinds ? ` ${cell(indexKind(index))} |` : ''}`);
|
|
138
225
|
}
|
|
139
226
|
parts.push('');
|
|
140
227
|
}
|
|
@@ -143,7 +230,34 @@ function renderTable(model, ref, table, database) {
|
|
|
143
230
|
parts.push('| Name | Kind | Columns | Definition |\n|---|---|---|---|');
|
|
144
231
|
for (const constraint of table.constraints) {
|
|
145
232
|
const definition = constraint.definition === undefined ? '' : `\`${constraint.definition}\``;
|
|
146
|
-
|
|
233
|
+
const kind = [constraint.kind, ...enforcement(constraint)].join(', ');
|
|
234
|
+
parts.push(`| ${cell(constraint.name)} | ${kind} | ${cell(constraint.columns.join(', '))} | ${cell(definition)} |`);
|
|
235
|
+
}
|
|
236
|
+
parts.push('');
|
|
237
|
+
}
|
|
238
|
+
const properties = propertiesTable([
|
|
239
|
+
[ref, table.properties],
|
|
240
|
+
...table.columns.map((c) => [c.name, c.properties]),
|
|
241
|
+
...table.indexes.map((i) => [i.name, i.properties]),
|
|
242
|
+
...table.constraints.map((c) => [c.name, c.properties]),
|
|
243
|
+
]);
|
|
244
|
+
if (properties)
|
|
245
|
+
parts.push(`## Properties\n\n${properties}`);
|
|
246
|
+
if (table.triggers?.length) {
|
|
247
|
+
parts.push('## Triggers\n');
|
|
248
|
+
for (const trigger of table.triggers) {
|
|
249
|
+
const when = `${trigger.timing === 'after' ? 'After' : 'Instead of'} ${trigger.events.join(', ')}. ${trigger.enabled ? 'Enabled' : 'Disabled'}.`;
|
|
250
|
+
parts.push(`### ${trigger.name}\n\n${when}\n`);
|
|
251
|
+
if (trigger.definition)
|
|
252
|
+
parts.push('```sql\n' + trigger.definition.trim() + '\n```\n');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (table.security?.length) {
|
|
256
|
+
parts.push('## Row-level security\n');
|
|
257
|
+
parts.push('| Policy | Kind | Predicate | Enabled |\n|---|---|---|---|');
|
|
258
|
+
for (const s of table.security) {
|
|
259
|
+
const kind = s.operation ? `${s.kind}, ${s.operation}` : s.kind;
|
|
260
|
+
parts.push(`| ${cell(s.policy)} | ${kind} | ${cell(`\`${s.predicate}\``)} | ${s.enabled ? 'yes' : 'no'} |`);
|
|
147
261
|
}
|
|
148
262
|
parts.push('');
|
|
149
263
|
}
|
package/dist/search.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Model } from './model.ts';
|
|
2
|
+
export interface Hit {
|
|
3
|
+
kind: 'table' | 'view' | 'column' | 'group';
|
|
4
|
+
/** schema.table for a table or column, the group's name for a group. */
|
|
5
|
+
ref: string;
|
|
6
|
+
column?: string;
|
|
7
|
+
title: string;
|
|
8
|
+
detail: string;
|
|
9
|
+
score: number;
|
|
10
|
+
}
|
|
11
|
+
export interface Entry extends Omit<Hit, 'score'> {
|
|
12
|
+
/** Lower-cased name, where a prefix match counts most. */
|
|
13
|
+
name: string;
|
|
14
|
+
/** Lower-cased ref and description, where any match counts. */
|
|
15
|
+
text: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function buildIndex(model: Model): Entry[];
|
|
18
|
+
export declare function search(index: Entry[], query: string, limit?: number): Hit[];
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Search over tables, columns and groups: built once from the model, queried on every keystroke of the page
|
|
2
|
+
// and by an agent through the MCP server.
|
|
3
|
+
import { tableRef } from "./model.js";
|
|
4
|
+
export function buildIndex(model) {
|
|
5
|
+
const entries = [];
|
|
6
|
+
for (const database of model.databases) {
|
|
7
|
+
for (const schema of database.schemas) {
|
|
8
|
+
for (const table of schema.tables) {
|
|
9
|
+
const ref = tableRef(schema.name, table.name);
|
|
10
|
+
entries.push({
|
|
11
|
+
kind: table.kind,
|
|
12
|
+
ref,
|
|
13
|
+
title: ref,
|
|
14
|
+
detail: table.description ?? `${table.columns.length} ${table.columns.length === 1 ? 'column' : 'columns'}`,
|
|
15
|
+
name: table.name.toLowerCase(),
|
|
16
|
+
text: `${ref} ${table.description ?? ''}`.toLowerCase(),
|
|
17
|
+
});
|
|
18
|
+
for (const column of table.columns) {
|
|
19
|
+
entries.push({
|
|
20
|
+
kind: 'column',
|
|
21
|
+
ref,
|
|
22
|
+
column: column.name,
|
|
23
|
+
title: `${ref}.${column.name}`,
|
|
24
|
+
detail: column.description ?? column.type,
|
|
25
|
+
name: column.name.toLowerCase(),
|
|
26
|
+
text: `${ref}.${column.name} ${column.type} ${column.description ?? ''}`.toLowerCase(),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (const group of model.groups) {
|
|
33
|
+
entries.push({ kind: 'group', ref: group.name, title: group.name, detail: group.description ?? `${group.tables.length} tables`, name: group.name.toLowerCase(), text: `${group.name} ${group.description ?? ''}`.toLowerCase() });
|
|
34
|
+
}
|
|
35
|
+
return entries;
|
|
36
|
+
}
|
|
37
|
+
// How well the words match comes first: a name that starts with the word beats one that contains it,
|
|
38
|
+
// which beats a match in the description. Among equal matches a table comes before a group, then a column,
|
|
39
|
+
// so typing a table's name finds the table and typing a column's name finds the column.
|
|
40
|
+
const kindWeight = { table: 3, view: 3, group: 2, column: 1 };
|
|
41
|
+
const prefixScore = 3;
|
|
42
|
+
const nameScore = 2;
|
|
43
|
+
const textScore = 1;
|
|
44
|
+
export function search(index, query, limit = 50) {
|
|
45
|
+
const words = query.toLowerCase().split(/\s+/).filter((w) => w !== '');
|
|
46
|
+
if (words.length === 0)
|
|
47
|
+
return [];
|
|
48
|
+
const hits = [];
|
|
49
|
+
for (const entry of index) {
|
|
50
|
+
let score = 0;
|
|
51
|
+
for (const word of words) {
|
|
52
|
+
const wordScore = entry.name.startsWith(word) ? prefixScore : entry.name.includes(word) ? nameScore : entry.text.includes(word) ? textScore : 0;
|
|
53
|
+
if (wordScore === 0) {
|
|
54
|
+
score = 0;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
score += wordScore;
|
|
58
|
+
}
|
|
59
|
+
if (score > 0)
|
|
60
|
+
hits.push({ kind: entry.kind, ref: entry.ref, column: entry.column, title: entry.title, detail: entry.detail, score });
|
|
61
|
+
}
|
|
62
|
+
hits.sort((a, b) => b.score - a.score || kindWeight[b.kind] - kindWeight[a.kind] || a.title.length - b.title.length || a.title.localeCompare(b.title));
|
|
63
|
+
return hits.slice(0, limit);
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dbtour",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
"./markdown": {
|
|
29
29
|
"types": "./dist/render/markdown.d.ts",
|
|
30
30
|
"default": "./dist/render/markdown.js"
|
|
31
|
+
},
|
|
32
|
+
"./search": {
|
|
33
|
+
"types": "./dist/search.d.ts",
|
|
34
|
+
"default": "./dist/search.js"
|
|
31
35
|
}
|
|
32
36
|
},
|
|
33
37
|
"files": [
|