dsh-plugin-dbgraph-catalog 0.0.0-stage → 0.1.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.
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Engine-neutral plumbing every dialect in this package shares.
3
+ *
4
+ * Four decisions live here so each dialect follows them rather than restating them:
5
+ *
6
+ * - **A read-only execution refuses anything that is not obviously a read.** The first keyword is
7
+ * parsed after stripping leading whitespace and SQL comments; `SELECT`/`SHOW`/`DESCRIBE`/`EXPLAIN`
8
+ * are admitted, everything else throws {@link DBGRAPH_READ_ONLY_CODE} when the caller asked for a
9
+ * read-only run. Keyword filtering is a policy gate, not a sandbox — a dialect that can open its
10
+ * store read-only (SQLite) does so as well, because a keyword list cannot see inside a function
11
+ * call. A statement whose first word is a read keyword is not enough on its own: an `EXPLAIN ANALYZE`
12
+ * executes the statement it claims to describe, and a `SELECT … INTO OUTFILE` writes a file on the
13
+ * server. {@link describeWriteChannel} names those channels and the gate refuses them too.
14
+ * - **Rows leave a dialect already JSON-safe.** `Date` becomes ISO text, binary becomes the hex
15
+ * marker {@link renderBinary} builds, `bigint` becomes text, and a nested object is normalized
16
+ * recursively. A transcript that persists a tool result must never choke on a value the driver
17
+ * handed back.
18
+ * - **Sensitive columns are masked inside `execute`**, not by the caller: the dialect is the only
19
+ * layer that sees both the result column names and the connection's masking configuration, and a
20
+ * masking rule that depends on a caller remembering to apply it is not a safety rule.
21
+ * - **A dialect owns a warm handle per connection id** (see `pool.ts`); the plugin's disposal closes
22
+ * them, so nothing here keeps a socket or a file descriptor past unload.
23
+ * @module dsh-plugin-dbgraph-catalog/support
24
+ */
25
+ import type { ConnectionConfig, DatabaseDialect, ExecuteLimits, Row } from 'dsh-plugin-dbgraph-service';
26
+ /**
27
+ * Code on the error thrown when a non-read statement is refused because the execution asked for
28
+ * read-only. The seam documents no code for this case; `DBGRAPH_UNAVAILABLE` would misreport a
29
+ * reachable database as unreachable, so the refusal gets its own name instead.
30
+ */
31
+ export declare const DBGRAPH_READ_ONLY_CODE = "DBGRAPH_READ_ONLY";
32
+ /**
33
+ * Code on the error thrown when a statement the dialect did send was rejected by the engine (bad
34
+ * syntax, missing privilege, a timeout). Distinct from {@link DBGRAPH_READ_ONLY_CODE}, which is this
35
+ * package refusing to send at all.
36
+ */
37
+ export declare const DBGRAPH_QUERY_FAILED_CODE = "DBGRAPH_QUERY_FAILED";
38
+ /** What a masked value becomes. */
39
+ export declare const REDACTED_VALUE = "[REDACTED]";
40
+ /** Bytes of a binary value rendered as hex before the marker abbreviates the rest. */
41
+ export declare const MAX_BINARY_PREVIEW_BYTES = 32;
42
+ /** A dialect plus the lifecycle the plugin needs to release its warm handles. */
43
+ export interface ManagedDialect extends DatabaseDialect {
44
+ /** Close every cached connection or pool. Safe to call twice; called on plugin disposal. */
45
+ close(): Promise<void>;
46
+ }
47
+ /**
48
+ * One thrown value's message, for embedding in a warning or a wrapper error.
49
+ * @param error - the thrown value, of any shape.
50
+ * @returns a non-empty, single-line-ish description.
51
+ */
52
+ export declare function messageOf(error: unknown): string;
53
+ /**
54
+ * The first SQL keyword of a statement, with leading whitespace and `--` / block comments skipped.
55
+ * @param query - the statement text.
56
+ * @returns the upper-cased first keyword, or `undefined` for an empty/comment-only statement.
57
+ */
58
+ export declare function firstStatementKeyword(query: string): string | undefined;
59
+ /**
60
+ * Whether a statement begins with one of the keywords the read-only gate admits.
61
+ * @param query - the statement text.
62
+ * @returns true when the first keyword is a read keyword.
63
+ */
64
+ export declare function isReadStatement(query: string): boolean;
65
+ /**
66
+ * Describe the write channel a statement hides behind a read keyword, if it has one.
67
+ *
68
+ * Two statement shapes begin like a read and are not one:
69
+ *
70
+ * - `EXPLAIN <word>` with anything but `SELECT` after it. `EXPLAIN ANALYZE UPDATE …` genuinely
71
+ * executes the update (MySQL runs the statement and reports its plan), so admitting it because its
72
+ * first word is `EXPLAIN` would let a read-only execution write rows.
73
+ * - `SELECT … INTO OUTFILE` / `INTO DUMPFILE`, which write a file on the database server even though
74
+ * the statement starts with `SELECT`.
75
+ *
76
+ * The check is lexical, like the keyword gate it extends: it names the channel rather than proving
77
+ * one, and it deliberately errs toward refusing. Comments are stripped first, but quoted text is
78
+ * kept, so a quoted phrase refuses a harmless statement rather than hiding a real clause.
79
+ * @param query - the statement text.
80
+ * @returns a human-readable description of the write channel, or `undefined` when there is none.
81
+ */
82
+ export declare function describeWriteChannel(query: string): string | undefined;
83
+ /**
84
+ * Enforce the read-only execution contract: when the caller asked for a read-only run, a statement
85
+ * that is not obviously a read is refused before it reaches the engine — and so is a read keyword
86
+ * that is only a disguise (see {@link describeWriteChannel}).
87
+ * @param query - the statement text.
88
+ * @param limits - the caller's execution limits.
89
+ * @throws DbgraphError with {@link DBGRAPH_READ_ONLY_CODE} when the statement is refused.
90
+ */
91
+ export declare function assertReadOnlyStatement(query: string, limits: ExecuteLimits): void;
92
+ /**
93
+ * Clamp a requested row cap into a usable integer.
94
+ * @param requested - the caller's value; NaN, negative and infinite values are corrected, not trusted.
95
+ * @returns a whole number of rows, never negative.
96
+ */
97
+ export declare function clampRowLimit(requested: number): number;
98
+ /**
99
+ * Throw when a call was aborted. Every dialect checks this before opening a connection, between
100
+ * objects while walking a catalog, and before it starts a statement.
101
+ * @param signal - the caller's abort signal, when one was supplied.
102
+ * @throws DbgraphError with `DBGRAPH_ABORTED` when the signal is already aborted.
103
+ */
104
+ export declare function throwIfAborted(signal: AbortSignal | undefined): void;
105
+ /**
106
+ * Render a binary value as a bounded hex marker. The length is always reported, so a truncated
107
+ * preview never masquerades as the whole value.
108
+ *
109
+ * Example: `0x00010203… (40 bytes)`.
110
+ * @param bytes - the binary value the driver returned.
111
+ * @returns a JSON-safe string.
112
+ */
113
+ export declare function renderBinary(bytes: Uint8Array): string;
114
+ /**
115
+ * Convert one driver value into something `JSON.stringify` represents without loss or surprise.
116
+ * `undefined` becomes `null` because a missing key and an explicit JSON `null` must not be two ways
117
+ * to say the same thing in a tool result; non-finite numbers become their text form (`NaN`,
118
+ * `Infinity`) instead of silently serializing as `null`.
119
+ * @param value - any value a row can hold.
120
+ * @returns a JSON-safe value.
121
+ */
122
+ export declare function toJsonSafe(value: unknown): unknown;
123
+ /**
124
+ * Whether a column name matches the sensitive-column rule for a connection.
125
+ *
126
+ * Shared by result masking and by enum sampling: a literal sampled from a `password` or `token`
127
+ * column would be written into the on-disk index and could surface in a `columns` listing, which is
128
+ * exactly what the masking rule exists to prevent. Sampling skips these columns regardless of the
129
+ * connection's `sensitiveColumns` mode, because the mode governs query results, not the persistent
130
+ * index.
131
+ * @param connection - the connection whose masking configuration applies.
132
+ * @param column - the column name.
133
+ * @returns true when the column should never carry sampled values.
134
+ */
135
+ export declare function isSensitiveName(connection: ConnectionConfig, column: string): boolean;
136
+ /**
137
+ * Normalize one statement's rows: mask sensitive columns, then make every remaining value JSON-safe.
138
+ *
139
+ * Masking happens before conversion on purpose — a masked column's value must never be inspected,
140
+ * let alone copied, even into a preview.
141
+ * @param columns - result column names, in result order.
142
+ * @param rows - raw driver rows keyed by column name.
143
+ * @param connection - the connection whose masking configuration applies.
144
+ * @returns the normalized rows and the names of columns that were masked, in result order.
145
+ */
146
+ export declare function normalizeRows(columns: readonly string[], rows: readonly Readonly<Record<string, unknown>>[], connection: ConnectionConfig): {
147
+ readonly rows: readonly Row[];
148
+ readonly redacted: readonly string[];
149
+ };
150
+ /**
151
+ * Read a string field from an untyped driver row (information_schema columns come back as `any`).
152
+ * @param value - the field value.
153
+ * @returns the string, or `undefined` when the value is not a string.
154
+ */
155
+ export declare function asString(value: unknown): string | undefined;
156
+ /**
157
+ * Read a numeric field from an untyped driver row. MySQL hands `BIGINT` columns back as a number or
158
+ * a decimal string depending on configuration, so both are accepted; anything not finite is
159
+ * `undefined` rather than `NaN`.
160
+ * @param value - the field value.
161
+ * @returns a finite number, or `undefined`.
162
+ */
163
+ export declare function asFiniteNumber(value: unknown): number | undefined;
164
+ //# sourceMappingURL=support.d.ts.map
package/lib/support.js ADDED
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Engine-neutral plumbing every dialect in this package shares.
3
+ *
4
+ * Four decisions live here so each dialect follows them rather than restating them:
5
+ *
6
+ * - **A read-only execution refuses anything that is not obviously a read.** The first keyword is
7
+ * parsed after stripping leading whitespace and SQL comments; `SELECT`/`SHOW`/`DESCRIBE`/`EXPLAIN`
8
+ * are admitted, everything else throws {@link DBGRAPH_READ_ONLY_CODE} when the caller asked for a
9
+ * read-only run. Keyword filtering is a policy gate, not a sandbox — a dialect that can open its
10
+ * store read-only (SQLite) does so as well, because a keyword list cannot see inside a function
11
+ * call. A statement whose first word is a read keyword is not enough on its own: an `EXPLAIN ANALYZE`
12
+ * executes the statement it claims to describe, and a `SELECT … INTO OUTFILE` writes a file on the
13
+ * server. {@link describeWriteChannel} names those channels and the gate refuses them too.
14
+ * - **Rows leave a dialect already JSON-safe.** `Date` becomes ISO text, binary becomes the hex
15
+ * marker {@link renderBinary} builds, `bigint` becomes text, and a nested object is normalized
16
+ * recursively. A transcript that persists a tool result must never choke on a value the driver
17
+ * handed back.
18
+ * - **Sensitive columns are masked inside `execute`**, not by the caller: the dialect is the only
19
+ * layer that sees both the result column names and the connection's masking configuration, and a
20
+ * masking rule that depends on a caller remembering to apply it is not a safety rule.
21
+ * - **A dialect owns a warm handle per connection id** (see `pool.ts`); the plugin's disposal closes
22
+ * them, so nothing here keeps a socket or a file descriptor past unload.
23
+ * @module dsh-plugin-dbgraph-catalog/support
24
+ */
25
+ import { DbgraphError, isSensitiveColumn } from 'dsh-plugin-dbgraph-service';
26
+ /**
27
+ * Code on the error thrown when a non-read statement is refused because the execution asked for
28
+ * read-only. The seam documents no code for this case; `DBGRAPH_UNAVAILABLE` would misreport a
29
+ * reachable database as unreachable, so the refusal gets its own name instead.
30
+ */
31
+ export const DBGRAPH_READ_ONLY_CODE = 'DBGRAPH_READ_ONLY';
32
+ /**
33
+ * Code on the error thrown when a statement the dialect did send was rejected by the engine (bad
34
+ * syntax, missing privilege, a timeout). Distinct from {@link DBGRAPH_READ_ONLY_CODE}, which is this
35
+ * package refusing to send at all.
36
+ */
37
+ export const DBGRAPH_QUERY_FAILED_CODE = 'DBGRAPH_QUERY_FAILED';
38
+ /** What a masked value becomes. */
39
+ export const REDACTED_VALUE = '[REDACTED]';
40
+ /** Bytes of a binary value rendered as hex before the marker abbreviates the rest. */
41
+ export const MAX_BINARY_PREVIEW_BYTES = 32;
42
+ /**
43
+ * One thrown value's message, for embedding in a warning or a wrapper error.
44
+ * @param error - the thrown value, of any shape.
45
+ * @returns a non-empty, single-line-ish description.
46
+ */
47
+ export function messageOf(error) {
48
+ if (error instanceof Error)
49
+ return error.message;
50
+ if (typeof error === 'string')
51
+ return error;
52
+ try {
53
+ const serialized = JSON.stringify(error);
54
+ if (serialized !== undefined)
55
+ return serialized;
56
+ }
57
+ catch {
58
+ // A value JSON cannot represent still has to produce a message; fall through to String.
59
+ }
60
+ return String(error);
61
+ }
62
+ /** Statement keywords treated as an obvious read for the read-only gate. */
63
+ const READ_STATEMENT_KEYWORDS = new Set(['SELECT', 'SHOW', 'DESCRIBE', 'EXPLAIN']);
64
+ /**
65
+ * The next ASCII word at or after `from`, skipping whitespace and `--` / block comments.
66
+ * Deliberately lexical: it never rewrites the statement it inspects.
67
+ * @param query - the statement text.
68
+ * @param from - the offset to start scanning at.
69
+ * @returns the word and its end, or `undefined` when the rest carries no word.
70
+ */
71
+ function scanStatementWord(query, from) {
72
+ let index = from;
73
+ while (index < query.length) {
74
+ if (query.startsWith('--', index)) {
75
+ const end = query.indexOf('\n', index + 2);
76
+ if (end === -1)
77
+ return undefined;
78
+ index = end + 1;
79
+ continue;
80
+ }
81
+ if (query.startsWith('/*', index)) {
82
+ const end = query.indexOf('*/', index + 2);
83
+ if (end === -1)
84
+ return undefined;
85
+ index = end + 2;
86
+ continue;
87
+ }
88
+ const char = query.charAt(index);
89
+ if (/\s/.test(char)) {
90
+ index += 1;
91
+ continue;
92
+ }
93
+ const match = /^[A-Za-z]+/.exec(query.slice(index));
94
+ const word = match?.[0];
95
+ if (word === undefined)
96
+ return undefined;
97
+ return { word: word.toUpperCase(), end: index + word.length };
98
+ }
99
+ return undefined;
100
+ }
101
+ /**
102
+ * The first SQL keyword of a statement, with leading whitespace and `--` / block comments skipped.
103
+ * @param query - the statement text.
104
+ * @returns the upper-cased first keyword, or `undefined` for an empty/comment-only statement.
105
+ */
106
+ export function firstStatementKeyword(query) {
107
+ return scanStatementWord(query, 0)?.word;
108
+ }
109
+ /**
110
+ * Whether a statement begins with one of the keywords the read-only gate admits.
111
+ * @param query - the statement text.
112
+ * @returns true when the first keyword is a read keyword.
113
+ */
114
+ export function isReadStatement(query) {
115
+ const keyword = firstStatementKeyword(query);
116
+ return keyword !== undefined && READ_STATEMENT_KEYWORDS.has(keyword);
117
+ }
118
+ /**
119
+ * Copy a quoted SQL literal or identifier verbatim, escapes included, so a `--` or `/*` inside it is
120
+ * not mistaken for a comment. MySQL spells an escaped quote both `\'` and `''`; backticks only double.
121
+ * @param query - the statement text.
122
+ * @param start - the offset of the opening quote.
123
+ * @returns the offset just past the closing quote, or the statement's end when it is unterminated.
124
+ */
125
+ function skipQuoted(query, start) {
126
+ const quote = query.charAt(start);
127
+ let index = start + 1;
128
+ while (index < query.length) {
129
+ const char = query.charAt(index);
130
+ if (char === '\\' && quote !== '`') {
131
+ index += 2;
132
+ continue;
133
+ }
134
+ if (char === quote) {
135
+ if (query.charAt(index + 1) === quote) {
136
+ index += 2;
137
+ continue;
138
+ }
139
+ return index + 1;
140
+ }
141
+ index += 1;
142
+ }
143
+ return query.length;
144
+ }
145
+ /**
146
+ * The statement text with every comment replaced by one space, quoted text left untouched.
147
+ *
148
+ * Quoted text must survive: stripping `--` naively would let `SELECT '--' INTO OUTFILE …` hide the
149
+ * write clause from the check below while the engine still executes it.
150
+ * @param query - the statement text.
151
+ * @returns the statement without comments.
152
+ */
153
+ function stripSqlComments(query) {
154
+ let stripped = '';
155
+ let index = 0;
156
+ while (index < query.length) {
157
+ if (query.startsWith('--', index)) {
158
+ const end = query.indexOf('\n', index + 2);
159
+ if (end === -1)
160
+ return stripped;
161
+ stripped += ' ';
162
+ index = end + 1;
163
+ continue;
164
+ }
165
+ if (query.startsWith('/*', index)) {
166
+ const end = query.indexOf('*/', index + 2);
167
+ if (end === -1)
168
+ return stripped;
169
+ stripped += ' ';
170
+ index = end + 2;
171
+ continue;
172
+ }
173
+ const char = query.charAt(index);
174
+ if (char === "'" || char === '"' || char === '`') {
175
+ const end = skipQuoted(query, index);
176
+ stripped += query.slice(index, end);
177
+ index = end;
178
+ continue;
179
+ }
180
+ stripped += char;
181
+ index += 1;
182
+ }
183
+ return stripped;
184
+ }
185
+ /** A phrase that turns an otherwise-read statement into a write, and how to describe it. */
186
+ const WRITE_CHANNEL_PHRASES = [
187
+ {
188
+ pattern: /\bINTO\s+OUTFILE\b/,
189
+ described: 'a statement with a SELECT ... INTO OUTFILE clause, which writes a file on the server',
190
+ },
191
+ {
192
+ pattern: /\bINTO\s+DUMPFILE\b/,
193
+ described: 'a statement with a SELECT ... INTO DUMPFILE clause, which writes a file on the server',
194
+ },
195
+ ];
196
+ /**
197
+ * Describe the write channel a statement hides behind a read keyword, if it has one.
198
+ *
199
+ * Two statement shapes begin like a read and are not one:
200
+ *
201
+ * - `EXPLAIN <word>` with anything but `SELECT` after it. `EXPLAIN ANALYZE UPDATE …` genuinely
202
+ * executes the update (MySQL runs the statement and reports its plan), so admitting it because its
203
+ * first word is `EXPLAIN` would let a read-only execution write rows.
204
+ * - `SELECT … INTO OUTFILE` / `INTO DUMPFILE`, which write a file on the database server even though
205
+ * the statement starts with `SELECT`.
206
+ *
207
+ * The check is lexical, like the keyword gate it extends: it names the channel rather than proving
208
+ * one, and it deliberately errs toward refusing. Comments are stripped first, but quoted text is
209
+ * kept, so a quoted phrase refuses a harmless statement rather than hiding a real clause.
210
+ * @param query - the statement text.
211
+ * @returns a human-readable description of the write channel, or `undefined` when there is none.
212
+ */
213
+ export function describeWriteChannel(query) {
214
+ const first = scanStatementWord(query, 0);
215
+ if (first === undefined)
216
+ return undefined;
217
+ if (first.word === 'EXPLAIN') {
218
+ const explained = scanStatementWord(query, first.end);
219
+ if (explained === undefined) {
220
+ return 'an "EXPLAIN" statement with no statement after it to describe';
221
+ }
222
+ if (explained.word !== 'SELECT') {
223
+ return `an "EXPLAIN ${explained.word}" statement, which executes the statement it explains instead of only planning it`;
224
+ }
225
+ }
226
+ const normalized = stripSqlComments(query).toUpperCase();
227
+ for (const phrase of WRITE_CHANNEL_PHRASES) {
228
+ if (phrase.pattern.test(normalized))
229
+ return phrase.described;
230
+ }
231
+ return undefined;
232
+ }
233
+ /**
234
+ * Enforce the read-only execution contract: when the caller asked for a read-only run, a statement
235
+ * that is not obviously a read is refused before it reaches the engine — and so is a read keyword
236
+ * that is only a disguise (see {@link describeWriteChannel}).
237
+ * @param query - the statement text.
238
+ * @param limits - the caller's execution limits.
239
+ * @throws DbgraphError with {@link DBGRAPH_READ_ONLY_CODE} when the statement is refused.
240
+ */
241
+ export function assertReadOnlyStatement(query, limits) {
242
+ if (!limits.readOnly)
243
+ return;
244
+ if (isReadStatement(query)) {
245
+ const channel = describeWriteChannel(query);
246
+ if (channel === undefined)
247
+ return;
248
+ throw new DbgraphError(`read-only execution refused ${channel}; only statements that cannot write are admitted`, DBGRAPH_READ_ONLY_CODE);
249
+ }
250
+ const keyword = firstStatementKeyword(query);
251
+ const described = keyword === undefined
252
+ ? 'an empty or comment-only statement'
253
+ : `a "${keyword}" statement`;
254
+ throw new DbgraphError(`read-only execution refused ${described}; only SELECT, SHOW, DESCRIBE and EXPLAIN are admitted`, DBGRAPH_READ_ONLY_CODE);
255
+ }
256
+ /**
257
+ * Clamp a requested row cap into a usable integer.
258
+ * @param requested - the caller's value; NaN, negative and infinite values are corrected, not trusted.
259
+ * @returns a whole number of rows, never negative.
260
+ */
261
+ export function clampRowLimit(requested) {
262
+ if (!Number.isFinite(requested) || requested < 0)
263
+ return 0;
264
+ return Math.floor(requested);
265
+ }
266
+ /**
267
+ * Throw when a call was aborted. Every dialect checks this before opening a connection, between
268
+ * objects while walking a catalog, and before it starts a statement.
269
+ * @param signal - the caller's abort signal, when one was supplied.
270
+ * @throws DbgraphError with `DBGRAPH_ABORTED` when the signal is already aborted.
271
+ */
272
+ export function throwIfAborted(signal) {
273
+ if (signal?.aborted === true) {
274
+ throw new DbgraphError('the call was aborted', 'DBGRAPH_ABORTED');
275
+ }
276
+ }
277
+ /**
278
+ * Render a binary value as a bounded hex marker. The length is always reported, so a truncated
279
+ * preview never masquerades as the whole value.
280
+ *
281
+ * Example: `0x00010203… (40 bytes)`.
282
+ * @param bytes - the binary value the driver returned.
283
+ * @returns a JSON-safe string.
284
+ */
285
+ export function renderBinary(bytes) {
286
+ const head = bytes.subarray(0, MAX_BINARY_PREVIEW_BYTES);
287
+ const hex = Array.from(head, byte => byte.toString(16).padStart(2, '0')).join('');
288
+ const abbreviated = bytes.byteLength > head.byteLength ? '…' : '';
289
+ return `0x${hex}${abbreviated} (${bytes.byteLength} bytes)`;
290
+ }
291
+ /**
292
+ * Convert one driver value into something `JSON.stringify` represents without loss or surprise.
293
+ * `undefined` becomes `null` because a missing key and an explicit JSON `null` must not be two ways
294
+ * to say the same thing in a tool result; non-finite numbers become their text form (`NaN`,
295
+ * `Infinity`) instead of silently serializing as `null`.
296
+ * @param value - any value a row can hold.
297
+ * @returns a JSON-safe value.
298
+ */
299
+ export function toJsonSafe(value) {
300
+ if (value === null || value === undefined)
301
+ return null;
302
+ switch (typeof value) {
303
+ case 'string':
304
+ case 'boolean':
305
+ return value;
306
+ case 'number':
307
+ return Number.isFinite(value) ? value : String(value);
308
+ case 'bigint':
309
+ return value.toString();
310
+ case 'object':
311
+ break;
312
+ default:
313
+ return String(value);
314
+ }
315
+ if (value instanceof Date) {
316
+ return Number.isNaN(value.getTime()) ? String(value) : value.toISOString();
317
+ }
318
+ if (value instanceof Uint8Array)
319
+ return renderBinary(value);
320
+ if (Array.isArray(value))
321
+ return value.map(item => toJsonSafe(item));
322
+ const record = {};
323
+ for (const [key, item] of Object.entries(value)) {
324
+ record[key] = toJsonSafe(item);
325
+ }
326
+ return record;
327
+ }
328
+ /**
329
+ * Whether a column name matches the sensitive-column rule for a connection.
330
+ *
331
+ * Shared by result masking and by enum sampling: a literal sampled from a `password` or `token`
332
+ * column would be written into the on-disk index and could surface in a `columns` listing, which is
333
+ * exactly what the masking rule exists to prevent. Sampling skips these columns regardless of the
334
+ * connection's `sensitiveColumns` mode, because the mode governs query results, not the persistent
335
+ * index.
336
+ * @param connection - the connection whose masking configuration applies.
337
+ * @param column - the column name.
338
+ * @returns true when the column should never carry sampled values.
339
+ */
340
+ export function isSensitiveName(connection, column) {
341
+ return isSensitiveColumn(column, connection.sensitivePatterns ?? []);
342
+ }
343
+ /**
344
+ * Normalize one statement's rows: mask sensitive columns, then make every remaining value JSON-safe.
345
+ *
346
+ * Masking happens before conversion on purpose — a masked column's value must never be inspected,
347
+ * let alone copied, even into a preview.
348
+ * @param columns - result column names, in result order.
349
+ * @param rows - raw driver rows keyed by column name.
350
+ * @param connection - the connection whose masking configuration applies.
351
+ * @returns the normalized rows and the names of columns that were masked, in result order.
352
+ */
353
+ export function normalizeRows(columns, rows, connection) {
354
+ const redacting = connection.sensitiveColumns !== 'show';
355
+ const masked = redacting
356
+ ? [...new Set(columns.filter(name => isSensitiveName(connection, name)))]
357
+ : [];
358
+ const maskedNames = new Set(masked);
359
+ const normalized = rows.map(row => {
360
+ const value = {};
361
+ for (const name of columns) {
362
+ value[name] = maskedNames.has(name) ? REDACTED_VALUE : toJsonSafe(row[name]);
363
+ }
364
+ return value;
365
+ });
366
+ return { rows: normalized, redacted: masked };
367
+ }
368
+ /**
369
+ * Read a string field from an untyped driver row (information_schema columns come back as `any`).
370
+ * @param value - the field value.
371
+ * @returns the string, or `undefined` when the value is not a string.
372
+ */
373
+ export function asString(value) {
374
+ return typeof value === 'string' ? value : undefined;
375
+ }
376
+ /**
377
+ * Read a numeric field from an untyped driver row. MySQL hands `BIGINT` columns back as a number or
378
+ * a decimal string depending on configuration, so both are accepted; anything not finite is
379
+ * `undefined` rather than `NaN`.
380
+ * @param value - the field value.
381
+ * @returns a finite number, or `undefined`.
382
+ */
383
+ export function asFiniteNumber(value) {
384
+ if (typeof value === 'number')
385
+ return Number.isFinite(value) ? value : undefined;
386
+ if (typeof value === 'bigint') {
387
+ const converted = Number(value);
388
+ return Number.isFinite(converted) ? converted : undefined;
389
+ }
390
+ if (typeof value === 'string' && value.trim() !== '') {
391
+ const converted = Number(value);
392
+ return Number.isFinite(converted) ? converted : undefined;
393
+ }
394
+ return undefined;
395
+ }
396
+ //# sourceMappingURL=support.js.map
package/package.json CHANGED
@@ -1,6 +1,73 @@
1
1
  {
2
2
  "name": "dsh-plugin-dbgraph-catalog",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Database dialect catalog extractors for dsh-plugin-dbgraph: MySQL, PostgreSQL, SQLite and (later) MongoDB and Redis, each answering one normalized catalog snapshot",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "deepseek-harness",
9
+ "cordis",
10
+ "cordis-plugin",
11
+ "database",
12
+ "sql",
13
+ "sqlite",
14
+ "mysql",
15
+ "postgresql",
16
+ "mongodb",
17
+ "redis",
18
+ "schema",
19
+ "ai-agent",
20
+ "llm-tools"
21
+ ],
22
+ "license": "MIT",
23
+ "author": "CC ZHAO",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/CC19990113/dsh-plugin-dbgraph.git",
27
+ "directory": "packages/catalog"
28
+ },
29
+ "homepage": "https://github.com/CC19990113/dsh-plugin-dbgraph#readme",
30
+ "bugs": "https://github.com/CC19990113/dsh-plugin-dbgraph/issues",
31
+ "type": "module",
32
+ "main": "lib/index.js",
33
+ "types": "lib/index.d.ts",
34
+ "exports": {
35
+ ".": {
36
+ "types": "./lib/index.d.ts",
37
+ "default": "./lib/index.js"
38
+ },
39
+ "./invariant": {
40
+ "types": "./lib/invariant.d.ts",
41
+ "default": "./lib/invariant.js"
42
+ },
43
+ "./package.json": "./package.json"
44
+ },
45
+ "files": [
46
+ "lib/**/*.js",
47
+ "lib/**/*.d.ts"
48
+ ],
49
+ "publishConfig": {
50
+ "access": "public",
51
+ "provenance": true
52
+ },
53
+ "peerDependencies": {
54
+ "@deepseek-ai/cordis": ">=4.0.1",
55
+ "@deepseek-ai/dsh-invariants": ">=0.1.0-rc.6",
56
+ "@deepseek-ai/dsh-llm": ">=0.1.0-rc.6"
57
+ },
58
+ "devDependencies": {
59
+ "@deepseek-ai/cordis": "^4.0.1",
60
+ "@deepseek-ai/dsh-invariants": "0.1.0-rc.6",
61
+ "@deepseek-ai/dsh-llm": "0.1.0-rc.6",
62
+ "mysql2": "^3.11.0",
63
+ "pg": "^8.13.0",
64
+ "dsh-plugin-dbgraph-service": "^0.1.0"
65
+ },
66
+ "dependencies": {
67
+ "dsh-plugin-dbgraph-service": "^0.1.0"
68
+ },
69
+ "optionalDependencies": {
70
+ "mysql2": "^3.11.0",
71
+ "pg": "^8.13.0"
72
+ }
6
73
  }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.