@zz1996/dbhub-dameng 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,587 @@
1
+ import {
2
+ isDriverNotInstalled
3
+ } from "./chunk-WVVMH6FJ.js";
4
+ import {
5
+ SQLRowLimiter
6
+ } from "./chunk-ZNQTMARG.js";
7
+ import {
8
+ ConnectorRegistry,
9
+ SafeURL,
10
+ obfuscateDSNPassword,
11
+ stripCommentsAndStrings
12
+ } from "./chunk-SQA2ISDE.js";
13
+
14
+ // src/connectors/sqlserver/index.ts
15
+ import sql from "mssql";
16
+ var SQLServerDSNParser = class {
17
+ async parse(dsn, config) {
18
+ const connectionTimeoutSeconds = config?.connectionTimeoutSeconds;
19
+ const queryTimeoutSeconds = config?.queryTimeoutSeconds;
20
+ if (!this.isValidDSN(dsn)) {
21
+ const obfuscatedDSN = obfuscateDSNPassword(dsn);
22
+ const expectedFormat = this.getSampleDSN();
23
+ throw new Error(
24
+ `Invalid SQL Server DSN format.
25
+ Provided: ${obfuscatedDSN}
26
+ Expected: ${expectedFormat}`
27
+ );
28
+ }
29
+ try {
30
+ const url = new SafeURL(dsn);
31
+ const options = {};
32
+ url.forEachSearchParam((value, key) => {
33
+ if (key === "authentication") {
34
+ options.authentication = value;
35
+ } else if (key === "sslmode") {
36
+ options.sslmode = value;
37
+ } else if (key === "instanceName") {
38
+ options.instanceName = value;
39
+ } else if (key === "domain") {
40
+ options.domain = value;
41
+ }
42
+ });
43
+ if (options.authentication === "ntlm" && !options.domain) {
44
+ throw new Error("NTLM authentication requires 'domain' parameter");
45
+ }
46
+ if (options.domain && options.authentication !== "ntlm") {
47
+ throw new Error("Parameter 'domain' requires 'authentication=ntlm'");
48
+ }
49
+ if (options.sslmode) {
50
+ if (options.sslmode === "disable") {
51
+ options.encrypt = false;
52
+ options.trustServerCertificate = false;
53
+ } else if (options.sslmode === "require") {
54
+ options.encrypt = true;
55
+ options.trustServerCertificate = true;
56
+ }
57
+ }
58
+ const config2 = {
59
+ server: url.hostname,
60
+ port: url.port ? parseInt(url.port) : 1433,
61
+ // Default SQL Server port
62
+ database: url.pathname ? url.pathname.substring(1) : "",
63
+ // Remove leading slash
64
+ options: {
65
+ encrypt: options.encrypt ?? false,
66
+ // Default to unencrypted for development
67
+ trustServerCertificate: options.trustServerCertificate ?? false,
68
+ ...connectionTimeoutSeconds !== void 0 && {
69
+ connectTimeout: connectionTimeoutSeconds * 1e3
70
+ },
71
+ ...queryTimeoutSeconds !== void 0 && {
72
+ requestTimeout: queryTimeoutSeconds * 1e3
73
+ },
74
+ instanceName: options.instanceName
75
+ // Add named instance support
76
+ }
77
+ };
78
+ switch (options.authentication) {
79
+ case "azure-active-directory-access-token": {
80
+ let DefaultAzureCredential;
81
+ try {
82
+ ({ DefaultAzureCredential } = await import("@azure/identity"));
83
+ } catch (importError) {
84
+ if (isDriverNotInstalled(importError, "@azure/identity")) {
85
+ throw new Error(
86
+ 'Azure AD authentication requires the "@azure/identity" package. Install it with: pnpm add @azure/identity'
87
+ );
88
+ }
89
+ throw importError;
90
+ }
91
+ try {
92
+ const credential = new DefaultAzureCredential();
93
+ const token = await credential.getToken("https://database.windows.net/");
94
+ config2.authentication = {
95
+ type: "azure-active-directory-access-token",
96
+ options: {
97
+ token: token.token
98
+ }
99
+ };
100
+ } catch (error) {
101
+ const errorMessage = error instanceof Error ? error.message : String(error);
102
+ throw new Error(`Failed to get Azure AD token: ${errorMessage}`);
103
+ }
104
+ break;
105
+ }
106
+ case "ntlm":
107
+ config2.authentication = {
108
+ type: "ntlm",
109
+ options: {
110
+ domain: options.domain,
111
+ userName: url.username,
112
+ password: url.password
113
+ }
114
+ };
115
+ break;
116
+ default:
117
+ config2.user = url.username;
118
+ config2.password = url.password;
119
+ break;
120
+ }
121
+ return config2;
122
+ } catch (error) {
123
+ throw new Error(
124
+ `Failed to parse SQL Server DSN: ${error instanceof Error ? error.message : String(error)}`
125
+ );
126
+ }
127
+ }
128
+ getSampleDSN() {
129
+ return "sqlserver://username:password@localhost:1433/database?sslmode=disable&instanceName=INSTANCE1";
130
+ }
131
+ isValidDSN(dsn) {
132
+ try {
133
+ return dsn.startsWith("sqlserver://");
134
+ } catch (error) {
135
+ return false;
136
+ }
137
+ }
138
+ };
139
+ var _SQLServerConnector = class _SQLServerConnector {
140
+ constructor() {
141
+ this.id = "sqlserver";
142
+ this.name = "SQL Server";
143
+ this.dsnParser = new SQLServerDSNParser();
144
+ // Source ID is set by ConnectorManager after cloning
145
+ this.sourceId = "default";
146
+ }
147
+ getId() {
148
+ return this.sourceId;
149
+ }
150
+ clone() {
151
+ return new _SQLServerConnector();
152
+ }
153
+ async connect(dsn, initScript, config) {
154
+ try {
155
+ this.config = await this.dsnParser.parse(dsn, config);
156
+ if (!this.config.options) {
157
+ this.config.options = {};
158
+ }
159
+ this.connection = await new sql.ConnectionPool(this.config).connect();
160
+ } catch (error) {
161
+ throw error;
162
+ }
163
+ }
164
+ async disconnect() {
165
+ if (this.connection) {
166
+ await this.connection.close();
167
+ this.connection = void 0;
168
+ }
169
+ }
170
+ async getSchemas() {
171
+ if (!this.connection) {
172
+ throw new Error("Not connected to SQL Server database");
173
+ }
174
+ try {
175
+ const result = await this.connection.request().query(`
176
+ SELECT SCHEMA_NAME
177
+ FROM INFORMATION_SCHEMA.SCHEMATA
178
+ ORDER BY SCHEMA_NAME
179
+ `);
180
+ return result.recordset.map((row) => row.SCHEMA_NAME);
181
+ } catch (error) {
182
+ throw new Error(`Failed to get schemas: ${error.message}`);
183
+ }
184
+ }
185
+ async getTables(schema) {
186
+ if (!this.connection) {
187
+ throw new Error("Not connected to SQL Server database");
188
+ }
189
+ try {
190
+ const schemaToUse = schema || "dbo";
191
+ const request = this.connection.request().input("schema", sql.VarChar, schemaToUse);
192
+ const query = `
193
+ SELECT TABLE_NAME
194
+ FROM INFORMATION_SCHEMA.TABLES
195
+ WHERE TABLE_SCHEMA = @schema
196
+ AND TABLE_TYPE = 'BASE TABLE'
197
+ ORDER BY TABLE_NAME
198
+ `;
199
+ const result = await request.query(query);
200
+ return result.recordset.map((row) => row.TABLE_NAME);
201
+ } catch (error) {
202
+ throw new Error(`Failed to get tables: ${error.message}`);
203
+ }
204
+ }
205
+ async getViews(schema) {
206
+ if (!this.connection) {
207
+ throw new Error("Not connected to SQL Server database");
208
+ }
209
+ try {
210
+ const schemaToUse = schema || "dbo";
211
+ const request = this.connection.request().input("schema", sql.VarChar, schemaToUse);
212
+ const query = `
213
+ SELECT TABLE_NAME
214
+ FROM INFORMATION_SCHEMA.TABLES
215
+ WHERE TABLE_SCHEMA = @schema
216
+ AND TABLE_TYPE = 'VIEW'
217
+ ORDER BY TABLE_NAME
218
+ `;
219
+ const result = await request.query(query);
220
+ return result.recordset.map((row) => row.TABLE_NAME);
221
+ } catch (error) {
222
+ throw new Error(`Failed to get views: ${error.message}`);
223
+ }
224
+ }
225
+ async tableExists(tableName, schema) {
226
+ if (!this.connection) {
227
+ throw new Error("Not connected to SQL Server database");
228
+ }
229
+ try {
230
+ const schemaToUse = schema || "dbo";
231
+ const request = this.connection.request().input("tableName", sql.VarChar, tableName).input("schema", sql.VarChar, schemaToUse);
232
+ const query = `
233
+ SELECT COUNT(*) as count
234
+ FROM INFORMATION_SCHEMA.TABLES
235
+ WHERE TABLE_NAME = @tableName
236
+ AND TABLE_SCHEMA = @schema
237
+ `;
238
+ const result = await request.query(query);
239
+ return result.recordset[0].count > 0;
240
+ } catch (error) {
241
+ throw new Error(`Failed to check if table exists: ${error.message}`);
242
+ }
243
+ }
244
+ async getTableIndexes(tableName, schema) {
245
+ if (!this.connection) {
246
+ throw new Error("Not connected to SQL Server database");
247
+ }
248
+ try {
249
+ const schemaToUse = schema || "dbo";
250
+ const request = this.connection.request().input("tableName", sql.VarChar, tableName).input("schema", sql.VarChar, schemaToUse);
251
+ const query = `
252
+ SELECT i.name AS index_name,
253
+ i.is_unique,
254
+ i.is_primary_key,
255
+ c.name AS column_name,
256
+ ic.key_ordinal
257
+ FROM sys.indexes i
258
+ INNER JOIN
259
+ sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id
260
+ INNER JOIN
261
+ sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id
262
+ INNER JOIN
263
+ sys.tables t ON i.object_id = t.object_id
264
+ INNER JOIN
265
+ sys.schemas s ON t.schema_id = s.schema_id
266
+ WHERE t.name = @tableName
267
+ AND s.name = @schema
268
+ ORDER BY i.name,
269
+ ic.key_ordinal
270
+ `;
271
+ const result = await request.query(query);
272
+ const indexMap = /* @__PURE__ */ new Map();
273
+ for (const row of result.recordset) {
274
+ const indexName = row.index_name;
275
+ const columnName = row.column_name;
276
+ const isUnique = !!row.is_unique;
277
+ const isPrimary = !!row.is_primary_key;
278
+ if (!indexMap.has(indexName)) {
279
+ indexMap.set(indexName, {
280
+ columns: [],
281
+ is_unique: isUnique,
282
+ is_primary: isPrimary
283
+ });
284
+ }
285
+ const indexInfo = indexMap.get(indexName);
286
+ indexInfo.columns.push(columnName);
287
+ }
288
+ const indexes = [];
289
+ indexMap.forEach((info, name) => {
290
+ indexes.push({
291
+ index_name: name,
292
+ column_names: info.columns,
293
+ is_unique: info.is_unique,
294
+ is_primary: info.is_primary
295
+ });
296
+ });
297
+ return indexes;
298
+ } catch (error) {
299
+ throw new Error(`Failed to get indexes for table ${tableName}: ${error.message}`);
300
+ }
301
+ }
302
+ async getTableSchema(tableName, schema) {
303
+ if (!this.connection) {
304
+ throw new Error("Not connected to SQL Server database");
305
+ }
306
+ try {
307
+ const schemaToUse = schema || "dbo";
308
+ const request = this.connection.request().input("tableName", sql.VarChar, tableName).input("schema", sql.VarChar, schemaToUse);
309
+ const query = `
310
+ SELECT c.COLUMN_NAME as column_name,
311
+ c.DATA_TYPE as data_type,
312
+ c.IS_NULLABLE as is_nullable,
313
+ c.COLUMN_DEFAULT as column_default,
314
+ ep.value as description
315
+ FROM INFORMATION_SCHEMA.COLUMNS c
316
+ LEFT JOIN sys.columns sc
317
+ ON sc.name = c.COLUMN_NAME
318
+ AND sc.object_id = OBJECT_ID(QUOTENAME(c.TABLE_SCHEMA) + '.' + QUOTENAME(c.TABLE_NAME))
319
+ LEFT JOIN sys.extended_properties ep
320
+ ON ep.major_id = sc.object_id
321
+ AND ep.minor_id = sc.column_id
322
+ AND ep.name = 'MS_Description'
323
+ WHERE c.TABLE_NAME = @tableName
324
+ AND c.TABLE_SCHEMA = @schema
325
+ ORDER BY c.ORDINAL_POSITION
326
+ `;
327
+ const result = await request.query(query);
328
+ return result.recordset.map((row) => ({
329
+ ...row,
330
+ description: row.description || null
331
+ }));
332
+ } catch (error) {
333
+ throw new Error(`Failed to get schema for table ${tableName}: ${error.message}`);
334
+ }
335
+ }
336
+ async getTableComment(tableName, schema) {
337
+ if (!this.connection) {
338
+ throw new Error("Not connected to SQL Server database");
339
+ }
340
+ try {
341
+ const schemaToUse = schema || "dbo";
342
+ const request = this.connection.request().input("tableName", sql.VarChar, tableName).input("schema", sql.VarChar, schemaToUse);
343
+ const query = `
344
+ SELECT ep.value as table_comment
345
+ FROM sys.extended_properties ep
346
+ JOIN sys.tables t ON ep.major_id = t.object_id
347
+ JOIN sys.schemas s ON t.schema_id = s.schema_id
348
+ WHERE ep.minor_id = 0
349
+ AND ep.name = 'MS_Description'
350
+ AND t.name = @tableName
351
+ AND s.name = @schema
352
+ `;
353
+ const result = await request.query(query);
354
+ if (result.recordset.length > 0) {
355
+ return result.recordset[0].table_comment || null;
356
+ }
357
+ return null;
358
+ } catch (error) {
359
+ return null;
360
+ }
361
+ }
362
+ async getStoredProcedures(schema, routineType) {
363
+ if (!this.connection) {
364
+ throw new Error("Not connected to SQL Server database");
365
+ }
366
+ try {
367
+ const schemaToUse = schema || "dbo";
368
+ const request = this.connection.request().input("schema", sql.VarChar, schemaToUse);
369
+ let typeFilter;
370
+ if (routineType === "function") {
371
+ typeFilter = "AND ROUTINE_TYPE = 'FUNCTION'";
372
+ } else if (routineType === "procedure") {
373
+ typeFilter = "AND ROUTINE_TYPE = 'PROCEDURE'";
374
+ } else {
375
+ typeFilter = "AND (ROUTINE_TYPE = 'PROCEDURE' OR ROUTINE_TYPE = 'FUNCTION')";
376
+ }
377
+ const query = `
378
+ SELECT ROUTINE_NAME
379
+ FROM INFORMATION_SCHEMA.ROUTINES
380
+ WHERE ROUTINE_SCHEMA = @schema
381
+ ${typeFilter}
382
+ ORDER BY ROUTINE_NAME
383
+ `;
384
+ const result = await request.query(query);
385
+ return result.recordset.map((row) => row.ROUTINE_NAME);
386
+ } catch (error) {
387
+ throw new Error(`Failed to get stored procedures: ${error.message}`);
388
+ }
389
+ }
390
+ async getStoredProcedureDetail(procedureName, schema) {
391
+ if (!this.connection) {
392
+ throw new Error("Not connected to SQL Server database");
393
+ }
394
+ try {
395
+ const schemaToUse = schema || "dbo";
396
+ const request = this.connection.request().input("procedureName", sql.VarChar, procedureName).input("schema", sql.VarChar, schemaToUse);
397
+ const routineQuery = `
398
+ SELECT ROUTINE_NAME as procedure_name,
399
+ ROUTINE_TYPE,
400
+ DATA_TYPE as return_data_type
401
+ FROM INFORMATION_SCHEMA.ROUTINES
402
+ WHERE ROUTINE_NAME = @procedureName
403
+ AND ROUTINE_SCHEMA = @schema
404
+ `;
405
+ const routineResult = await request.query(routineQuery);
406
+ if (routineResult.recordset.length === 0) {
407
+ throw new Error(`Stored procedure '${procedureName}' not found in schema '${schemaToUse}'`);
408
+ }
409
+ const routine = routineResult.recordset[0];
410
+ const parameterQuery = `
411
+ SELECT PARAMETER_NAME,
412
+ PARAMETER_MODE,
413
+ DATA_TYPE,
414
+ CHARACTER_MAXIMUM_LENGTH,
415
+ ORDINAL_POSITION
416
+ FROM INFORMATION_SCHEMA.PARAMETERS
417
+ WHERE SPECIFIC_NAME = @procedureName
418
+ AND SPECIFIC_SCHEMA = @schema
419
+ ORDER BY ORDINAL_POSITION
420
+ `;
421
+ const parameterResult = await request.query(parameterQuery);
422
+ let parameterList = "";
423
+ if (parameterResult.recordset.length > 0) {
424
+ parameterList = parameterResult.recordset.map(
425
+ (param) => {
426
+ const lengthStr = param.CHARACTER_MAXIMUM_LENGTH > 0 ? `(${param.CHARACTER_MAXIMUM_LENGTH})` : "";
427
+ return `${param.PARAMETER_NAME} ${param.PARAMETER_MODE} ${param.DATA_TYPE}${lengthStr}`;
428
+ }
429
+ ).join(", ");
430
+ }
431
+ const definitionQuery = `
432
+ SELECT definition
433
+ FROM sys.sql_modules sm
434
+ JOIN sys.objects o ON sm.object_id = o.object_id
435
+ JOIN sys.schemas s ON o.schema_id = s.schema_id
436
+ WHERE o.name = @procedureName
437
+ AND s.name = @schema
438
+ `;
439
+ const definitionResult = await request.query(definitionQuery);
440
+ let definition = void 0;
441
+ if (definitionResult.recordset.length > 0) {
442
+ definition = definitionResult.recordset[0].definition;
443
+ }
444
+ return {
445
+ procedure_name: routine.procedure_name,
446
+ procedure_type: routine.ROUTINE_TYPE === "PROCEDURE" ? "procedure" : "function",
447
+ language: "sql",
448
+ // SQL Server procedures are typically in T-SQL
449
+ parameter_list: parameterList,
450
+ return_type: routine.ROUTINE_TYPE === "FUNCTION" ? routine.return_data_type : void 0,
451
+ definition
452
+ };
453
+ } catch (error) {
454
+ throw new Error(`Failed to get stored procedure details: ${error.message}`);
455
+ }
456
+ }
457
+ async executeSQL(sqlQuery, options, parameters) {
458
+ if (!this.connection) {
459
+ throw new Error("Not connected to SQL Server database");
460
+ }
461
+ const afterNoise = sqlQuery.slice(
462
+ sqlQuery.match(_SQLServerConnector.LEADING_NOISE)[0].length
463
+ );
464
+ if (/^explain\b/i.test(afterNoise)) {
465
+ return this.explainQuery(afterNoise.slice("explain".length).trim());
466
+ }
467
+ try {
468
+ let processedSQL = sqlQuery;
469
+ if (options.maxRows) {
470
+ processedSQL = SQLRowLimiter.applyMaxRowsForSQLServer(sqlQuery, options.maxRows);
471
+ }
472
+ const request = this.connection.request();
473
+ const messages = [];
474
+ request.on(
475
+ "info",
476
+ (info) => {
477
+ messages.push({
478
+ text: info.message,
479
+ // SQL Server reports severity as a numeric class; info messages are < 10.
480
+ severity: info.class !== void 0 ? String(info.class) : void 0,
481
+ code: info.number,
482
+ line: info.lineNumber
483
+ });
484
+ }
485
+ );
486
+ if (parameters && parameters.length > 0) {
487
+ parameters.forEach((param, index) => {
488
+ const paramName = `p${index + 1}`;
489
+ if (typeof param === "string") {
490
+ request.input(paramName, sql.VarChar, param);
491
+ } else if (typeof param === "number") {
492
+ if (Number.isInteger(param)) {
493
+ request.input(paramName, sql.Int, param);
494
+ } else {
495
+ request.input(paramName, sql.Float, param);
496
+ }
497
+ } else if (typeof param === "boolean") {
498
+ request.input(paramName, sql.Bit, param);
499
+ } else if (param === null || param === void 0) {
500
+ request.input(paramName, sql.VarChar, param);
501
+ } else if (Array.isArray(param)) {
502
+ request.input(paramName, sql.VarChar, JSON.stringify(param));
503
+ } else {
504
+ request.input(paramName, sql.VarChar, JSON.stringify(param));
505
+ }
506
+ });
507
+ }
508
+ let result;
509
+ try {
510
+ result = await request.query(processedSQL);
511
+ } catch (error) {
512
+ if (parameters && parameters.length > 0) {
513
+ console.error(`[SQL Server executeSQL] ERROR: ${error.message}`);
514
+ console.error(`[SQL Server executeSQL] SQL: ${processedSQL}`);
515
+ console.error(`[SQL Server executeSQL] Parameters: ${JSON.stringify(parameters)}`);
516
+ }
517
+ throw error;
518
+ }
519
+ return {
520
+ rows: result.recordset || [],
521
+ rowCount: result.rowsAffected[0] || 0,
522
+ ...messages.length > 0 ? { messages } : {}
523
+ };
524
+ } catch (error) {
525
+ throw new Error(`Failed to execute query: ${error.message}`);
526
+ }
527
+ }
528
+ /**
529
+ * Return the estimated execution plan for a query using SHOWPLAN_XML.
530
+ *
531
+ * SHOWPLAN_XML compiles the statement and returns its plan without executing
532
+ * it, but it has two constraints: `SET SHOWPLAN_XML ON` must be the only
533
+ * statement in its batch, and the setting is session scoped. The shared pool
534
+ * hands out a fresh connection per request() and an open transaction
535
+ * suppresses SHOWPLAN, so neither can carry the setting to a follow-up query.
536
+ *
537
+ * We therefore run the SET / query pair on a short-lived, single-connection
538
+ * pool built from the same config. The dedicated session keeps SHOWPLAN state
539
+ * off the shared pool, so a concurrent query can never land on a connection
540
+ * with SHOWPLAN enabled (which would return a plan instead of its results).
541
+ */
542
+ async explainQuery(innerQuery) {
543
+ const cleaned = stripCommentsAndStrings(innerQuery, "sqlserver").trim();
544
+ if (!cleaned) {
545
+ throw new Error("EXPLAIN requires a statement to analyze");
546
+ }
547
+ if (/\bset\s+showplan/i.test(cleaned)) {
548
+ throw new Error("EXPLAIN does not support SET SHOWPLAN statements");
549
+ }
550
+ if (!this.config) {
551
+ throw new Error("Not connected to SQL Server database");
552
+ }
553
+ const explainPool = new sql.ConnectionPool({
554
+ ...this.config,
555
+ pool: { ...this.config.pool, max: 1, min: 1 }
556
+ });
557
+ try {
558
+ await explainPool.connect();
559
+ await explainPool.request().batch("SET SHOWPLAN_XML ON");
560
+ const planResult = await explainPool.request().batch(innerQuery);
561
+ const planRow = planResult.recordset?.[0];
562
+ const planXml = planRow ? Object.values(planRow)[0] : null;
563
+ return {
564
+ rows: planXml != null ? [{ plan: planXml }] : [],
565
+ rowCount: planXml != null ? 1 : 0
566
+ };
567
+ } catch (error) {
568
+ throw new Error(`Failed to explain query: ${error.message}`);
569
+ } finally {
570
+ await explainPool.close();
571
+ }
572
+ }
573
+ };
574
+ /**
575
+ * Leading whitespace and SQL comments to skip before looking for a keyword.
576
+ * The read-only validator strips comments before checking the first keyword,
577
+ * so the connector must skip them too; otherwise an EXPLAIN preceded by a
578
+ * comment passes validation but reaches the server untranslated.
579
+ */
580
+ _SQLServerConnector.LEADING_NOISE = /^(?:\s+|--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*/;
581
+ var SQLServerConnector = _SQLServerConnector;
582
+ var sqlServerConnector = new SQLServerConnector();
583
+ ConnectorRegistry.register(sqlServerConnector);
584
+ export {
585
+ SQLServerConnector,
586
+ SQLServerDSNParser
587
+ };
package/docs/README.md ADDED
@@ -0,0 +1,14 @@
1
+ # DBHub Documentation
2
+
3
+ Install the [Mintlify CLI](https://www.npmjs.com/package/mint) to preview documentation locally:
4
+
5
+ ```bash
6
+ npm i -g mint
7
+ ```
8
+
9
+ Run the following command at the root of your documentation (where `docs.json` is located):
10
+
11
+ ```bash
12
+ cd docs
13
+ mint dev
14
+ ```