dbtour 0.7.1 → 0.9.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 +2 -0
- package/dist/cli.js +125 -3
- package/dist/config.d.ts +11 -0
- package/dist/config.js +24 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/mcp.d.ts +23 -2
- package/dist/mcp.js +41 -3
- package/dist/plugins.d.ts +8 -0
- package/dist/plugins.js +2 -1
- package/dist/serve.d.ts +24 -0
- package/dist/serve.js +129 -0
- package/dist/sql.d.ts +38 -0
- package/dist/sql.js +185 -0
- package/package.json +5 -1
package/dist/cli.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export interface Io {
|
|
|
10
10
|
load?: (source: SourceConfig) => Promise<Source>;
|
|
11
11
|
/** How an add-on is loaded; tests hand in their own. */
|
|
12
12
|
loadAddon?: (addon: AddonConfig) => Promise<Addon>;
|
|
13
|
+
/** When serve stops; Ctrl+C unless a test hands in its own. */
|
|
14
|
+
stop?: Promise<void>;
|
|
13
15
|
/** Where mcp reads requests and writes answers; process.stdin and process.stdout unless a test hands in its own. */
|
|
14
16
|
stdin?: Readable;
|
|
15
17
|
stdout?: Writable;
|
package/dist/cli.js
CHANGED
|
@@ -13,10 +13,13 @@ import { createInterface } from 'node:readline';
|
|
|
13
13
|
import { dirname, join, resolve } from 'node:path';
|
|
14
14
|
import { pathToFileURL } from 'node:url';
|
|
15
15
|
import { parseArgs } from 'node:util';
|
|
16
|
-
import { configFile, defaultConfigText, modelFile, parseConfig, resolveConnection } from "./config.js";
|
|
16
|
+
import { configFile, defaultConfigText, modelFile, parseConfig, resolveConnection, resolveReader } from "./config.js";
|
|
17
17
|
import { emptyModel, scope, validate } from "./model.js";
|
|
18
18
|
import { applyNotes, parseNotes } from "./notes.js";
|
|
19
19
|
import { answerLine } from "./mcp.js";
|
|
20
|
+
import { checkQuery, clampLimit, defaultTimeoutMs, resultText } from "./sql.js";
|
|
21
|
+
import { startServer } from "./serve.js";
|
|
22
|
+
import { randomBytes } from 'node:crypto';
|
|
20
23
|
import { loadAddon, loadSource } from "./plugins.js";
|
|
21
24
|
import { renderMarkdown } from "./render/markdown.js";
|
|
22
25
|
const help = `Usage: dbtour <command>
|
|
@@ -27,10 +30,19 @@ const help = `Usage: dbtour <command>
|
|
|
27
30
|
add-ons in ${configFile} render (explorer: index.html) under <out>
|
|
28
31
|
mcp [project] answer an agent's questions about <out>/${modelFile} and the notes over MCP,
|
|
29
32
|
on stdin and stdout: claude mcp add dbtour -- npx dbtour mcp
|
|
33
|
+
sql "<query>" run one SELECT through a source's reader, a read-only login of its own,
|
|
34
|
+
rolled back, at most --limit rows (100); --source <name>, --json
|
|
35
|
+
serve [--port 4321] the rendered page on this machine; its SQL editor runs queries through the
|
|
36
|
+
readers, as sql does
|
|
30
37
|
|
|
31
38
|
Set the connection string in the environment variable ${configFile} names, never in the file.`;
|
|
32
39
|
export async function main(argv, io = { cwd: process.cwd(), log: console.log, error: console.error }) {
|
|
33
|
-
const { values, positionals } = parseArgs({
|
|
40
|
+
const { values, positionals } = parseArgs({
|
|
41
|
+
args: argv,
|
|
42
|
+
options: { help: { type: 'boolean', short: 'h' }, source: { type: 'string' }, limit: { type: 'string' }, json: { type: 'boolean' }, port: { type: 'string' } },
|
|
43
|
+
allowPositionals: true,
|
|
44
|
+
strict: false,
|
|
45
|
+
});
|
|
34
46
|
const command = positionals[0];
|
|
35
47
|
if (values.help || command === undefined) {
|
|
36
48
|
io.log(help);
|
|
@@ -46,6 +58,14 @@ export async function main(argv, io = { cwd: process.cwd(), log: console.log, er
|
|
|
46
58
|
return await render(io);
|
|
47
59
|
case 'mcp':
|
|
48
60
|
return await mcp(positionals[1] === undefined ? io : { ...io, cwd: resolve(io.cwd, positionals[1]) });
|
|
61
|
+
case 'serve':
|
|
62
|
+
return await serve(io, typeof values.port === 'string' ? Number(values.port) : defaultPort);
|
|
63
|
+
case 'sql':
|
|
64
|
+
return await sql(io, positionals[1], {
|
|
65
|
+
source: typeof values.source === 'string' ? values.source : undefined,
|
|
66
|
+
limit: typeof values.limit === 'string' ? Number(values.limit) : undefined,
|
|
67
|
+
json: values.json === true,
|
|
68
|
+
});
|
|
49
69
|
default:
|
|
50
70
|
io.error(`"${command}" is not a dbtour command.\n\n${help}`);
|
|
51
71
|
return 1;
|
|
@@ -189,10 +209,112 @@ async function mcp(io) {
|
|
|
189
209
|
};
|
|
190
210
|
const version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
191
211
|
const output = io.stdout ?? process.stdout;
|
|
212
|
+
// The query tool is offered only when a source has a reader; its errors go back to the agent as text.
|
|
213
|
+
const run = config.sources.some((s) => s.reader !== undefined)
|
|
214
|
+
? async (text, source, limit) => {
|
|
215
|
+
try {
|
|
216
|
+
return { content: [{ type: 'text', text: resultText(await runQuery(io, config, text, source, limit)) }] };
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
return { content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }], isError: true };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
: undefined;
|
|
223
|
+
// One request at a time, in order: a query is awaited before the next line is read.
|
|
192
224
|
for await (const line of createInterface({ input: io.stdin ?? process.stdin, crlfDelay: Infinity })) {
|
|
193
|
-
const reply = answerLine(line, { model: current, version });
|
|
225
|
+
const reply = await answerLine(line, { model: current, version, run });
|
|
194
226
|
if (reply !== undefined)
|
|
195
227
|
output.write(reply + '\n');
|
|
196
228
|
}
|
|
197
229
|
return 0;
|
|
198
230
|
}
|
|
231
|
+
/** dbtour serve's port unless --port says otherwise: one an editor's own dev servers seldom take. */
|
|
232
|
+
const defaultPort = 4321;
|
|
233
|
+
/**
|
|
234
|
+
* The rendered folder on 127.0.0.1, with a door for its SQL editor: a query posted by the page runs as dbtour
|
|
235
|
+
* sql runs it. A token made fresh each time is handed to the page and required on every query.
|
|
236
|
+
*/
|
|
237
|
+
async function serve(io, port) {
|
|
238
|
+
const config = readConfig(io);
|
|
239
|
+
const root = join(io.cwd, config.out);
|
|
240
|
+
if (!existsSync(join(root, 'index.html')))
|
|
241
|
+
throw missing(`No ${join(config.out, 'index.html')} to serve: add explorer to addons in ${configFile}, run dbtour render, then dbtour serve again`);
|
|
242
|
+
const readers = config.sources.filter((s) => s.reader !== undefined);
|
|
243
|
+
const token = randomBytes(24).toString('base64url');
|
|
244
|
+
let server;
|
|
245
|
+
try {
|
|
246
|
+
server = await startServer({
|
|
247
|
+
root,
|
|
248
|
+
port,
|
|
249
|
+
token,
|
|
250
|
+
sources: config.sources.map((s) => ({ name: s.name, reader: s.reader !== undefined })),
|
|
251
|
+
run: readers.length
|
|
252
|
+
? async (text, source, limit) => ({ ...(await runQuery(io, config, text, source, limit)), source: pickSource(config, source).name })
|
|
253
|
+
: undefined,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
if (error.code === 'EADDRINUSE')
|
|
258
|
+
throw new Error(`Port ${port} is taken: run dbtour serve --port ${port + 1}`, { cause: error });
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
io.log(`Serving ${config.out} at ${server.url}`);
|
|
262
|
+
io.log(readers.length ? `Queries run through ${readers.map((s) => `${s.name}'s reader`).join(', ')}. Ctrl+C to stop.` : `No source has a reader, so the page can be read but runs no query: add reader to a source in ${configFile}. Ctrl+C to stop.`);
|
|
263
|
+
await (io.stop ?? new Promise((done) => process.once('SIGINT', () => done())));
|
|
264
|
+
await server.close();
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
async function sql(io, text, options) {
|
|
268
|
+
if (text === undefined || text.trim() === '') {
|
|
269
|
+
io.error('Give the query to run: dbtour sql "SELECT TOP (10) * FROM dbo.Orders"');
|
|
270
|
+
return 1;
|
|
271
|
+
}
|
|
272
|
+
const result = await runQuery(io, readConfig(io), text, options.source, options.limit);
|
|
273
|
+
io.log(options.json ? JSON.stringify(result, cellJson, 2) : resultText(result));
|
|
274
|
+
return 0;
|
|
275
|
+
}
|
|
276
|
+
/** JSON for a cell: a date as ISO, bytes as hex. */
|
|
277
|
+
function cellJson(_key, value) {
|
|
278
|
+
if (value && typeof value === 'object' && value.type === 'Buffer')
|
|
279
|
+
return `0x${Buffer.from(value.data).toString('hex')}`;
|
|
280
|
+
return value;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* The query checked, then run through the chosen source's reader. Refusals throw before any connection is
|
|
284
|
+
* made: a query that is not one read, no reader or the extract's own login, a source that cannot run.
|
|
285
|
+
*/
|
|
286
|
+
async function runQuery(io, config, text, sourceName, limit) {
|
|
287
|
+
const checked = checkQuery(text);
|
|
288
|
+
if (!checked.ok)
|
|
289
|
+
throw new Error(checked.reason);
|
|
290
|
+
const source = pickSource(config, sourceName);
|
|
291
|
+
const reader = resolveReader(source, io.env ?? process.env);
|
|
292
|
+
const load = io.load ?? ((s) => loadSource(s, importer(io.cwd)));
|
|
293
|
+
const { run } = await load(source);
|
|
294
|
+
if (run === undefined)
|
|
295
|
+
throw missing(`Source ${source.name} (${source.type}) cannot run queries: update ${source.module ?? `dbtour-source-${source.type}`} to the version of dbtour you have, then run it again`);
|
|
296
|
+
try {
|
|
297
|
+
return await run({ ...source, connection: reader }, checked.sql, { limit: clampLimit(limit), timeoutMs: defaultTimeoutMs });
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
throw new Error(`${source.name} (${source.type}): ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/** The source named, or the only one with a reader; several want a name, and none says what to add. */
|
|
304
|
+
function pickSource(config, name) {
|
|
305
|
+
if (name !== undefined) {
|
|
306
|
+
const named = config.sources.filter((s) => s.name === name);
|
|
307
|
+
if (named.length === 0)
|
|
308
|
+
throw new Error(`There is no source named ${name}: ${config.sources.map((s) => s.name).join(', ')}`);
|
|
309
|
+
// Two of one name would leave which database the query runs in to the order of the file.
|
|
310
|
+
if (named.length > 1)
|
|
311
|
+
throw new Error(`Two sources are named ${name}: give each its own name in ${configFile}, then run it again`);
|
|
312
|
+
return named[0];
|
|
313
|
+
}
|
|
314
|
+
const readers = config.sources.filter((s) => s.reader !== undefined);
|
|
315
|
+
if (readers.length === 1)
|
|
316
|
+
return readers[0];
|
|
317
|
+
if (readers.length > 1)
|
|
318
|
+
throw new Error(`Several sources have a reader: say which with ${readers.map((s) => `--source ${s.name}`).join(' or ')}`);
|
|
319
|
+
return config.sources[0];
|
|
320
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -3,6 +3,11 @@ export interface SourceConfig {
|
|
|
3
3
|
name: string;
|
|
4
4
|
/** `${NAME}`: the environment variable holding the connection string. Never the string itself. */
|
|
5
5
|
connection?: string;
|
|
6
|
+
/**
|
|
7
|
+
* `${NAME}`: the variable holding the connection string queries run with, for a login of its own with
|
|
8
|
+
* db_datareader alone. Never the extract's connection.
|
|
9
|
+
*/
|
|
10
|
+
reader?: string;
|
|
6
11
|
/** A module to import instead of dbtour-source-<type>. */
|
|
7
12
|
module?: string;
|
|
8
13
|
[option: string]: unknown;
|
|
@@ -24,3 +29,9 @@ export declare const modelFile = "schema.json";
|
|
|
24
29
|
export declare function parseConfig(text: string): Config;
|
|
25
30
|
export declare function defaultConfigText(type: string): string;
|
|
26
31
|
export declare function resolveConnection(value: string | undefined, env: Record<string, string | undefined>): string | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* The connection queries run with: the source's reader, a login of its own. Refused when it is missing (exit
|
|
34
|
+
* 2, with what to add), when it names the same variable as the extract's connection, or when the two
|
|
35
|
+
* variables hold the same string, since then a query would run with a login that may write.
|
|
36
|
+
*/
|
|
37
|
+
export declare function resolveReader(source: SourceConfig, env: Record<string, string | undefined>): string;
|
package/dist/config.js
CHANGED
|
@@ -52,6 +52,8 @@ sources:
|
|
|
52
52
|
name: ${type === 'mssql' ? 'MyDatabase' : 'my-database'}
|
|
53
53
|
# The name of the environment variable holding the connection string. Never the string itself.
|
|
54
54
|
connection: \${${variable}}
|
|
55
|
+
# Optional: a read-only login of its own (db_datareader), for dbtour sql and the MCP query tool.
|
|
56
|
+
# reader: \${${variable}_READER}
|
|
55
57
|
# Descriptions, relations and groups the database cannot say. Optional.
|
|
56
58
|
notes: dbtour.notes.yaml
|
|
57
59
|
# What else render writes. explorer is the page: npm install --save-dev dbtour-addon-explorer
|
|
@@ -73,3 +75,25 @@ export function resolveConnection(value, env) {
|
|
|
73
75
|
}
|
|
74
76
|
return resolved;
|
|
75
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* The connection queries run with: the source's reader, a login of its own. Refused when it is missing (exit
|
|
80
|
+
* 2, with what to add), when it names the same variable as the extract's connection, or when the two
|
|
81
|
+
* variables hold the same string, since then a query would run with a login that may write.
|
|
82
|
+
*/
|
|
83
|
+
export function resolveReader(source, env) {
|
|
84
|
+
const example = `\${DBTOUR_${source.type.toUpperCase()}_READER}`;
|
|
85
|
+
if (typeof source.reader !== 'string' || source.reader === '') {
|
|
86
|
+
throw Object.assign(new Error(`Source ${source.name} has no reader, the login dbtour runs queries with: add "reader: ${example}" to it in ${configFile}, naming a login with db_datareader alone, and set that variable`), { exitCode: 2 });
|
|
87
|
+
}
|
|
88
|
+
const readerVariable = variablePattern.exec(source.reader)?.[1];
|
|
89
|
+
const connectionVariable = typeof source.connection === 'string' ? variablePattern.exec(source.connection)?.[1] : undefined;
|
|
90
|
+
if (readerVariable !== undefined && readerVariable === connectionVariable) {
|
|
91
|
+
throw new Error(`reader names the same variable as connection (${readerVariable}): a reader is a login of its own, with db_datareader alone, so a query can never write`);
|
|
92
|
+
}
|
|
93
|
+
const reader = resolveConnection(source.reader, env);
|
|
94
|
+
const connection = connectionVariable === undefined ? undefined : env[connectionVariable];
|
|
95
|
+
if (connection !== undefined && connection === reader) {
|
|
96
|
+
throw new Error(`reader resolves to the same connection string as connection: give ${readerVariable} a login of its own, with db_datareader alone`);
|
|
97
|
+
}
|
|
98
|
+
return reader;
|
|
99
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from './model.ts';
|
|
2
2
|
export { parseNotes, applyNotes, type Notes, type TableNotes, type RelationNote, type Applied } from './notes.ts';
|
|
3
|
-
export { parseConfig, defaultConfigText, resolveConnection, configFile, modelFile, type Config, type SourceConfig, type AddonConfig } from './config.ts';
|
|
4
|
-
export { loadSource, sourceModuleName, loadAddon, addonModuleName, type Source, type Extract, type Importer, type Addon, type Files } from './plugins.ts';
|
|
3
|
+
export { parseConfig, defaultConfigText, resolveConnection, resolveReader, configFile, modelFile, type Config, type SourceConfig, type AddonConfig } from './config.ts';
|
|
4
|
+
export { loadSource, sourceModuleName, loadAddon, addonModuleName, type Source, type Extract, type Run, type Importer, type Addon, type Files } from './plugins.ts';
|
|
5
|
+
export { checkQuery, clampLimit, resultText, formatCell, defaultLimit, maxLimit, defaultTimeoutMs, maxResultBytes, type QueryResult, type RunOptions, type Checked } from './sql.ts';
|
|
5
6
|
export { renderMarkdown, renderTableMarkdown, driverName, wholeDiagramLimit } from './render/markdown.ts';
|
|
6
7
|
export { erDiagram, neighbourhood, entityName } from './render/mermaid.ts';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from "./model.js";
|
|
2
2
|
export { parseNotes, applyNotes } from "./notes.js";
|
|
3
|
-
export { parseConfig, defaultConfigText, resolveConnection, configFile, modelFile } from "./config.js";
|
|
3
|
+
export { parseConfig, defaultConfigText, resolveConnection, resolveReader, configFile, modelFile } from "./config.js";
|
|
4
4
|
export { loadSource, sourceModuleName, loadAddon, addonModuleName } from "./plugins.js";
|
|
5
|
+
export { checkQuery, clampLimit, resultText, formatCell, defaultLimit, maxLimit, defaultTimeoutMs, maxResultBytes } from "./sql.js";
|
|
5
6
|
export { renderMarkdown, renderTableMarkdown, driverName, wholeDiagramLimit } from "./render/markdown.js";
|
|
6
7
|
export { erDiagram, neighbourhood, entityName } from "./render/mermaid.js";
|
package/dist/mcp.d.ts
CHANGED
|
@@ -5,6 +5,11 @@ export interface Context {
|
|
|
5
5
|
/** The model with its notes, read again when the files change; throws when there is none. */
|
|
6
6
|
model: () => Model;
|
|
7
7
|
version: string;
|
|
8
|
+
/**
|
|
9
|
+
* Runs a query through a source's reader. Given only when a source has one, and only then is the query
|
|
10
|
+
* tool offered: the other tools read the model, this one reads rows.
|
|
11
|
+
*/
|
|
12
|
+
run?: (sql: string, source: string | undefined, limit: number | undefined) => Promise<ToolResult>;
|
|
8
13
|
}
|
|
9
14
|
export interface ToolResult {
|
|
10
15
|
content: {
|
|
@@ -28,6 +33,22 @@ export declare const toolList: {
|
|
|
28
33
|
};
|
|
29
34
|
}[];
|
|
30
35
|
/** 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
|
-
|
|
36
|
+
export declare function answerLine(line: string, context: Context): string | undefined | Promise<string | undefined>;
|
|
37
|
+
/** The answer to one request; a promise only for a query, the one tool that waits on the database. */
|
|
38
|
+
export declare function answer(message: unknown, context: Context): object | undefined | Promise<object>;
|
|
39
|
+
/** Offered beside the others only when a source has a reader; see Context.run. */
|
|
40
|
+
export declare const queryTool: {
|
|
41
|
+
name: string;
|
|
42
|
+
title: string;
|
|
43
|
+
description: string;
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: string;
|
|
46
|
+
properties: Record<string, unknown>;
|
|
47
|
+
required: string[];
|
|
48
|
+
};
|
|
49
|
+
annotations: {
|
|
50
|
+
readOnlyHint: boolean;
|
|
51
|
+
openWorldHint: boolean;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
33
54
|
export declare function callTool(model: Model, name: string, args: Record<string, unknown>): ToolResult;
|
package/dist/mcp.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
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
|
+
import { checkQuery, defaultLimit, maxLimit } from "./sql.js";
|
|
8
9
|
// The versions this server speaks, newest first. It uses only initialize, ping, tools/list and tools/call,
|
|
9
10
|
// which none of them changed, so it agrees to whichever the client asks for and offers the newest otherwise.
|
|
10
11
|
export const protocolVersions = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
|
|
@@ -77,8 +78,11 @@ export function answerLine(line, context) {
|
|
|
77
78
|
return JSON.stringify(failure(null, -32700, 'That line is not JSON: send one JSON-RPC message per line.'));
|
|
78
79
|
}
|
|
79
80
|
const response = answer(message, context);
|
|
81
|
+
if (response instanceof Promise)
|
|
82
|
+
return response.then((r) => JSON.stringify(r));
|
|
80
83
|
return response === undefined ? undefined : JSON.stringify(response);
|
|
81
84
|
}
|
|
85
|
+
/** The answer to one request; a promise only for a query, the one tool that waits on the database. */
|
|
82
86
|
export function answer(message, context) {
|
|
83
87
|
if (typeof message !== 'object' || message === null || Array.isArray(message))
|
|
84
88
|
return failure(null, -32600, 'Send one JSON-RPC request as an object; batches are not accepted.');
|
|
@@ -109,12 +113,15 @@ export function answer(message, context) {
|
|
|
109
113
|
case 'ping':
|
|
110
114
|
return success(id, {});
|
|
111
115
|
case 'tools/list':
|
|
112
|
-
return success(id, { tools: toolList });
|
|
116
|
+
return success(id, { tools: context.run ? [...toolList, queryTool] : toolList });
|
|
113
117
|
case 'tools/call': {
|
|
114
118
|
const name = params.name;
|
|
115
|
-
|
|
116
|
-
|
|
119
|
+
const offered = context.run ? [...toolNames, queryTool.name] : toolNames;
|
|
120
|
+
if (typeof name !== 'string' || !offered.includes(name))
|
|
121
|
+
return failure(id, -32602, `There is no tool "${String(name)}": the tools are ${offered.join(', ')}.`);
|
|
117
122
|
const args = typeof params.arguments === 'object' && params.arguments !== null ? params.arguments : {};
|
|
123
|
+
if (name === queryTool.name)
|
|
124
|
+
return callQuery(context.run, args).then((result) => success(id, result));
|
|
118
125
|
let model;
|
|
119
126
|
try {
|
|
120
127
|
model = context.model();
|
|
@@ -128,6 +135,37 @@ export function answer(message, context) {
|
|
|
128
135
|
return failure(id, -32601, `dbtour does not answer ${request.method}: it offers tools only.`);
|
|
129
136
|
}
|
|
130
137
|
}
|
|
138
|
+
/** Offered beside the others only when a source has a reader; see Context.run. */
|
|
139
|
+
export const queryTool = {
|
|
140
|
+
name: 'query',
|
|
141
|
+
title: 'Query',
|
|
142
|
+
description: `Run one SELECT (or WITH … SELECT) through the project's read-only login, inside a transaction that is always rolled back, and get the rows as a table, ${defaultLimit} at most unless you ask for fewer or more (up to ${maxLimit}). Read every table you name with the table tool first. The rows go to the model provider your agent uses: leave out columns that are masked, encrypted or carry a sensitivity label unless the question needs them.`,
|
|
143
|
+
inputSchema: {
|
|
144
|
+
type: 'object',
|
|
145
|
+
properties: {
|
|
146
|
+
sql: { type: 'string', description: 'One SELECT, or a WITH followed by a SELECT, in the database\'s dialect.' },
|
|
147
|
+
source: { type: 'string', description: 'The source to run it in, by its name in dbtour.yaml, when more than one has a reader.' },
|
|
148
|
+
limit: { type: 'number', description: `The most rows to return; ${defaultLimit} if not given.` },
|
|
149
|
+
},
|
|
150
|
+
required: ['sql'],
|
|
151
|
+
},
|
|
152
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
153
|
+
};
|
|
154
|
+
/** A query refused here never reaches the source; one that runs comes back as the source's answer. */
|
|
155
|
+
async function callQuery(run, args) {
|
|
156
|
+
if (typeof args.sql !== 'string' || args.sql.trim() === '')
|
|
157
|
+
return problem('query needs sql: one SELECT, like SELECT TOP (10) * FROM dbo.Orders.');
|
|
158
|
+
const checked = checkQuery(args.sql);
|
|
159
|
+
if (!checked.ok)
|
|
160
|
+
return problem(checked.reason);
|
|
161
|
+
// Whatever goes wrong in the database is this call's failure, not the server's: the next request is answered.
|
|
162
|
+
try {
|
|
163
|
+
return await run(checked.sql, typeof args.source === 'string' ? args.source : undefined, typeof args.limit === 'number' ? args.limit : undefined);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
return problem(error instanceof Error ? error.message : String(error));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
131
169
|
function success(id, result) {
|
|
132
170
|
return { jsonrpc: '2.0', id, result };
|
|
133
171
|
}
|
package/dist/plugins.d.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import type { AddonConfig, SourceConfig } from './config.ts';
|
|
2
2
|
import type { Api, Database, Model } from './model.ts';
|
|
3
|
+
import type { QueryResult, RunOptions } from './sql.ts';
|
|
3
4
|
export type Extract = (config: SourceConfig) => Promise<Database | Api>;
|
|
5
|
+
/**
|
|
6
|
+
* Runs one query dbtour has already checked, with `config.connection` set to the source's reader: read-only,
|
|
7
|
+
* inside a transaction the source always rolls back, at most `limit` rows, cancelled after `timeoutMs`.
|
|
8
|
+
*/
|
|
9
|
+
export type Run = (config: SourceConfig, sql: string, options: RunOptions) => Promise<QueryResult>;
|
|
4
10
|
export interface Source {
|
|
5
11
|
extract: Extract;
|
|
12
|
+
/** Absent from a source that cannot run queries, or from one published before it could. */
|
|
13
|
+
run?: Run;
|
|
6
14
|
}
|
|
7
15
|
export type Importer = (specifier: string) => Promise<unknown>;
|
|
8
16
|
export declare function sourceModuleName(source: SourceConfig): string;
|
package/dist/plugins.js
CHANGED
|
@@ -22,7 +22,8 @@ export async function loadSource(source, importer = (s) => import(__rewriteRelat
|
|
|
22
22
|
const extract = loaded?.extract;
|
|
23
23
|
if (typeof extract !== 'function')
|
|
24
24
|
throw new Error(`${name} does not export extract(config), so it is not a dbtour source`);
|
|
25
|
-
|
|
25
|
+
const run = loaded.run;
|
|
26
|
+
return typeof run === 'function' ? { extract: extract, run: run } : { extract: extract };
|
|
26
27
|
}
|
|
27
28
|
export function addonModuleName(addon) {
|
|
28
29
|
return addon.module ?? `dbtour-addon-${addon.type}`;
|
package/dist/serve.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { QueryResult } from './sql.ts';
|
|
2
|
+
export interface Served {
|
|
3
|
+
/** The folder render wrote: index.html and the Markdown beside it. */
|
|
4
|
+
root: string;
|
|
5
|
+
/** 0 for any free port. */
|
|
6
|
+
port: number;
|
|
7
|
+
/** The session's secret, given to the page in a meta tag and required on every query. */
|
|
8
|
+
token: string;
|
|
9
|
+
/** The sources, and whether each has a reader: what the page shows, never a connection string. */
|
|
10
|
+
sources: {
|
|
11
|
+
name: string;
|
|
12
|
+
reader: boolean;
|
|
13
|
+
}[];
|
|
14
|
+
/** Runs one query through a source's reader; absent when no source has one. */
|
|
15
|
+
run?: (sql: string, source: string | undefined, limit: number | undefined) => Promise<QueryResult & {
|
|
16
|
+
source: string;
|
|
17
|
+
}>;
|
|
18
|
+
}
|
|
19
|
+
/** The address it listens on: this machine alone. */
|
|
20
|
+
export declare const serveHost = "127.0.0.1";
|
|
21
|
+
export declare function startServer(options: Served): Promise<{
|
|
22
|
+
url: string;
|
|
23
|
+
close: () => Promise<void>;
|
|
24
|
+
}>;
|
package/dist/serve.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// dbtour serve: the rendered folder on this machine, and the one door the page has to the database. The page
|
|
2
|
+
// runs a query by posting it here, and it runs as dbtour sql does: checked, through the source's reader, in a
|
|
3
|
+
// transaction that is rolled back. Only this machine can reach it (127.0.0.1), and only this server's own
|
|
4
|
+
// page can ask: the request must carry the session's token, which the page is given when served, and say it
|
|
5
|
+
// comes from this server (Host and Origin), so another website, or a name rebound to this address, cannot.
|
|
6
|
+
import { createServer } from 'node:http';
|
|
7
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
8
|
+
import { extname, join, normalize, resolve, sep } from 'node:path';
|
|
9
|
+
/** The address it listens on: this machine alone. */
|
|
10
|
+
export const serveHost = '127.0.0.1';
|
|
11
|
+
/** A query and its options are a few lines; anything past this is not one, and is refused unread. */
|
|
12
|
+
const maxBodyBytes = 100 * 1024;
|
|
13
|
+
const types = {
|
|
14
|
+
'.html': 'text/html; charset=utf-8',
|
|
15
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
16
|
+
'.json': 'application/json; charset=utf-8',
|
|
17
|
+
'.svg': 'image/svg+xml',
|
|
18
|
+
'.png': 'image/png',
|
|
19
|
+
'.css': 'text/css; charset=utf-8',
|
|
20
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
21
|
+
};
|
|
22
|
+
export async function startServer(options) {
|
|
23
|
+
let origins = new Set();
|
|
24
|
+
let hosts = new Set();
|
|
25
|
+
const server = createServer((request, response) => {
|
|
26
|
+
handle(request, response, options, hosts, origins).catch((error) => send(response, 500, { error: error instanceof Error ? error.message : String(error) }));
|
|
27
|
+
});
|
|
28
|
+
await new Promise((done, fail) => {
|
|
29
|
+
server.once('error', fail);
|
|
30
|
+
server.listen(options.port, serveHost, () => done());
|
|
31
|
+
});
|
|
32
|
+
const port = server.address().port;
|
|
33
|
+
hosts = new Set([`${serveHost}:${port}`, `localhost:${port}`]);
|
|
34
|
+
origins = new Set([...hosts].map((h) => `http://${h}`));
|
|
35
|
+
return {
|
|
36
|
+
url: `http://${serveHost}:${port}`,
|
|
37
|
+
close: () => new Promise((done) => server.close(() => done())),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
async function handle(request, response, options, hosts, origins) {
|
|
41
|
+
// Every route, not only the query: the page carries the token and the model, and the Markdown and
|
|
42
|
+
// schema.json are the schema, none of it for a site whose name was rebound to this address.
|
|
43
|
+
if (!hosts.has(request.headers.host ?? '')) {
|
|
44
|
+
return send(response, 403, { error: 'Open the page at the address dbtour serve printed: this server answers only to its own name.' });
|
|
45
|
+
}
|
|
46
|
+
const path = decodeURIComponent(new URL(request.url ?? '/', 'http://local').pathname);
|
|
47
|
+
if (path === '/api/query')
|
|
48
|
+
return query(request, response, options, hosts, origins);
|
|
49
|
+
if (request.method !== 'GET' && request.method !== 'HEAD')
|
|
50
|
+
return send(response, 405, { error: 'Only the page and its files are here: GET them.' });
|
|
51
|
+
const file = resolve(options.root, normalize(`.${path === '/' ? '/index.html' : path}`));
|
|
52
|
+
// Nothing outside the rendered folder, however the path is written.
|
|
53
|
+
if (file !== resolve(options.root) && !file.startsWith(resolve(options.root) + sep))
|
|
54
|
+
return send(response, 404, { error: 'Not here.' });
|
|
55
|
+
if (!existsSync(file) || !statSync(file).isFile())
|
|
56
|
+
return send(response, 404, { error: 'Not here.' });
|
|
57
|
+
let body = readFileSync(file);
|
|
58
|
+
if (file === join(resolve(options.root), 'index.html'))
|
|
59
|
+
body = withServe(body.toString('utf8'), options);
|
|
60
|
+
response.writeHead(200, { 'content-type': types[extname(file)] ?? 'application/octet-stream', 'cache-control': 'no-store' });
|
|
61
|
+
response.end(request.method === 'HEAD' ? undefined : body);
|
|
62
|
+
}
|
|
63
|
+
/** The page, told it is served: the token to send with a query, and which sources can run one. */
|
|
64
|
+
function withServe(html, options) {
|
|
65
|
+
const value = JSON.stringify({ token: options.token, sources: options.sources }).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
66
|
+
const meta = `<meta name="dbtour-serve" content="${value}">`;
|
|
67
|
+
return html.includes('</head>') ? html.replace('</head>', `${meta}</head>`) : meta + html;
|
|
68
|
+
}
|
|
69
|
+
async function query(request, response, options, hosts, origins) {
|
|
70
|
+
if (request.method !== 'POST')
|
|
71
|
+
return send(response, 405, { error: 'POST a query: {"sql": "SELECT …"}.' });
|
|
72
|
+
// The page's own requests name this server; anything else is another site, or a name rebound to this address.
|
|
73
|
+
const origin = request.headers.origin;
|
|
74
|
+
if (!hosts.has(request.headers.host ?? '') || (origin !== undefined && !origins.has(origin)) || request.headers['x-dbtour-token'] !== options.token) {
|
|
75
|
+
return send(response, 403, { error: 'Only the page dbtour serve gave out can run a query here: open it again from the address dbtour serve printed.' });
|
|
76
|
+
}
|
|
77
|
+
const text = await body(request);
|
|
78
|
+
if (text === undefined)
|
|
79
|
+
return send(response, 413, { error: 'That is more than a query: send one SELECT.' });
|
|
80
|
+
let asked;
|
|
81
|
+
try {
|
|
82
|
+
asked = JSON.parse(text);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
asked = undefined;
|
|
86
|
+
}
|
|
87
|
+
if (typeof asked !== 'object' || asked === null || Array.isArray(asked) || typeof asked.sql !== 'string') {
|
|
88
|
+
return send(response, 400, { error: 'Send {"sql": "SELECT …"}, with source and limit if you want them.' });
|
|
89
|
+
}
|
|
90
|
+
const { sql, source, limit } = asked;
|
|
91
|
+
if (!options.run) {
|
|
92
|
+
return send(response, 422, { error: 'This page can be read, but no source has a reader, so a query cannot run here: add reader: ${DBTOUR_MSSQL_READER} to a source in dbtour.yaml and start dbtour serve again.' });
|
|
93
|
+
}
|
|
94
|
+
const started = Date.now();
|
|
95
|
+
try {
|
|
96
|
+
const result = await options.run(sql, typeof source === 'string' ? source : undefined, typeof limit === 'number' ? limit : undefined);
|
|
97
|
+
send(response, 200, { ...result, ms: Date.now() - started });
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
send(response, 422, { error: error instanceof Error ? error.message : String(error) });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The request's body, or nothing when it is larger than any query. Past the limit the rest is read and
|
|
105
|
+
* dropped, not kept, so the connection stays open for the 413 that says why.
|
|
106
|
+
*/
|
|
107
|
+
function body(request) {
|
|
108
|
+
return new Promise((done, fail) => {
|
|
109
|
+
const chunks = [];
|
|
110
|
+
let size = 0;
|
|
111
|
+
request.on('data', (chunk) => {
|
|
112
|
+
size += chunk.length;
|
|
113
|
+
if (size <= maxBodyBytes)
|
|
114
|
+
chunks.push(chunk);
|
|
115
|
+
});
|
|
116
|
+
request.on('end', () => done(size > maxBodyBytes ? undefined : Buffer.concat(chunks).toString('utf8')));
|
|
117
|
+
request.on('error', fail);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/** JSON, with a date as ISO and bytes as hex, as dbtour sql --json writes them. */
|
|
121
|
+
function send(response, status, value) {
|
|
122
|
+
const text = JSON.stringify(value, (_key, v) => {
|
|
123
|
+
if (v && typeof v === 'object' && v.type === 'Buffer')
|
|
124
|
+
return `0x${Buffer.from(v.data).toString('hex')}`;
|
|
125
|
+
return v;
|
|
126
|
+
});
|
|
127
|
+
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
128
|
+
response.end(text);
|
|
129
|
+
}
|
package/dist/sql.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** What a query returned: its columns in order, its rows as arrays, and whether the limit stopped it. */
|
|
2
|
+
export interface QueryResult {
|
|
3
|
+
columns: string[];
|
|
4
|
+
rows: unknown[][];
|
|
5
|
+
/** Not every row came back: the limit stopped it, or the rows outgrew the byte budget (`reason`). */
|
|
6
|
+
truncated: boolean;
|
|
7
|
+
limit: number;
|
|
8
|
+
reason?: 'limit' | 'size';
|
|
9
|
+
}
|
|
10
|
+
export interface RunOptions {
|
|
11
|
+
limit: number;
|
|
12
|
+
timeoutMs: number;
|
|
13
|
+
}
|
|
14
|
+
/** Enough rows to see what a query returns without spending an agent's context on them. */
|
|
15
|
+
export declare const defaultLimit = 100;
|
|
16
|
+
/** The most one answer carries, however the limit is set: past it, a query wants narrowing, not paging. */
|
|
17
|
+
export declare const maxLimit = 5000;
|
|
18
|
+
/** How long a query may run before it is cancelled: a read that takes longer wants an index or a filter. */
|
|
19
|
+
export declare const defaultTimeoutMs = 30000;
|
|
20
|
+
/**
|
|
21
|
+
* The most a result holds in memory, however few its rows: a handful of varchar(max) cells can be larger than
|
|
22
|
+
* any agent can read, and an MCP server that buffered them would stop answering. Past it, the query wants
|
|
23
|
+
* fewer or narrower columns.
|
|
24
|
+
*/
|
|
25
|
+
export declare const maxResultBytes: number;
|
|
26
|
+
export declare function clampLimit(limit: number | undefined): number;
|
|
27
|
+
export type Checked = {
|
|
28
|
+
ok: true;
|
|
29
|
+
sql: string;
|
|
30
|
+
} | {
|
|
31
|
+
ok: false;
|
|
32
|
+
reason: string;
|
|
33
|
+
};
|
|
34
|
+
export declare function checkQuery(sql: string): Checked;
|
|
35
|
+
/** One cell as text for a table: NULL, dates as ISO, bytes as hex, one line, no stray column separator. */
|
|
36
|
+
export declare function formatCell(value: unknown): string;
|
|
37
|
+
/** The rows as a Markdown table, readable in a terminal and by an agent, and how many came back. */
|
|
38
|
+
export declare function resultText(result: QueryResult): string;
|
package/dist/sql.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Running a query someone (or an agent) wrote, safely enough to hand to an agent. The safety is the source's:
|
|
2
|
+
// a read-only login of its own and a transaction that is always rolled back. This is the second line, read
|
|
3
|
+
// before anything reaches the database: one SELECT or WITH, nothing that writes, runs code, ends the transaction
|
|
4
|
+
// or reaches out of the database, and a row limit. It reads T-SQL as SQL Server does for strings, names and
|
|
5
|
+
// comments, so a word inside 'text', [a name] or a comment is not taken for a statement, and it does not trust
|
|
6
|
+
// semicolons, which T-SQL does not need between statements. It cannot see what a name points to (a synonym,
|
|
7
|
+
// another database's table, a linked server): the reader login's permissions are that boundary. Pure, so it is
|
|
8
|
+
// tested in Node.
|
|
9
|
+
/** Enough rows to see what a query returns without spending an agent's context on them. */
|
|
10
|
+
export const defaultLimit = 100;
|
|
11
|
+
/** The most one answer carries, however the limit is set: past it, a query wants narrowing, not paging. */
|
|
12
|
+
export const maxLimit = 5000;
|
|
13
|
+
/** How long a query may run before it is cancelled: a read that takes longer wants an index or a filter. */
|
|
14
|
+
export const defaultTimeoutMs = 30_000;
|
|
15
|
+
/**
|
|
16
|
+
* The most a result holds in memory, however few its rows: a handful of varchar(max) cells can be larger than
|
|
17
|
+
* any agent can read, and an MCP server that buffered them would stop answering. Past it, the query wants
|
|
18
|
+
* fewer or narrower columns.
|
|
19
|
+
*/
|
|
20
|
+
export const maxResultBytes = 10 * 1024 * 1024;
|
|
21
|
+
export function clampLimit(limit) {
|
|
22
|
+
if (limit === undefined || !Number.isFinite(limit))
|
|
23
|
+
return defaultLimit;
|
|
24
|
+
return Math.min(maxLimit, Math.max(1, Math.floor(limit)));
|
|
25
|
+
}
|
|
26
|
+
// Words that write, run code, change the schema or permissions, or reach out of the database. SELECT … INTO
|
|
27
|
+
// writes a table, so INTO is among them. A column with one of these names is written [bracketed].
|
|
28
|
+
const refusedWords = new Set([
|
|
29
|
+
'INSERT', 'UPDATE', 'DELETE', 'MERGE', 'TRUNCATE', 'DROP', 'ALTER', 'CREATE', 'GRANT', 'REVOKE', 'DENY',
|
|
30
|
+
'EXEC', 'EXECUTE', 'INTO', 'OPENROWSET', 'OPENDATASOURCE', 'OPENQUERY', 'BULK', 'BACKUP', 'RESTORE',
|
|
31
|
+
'SHUTDOWN', 'KILL', 'DBCC', 'WAITFOR', 'USE', 'GO', 'RECONFIGURE', 'DECLARE', 'SET',
|
|
32
|
+
// Transaction control: a COMMIT would keep what the rollback is there to undo.
|
|
33
|
+
'BEGIN', 'COMMIT', 'ROLLBACK', 'SAVE', 'TRAN', 'TRANSACTION',
|
|
34
|
+
]);
|
|
35
|
+
/** Joins two SELECTs into one query; any other second SELECT at the top is a second statement. */
|
|
36
|
+
const setOperators = new Set(['UNION', 'ALL', 'EXCEPT', 'INTERSECT']);
|
|
37
|
+
/**
|
|
38
|
+
* The code with every string, quoted name and comment blanked to spaces, so what is left is keywords and plain
|
|
39
|
+
* names; or what is left open. Positions are kept, so a semicolon found here is one in the query.
|
|
40
|
+
*/
|
|
41
|
+
function blank(sql) {
|
|
42
|
+
let code = '';
|
|
43
|
+
let i = 0;
|
|
44
|
+
const closing = (end, what) => {
|
|
45
|
+
// A quote is escaped by doubling it: 'it''s', [a]]b], "a""b".
|
|
46
|
+
for (let j = i + 1; j < sql.length; j++) {
|
|
47
|
+
if (sql[j] === end) {
|
|
48
|
+
if (sql[j + 1] === end) {
|
|
49
|
+
j++;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
code += ' '.repeat(j + 1 - i);
|
|
53
|
+
i = j + 1;
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return what;
|
|
58
|
+
};
|
|
59
|
+
while (i < sql.length) {
|
|
60
|
+
const c = sql[i];
|
|
61
|
+
if (c === "'" || c === '"' || c === '[') {
|
|
62
|
+
const open = closing(c === '[' ? ']' : c, c === "'" ? 'a string' : 'a quoted name');
|
|
63
|
+
if (open)
|
|
64
|
+
return { open };
|
|
65
|
+
}
|
|
66
|
+
else if (sql.startsWith('--', i)) {
|
|
67
|
+
// A carriage return ends the comment as a line feed does, as SQL Server reads it.
|
|
68
|
+
const rest = sql.slice(i).search(/[\r\n]/);
|
|
69
|
+
const stop = rest === -1 ? sql.length : i + rest;
|
|
70
|
+
code += ' '.repeat(stop - i);
|
|
71
|
+
i = stop;
|
|
72
|
+
}
|
|
73
|
+
else if (sql.startsWith('/*', i)) {
|
|
74
|
+
// Block comments nest in T-SQL.
|
|
75
|
+
let depth = 0;
|
|
76
|
+
let j = i;
|
|
77
|
+
while (j < sql.length) {
|
|
78
|
+
if (sql.startsWith('/*', j)) {
|
|
79
|
+
depth++;
|
|
80
|
+
j += 2;
|
|
81
|
+
}
|
|
82
|
+
else if (sql.startsWith('*/', j)) {
|
|
83
|
+
depth--;
|
|
84
|
+
j += 2;
|
|
85
|
+
if (depth === 0)
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
else
|
|
89
|
+
j++;
|
|
90
|
+
}
|
|
91
|
+
if (depth > 0)
|
|
92
|
+
return { open: 'a comment' };
|
|
93
|
+
code += ' '.repeat(j - i);
|
|
94
|
+
i = j;
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
code += c;
|
|
98
|
+
i++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { code };
|
|
102
|
+
}
|
|
103
|
+
export function checkQuery(sql) {
|
|
104
|
+
const blanked = blank(sql);
|
|
105
|
+
if ('open' in blanked)
|
|
106
|
+
return { ok: false, reason: `The query leaves ${blanked.open} open: close it, then run it again.` };
|
|
107
|
+
const { code } = blanked;
|
|
108
|
+
if (code.trim() === '')
|
|
109
|
+
return { ok: false, reason: 'There is no query to run: write one SELECT, or a WITH followed by a SELECT.' };
|
|
110
|
+
// One trailing semicolon ends the statement; any other starts a second one.
|
|
111
|
+
const semicolons = [...code.matchAll(/;/g)].map((m) => m.index);
|
|
112
|
+
const last = semicolons[semicolons.length - 1];
|
|
113
|
+
if (semicolons.length > 1 || (last !== undefined && code.slice(last + 1).trim() !== '')) {
|
|
114
|
+
return { ok: false, reason: 'dbtour runs one statement at a time: send a single SELECT, without a second statement after a semicolon.' };
|
|
115
|
+
}
|
|
116
|
+
// Each word with how deep in parentheses it sits: a SELECT inside them is a subquery or a CTE's body.
|
|
117
|
+
const words = [];
|
|
118
|
+
let depth = 0;
|
|
119
|
+
for (const m of code.matchAll(/[A-Za-z_@#][\w@#$]*|[()]/g)) {
|
|
120
|
+
if (m[0] === '(')
|
|
121
|
+
depth++;
|
|
122
|
+
else if (m[0] === ')')
|
|
123
|
+
depth--;
|
|
124
|
+
else
|
|
125
|
+
words.push({ text: m[0], upper: m[0].toUpperCase(), depth });
|
|
126
|
+
}
|
|
127
|
+
if (words.length === 0)
|
|
128
|
+
return { ok: false, reason: 'There is no query to run: write one SELECT, or a WITH followed by a SELECT.' };
|
|
129
|
+
if (words[0].upper !== 'SELECT' && words[0].upper !== 'WITH') {
|
|
130
|
+
return { ok: false, reason: `The query starts with ${words[0].text}: dbtour runs only a SELECT, or a WITH followed by a SELECT.` };
|
|
131
|
+
}
|
|
132
|
+
const word = words.find((w) => refusedWords.has(w.upper));
|
|
133
|
+
if (word) {
|
|
134
|
+
const why = word.upper === 'INTO'
|
|
135
|
+
? 'SELECT … INTO writes a table'
|
|
136
|
+
: ['BEGIN', 'COMMIT', 'ROLLBACK', 'SAVE', 'TRAN', 'TRANSACTION'].includes(word.upper)
|
|
137
|
+
? `${word.upper} controls the transaction dbtour rolls back`
|
|
138
|
+
: `${word.upper} writes, runs code or reaches outside the database`;
|
|
139
|
+
return { ok: false, reason: `The query uses ${word.text}, and ${why}: dbtour runs reads only. If ${word.text} is a column or a table, write it as [${word.text}].` };
|
|
140
|
+
}
|
|
141
|
+
// NEXT VALUE FOR moves a sequence on, and a rollback does not move it back. FETCH NEXT, for paging, is a read.
|
|
142
|
+
const sequence = words.findIndex((w, i) => w.upper === 'NEXT' && words[i + 1]?.upper === 'VALUE' && words[i + 2]?.upper === 'FOR');
|
|
143
|
+
if (sequence !== -1)
|
|
144
|
+
return { ok: false, reason: 'The query uses NEXT VALUE FOR, which moves a sequence on even when the transaction is rolled back: dbtour runs reads only.' };
|
|
145
|
+
// Without semicolons, a second SELECT at the top is a second statement, unless a set operator joins the two.
|
|
146
|
+
const top = words.filter((w) => w.depth === 0);
|
|
147
|
+
const second = top.findIndex((w, i) => w.upper === 'SELECT' && i > 0 && top.slice(0, i).some((p) => p.upper === 'SELECT') && !setOperators.has(top[i - 1].upper));
|
|
148
|
+
if (second !== -1) {
|
|
149
|
+
return { ok: false, reason: 'The query has a second SELECT that UNION, EXCEPT or INTERSECT does not join to the first, which T-SQL runs as a second statement: dbtour runs one statement at a time.' };
|
|
150
|
+
}
|
|
151
|
+
return { ok: true, sql: (last === undefined ? sql : sql.slice(0, last)).trim() };
|
|
152
|
+
}
|
|
153
|
+
/** Bytes shown before a binary value is cut short: enough to tell two apart. */
|
|
154
|
+
const bytesShown = 32;
|
|
155
|
+
/** Characters of text shown in a cell: a long value is cut, with … after it. */
|
|
156
|
+
const textShown = 200;
|
|
157
|
+
/** One cell as text for a table: NULL, dates as ISO, bytes as hex, one line, no stray column separator. */
|
|
158
|
+
export function formatCell(value) {
|
|
159
|
+
if (value === null || value === undefined)
|
|
160
|
+
return 'NULL';
|
|
161
|
+
if (value instanceof Date)
|
|
162
|
+
return value.toISOString();
|
|
163
|
+
if (value instanceof Uint8Array) {
|
|
164
|
+
const hex = Buffer.from(value.subarray(0, bytesShown)).toString('hex');
|
|
165
|
+
return `0x${hex}${value.length > bytesShown ? '…' : ''}`;
|
|
166
|
+
}
|
|
167
|
+
const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
168
|
+
const line = text.replace(/\r?\n/g, '↵').replace(/\|/g, '\\|');
|
|
169
|
+
return line.length > textShown ? `${line.slice(0, textShown)}…` : line;
|
|
170
|
+
}
|
|
171
|
+
/** The rows as a Markdown table, readable in a terminal and by an agent, and how many came back. */
|
|
172
|
+
export function resultText(result) {
|
|
173
|
+
const header = `| ${result.columns.map(formatCell).join(' | ')} |\n|${result.columns.map(() => '---').join('|')}|`;
|
|
174
|
+
const rows = result.rows.map((row) => `| ${row.map(formatCell).join(' | ')} |`);
|
|
175
|
+
const n = result.rows.length;
|
|
176
|
+
const counted = (count) => `${count} ${count === 1 ? 'row' : 'rows'}`;
|
|
177
|
+
const said = result.truncated && result.reason === 'size'
|
|
178
|
+
? `The first ${counted(n)}: they came to more than ${maxResultBytes / (1024 * 1024)} MB. Select fewer or narrower columns.`
|
|
179
|
+
: result.truncated
|
|
180
|
+
? `The first ${n} rows: the limit stopped it. Narrow the query, or raise the limit.`
|
|
181
|
+
: n === 0
|
|
182
|
+
? 'No rows.'
|
|
183
|
+
: `${counted(n)}.`;
|
|
184
|
+
return `${[header, ...rows].join('\n')}\n\n${said}`;
|
|
185
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dbtour",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -32,6 +32,10 @@
|
|
|
32
32
|
"./search": {
|
|
33
33
|
"types": "./dist/search.d.ts",
|
|
34
34
|
"default": "./dist/search.js"
|
|
35
|
+
},
|
|
36
|
+
"./sql": {
|
|
37
|
+
"types": "./dist/sql.d.ts",
|
|
38
|
+
"default": "./dist/sql.js"
|
|
35
39
|
}
|
|
36
40
|
},
|
|
37
41
|
"files": [
|