turbine-orm 0.27.0 → 0.28.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/README.md +17 -13
- package/dist/cjs/cli/config.js +20 -3
- package/dist/cjs/cli/destructive.js +47 -31
- package/dist/cjs/cli/index.js +273 -71
- package/dist/cjs/cli/mcp.js +788 -0
- package/dist/cjs/cli/migrate.js +95 -20
- package/dist/cjs/cli/studio.js +3 -2
- package/dist/cjs/client.js +267 -34
- package/dist/cjs/dialect.js +2 -0
- package/dist/cjs/generate.js +171 -7
- package/dist/cjs/index.js +4 -1
- package/dist/cjs/introspect.js +177 -4
- package/dist/cjs/query/batched-loader.js +148 -0
- package/dist/cjs/query/builder.js +714 -133
- package/dist/cjs/schema-builder.js +59 -4
- package/dist/cjs/schema-sql.js +315 -6
- package/dist/cjs/seed.js +66 -0
- package/dist/cli/config.d.ts +9 -2
- package/dist/cli/config.js +19 -3
- package/dist/cli/destructive.js +47 -31
- package/dist/cli/index.d.ts +52 -1
- package/dist/cli/index.js +272 -74
- package/dist/cli/mcp.d.ts +17 -0
- package/dist/cli/mcp.js +781 -0
- package/dist/cli/migrate.d.ts +37 -0
- package/dist/cli/migrate.js +92 -20
- package/dist/cli/studio.d.ts +3 -2
- package/dist/cli/studio.js +3 -2
- package/dist/client.d.ts +136 -1
- package/dist/client.js +267 -34
- package/dist/dialect.d.ts +17 -0
- package/dist/dialect.js +2 -0
- package/dist/generate.d.ts +17 -0
- package/dist/generate.js +171 -10
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -0
- package/dist/introspect.d.ts +20 -1
- package/dist/introspect.js +175 -4
- package/dist/query/batched-loader.d.ts +29 -2
- package/dist/query/batched-loader.js +148 -1
- package/dist/query/builder.d.ts +156 -8
- package/dist/query/builder.js +715 -134
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +113 -8
- package/dist/schema-builder.d.ts +73 -8
- package/dist/schema-builder.js +59 -4
- package/dist/schema-sql.d.ts +67 -0
- package/dist/schema-sql.js +310 -6
- package/dist/schema.d.ts +53 -0
- package/dist/seed.d.ts +4 -0
- package/dist/seed.js +63 -0
- package/package.json +2 -3
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.startMcpServer = startMcpServer;
|
|
7
|
+
exports.runMcpServer = runMcpServer;
|
|
8
|
+
const node_crypto_1 = require("node:crypto");
|
|
9
|
+
const node_fs_1 = require("node:fs");
|
|
10
|
+
const node_path_1 = require("node:path");
|
|
11
|
+
const pg_1 = __importDefault(require("pg"));
|
|
12
|
+
const index_advisor_js_1 = require("../index-advisor.js");
|
|
13
|
+
const index_js_1 = require("../query/index.js");
|
|
14
|
+
const schema_js_1 = require("../schema.js");
|
|
15
|
+
const migrate_js_1 = require("./migrate.js");
|
|
16
|
+
/**
|
|
17
|
+
* Walk up from the running script to find turbine-orm's own package.json.
|
|
18
|
+
* Uses process.argv[1] instead of import.meta.url so the same code compiles
|
|
19
|
+
* cleanly for both the ESM and CJS builds (same convention as cli/index.ts).
|
|
20
|
+
*/
|
|
21
|
+
function readOwnVersion() {
|
|
22
|
+
try {
|
|
23
|
+
let entry = process.argv[1] ?? '';
|
|
24
|
+
try {
|
|
25
|
+
entry = (0, node_fs_1.realpathSync)(entry);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// keep the raw path if realpath fails
|
|
29
|
+
}
|
|
30
|
+
let dir = (0, node_path_1.dirname)(entry);
|
|
31
|
+
for (let i = 0; i < 6; i++) {
|
|
32
|
+
const candidate = (0, node_path_1.resolve)(dir, 'package.json');
|
|
33
|
+
if ((0, node_fs_1.existsSync)(candidate)) {
|
|
34
|
+
const pkg = JSON.parse((0, node_fs_1.readFileSync)(candidate, 'utf8'));
|
|
35
|
+
if (pkg.name === 'turbine-orm' && pkg.version)
|
|
36
|
+
return pkg.version;
|
|
37
|
+
}
|
|
38
|
+
const parent = (0, node_path_1.dirname)(dir);
|
|
39
|
+
if (parent === dir)
|
|
40
|
+
break;
|
|
41
|
+
dir = parent;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// fall through
|
|
46
|
+
}
|
|
47
|
+
return '0.0.0';
|
|
48
|
+
}
|
|
49
|
+
const PROTOCOL_VERSION = '2025-06-18';
|
|
50
|
+
const STATEMENT_TIMEOUT = '30s';
|
|
51
|
+
const TRACKING_TABLE = '_turbine_migrations';
|
|
52
|
+
const TOOLS = [
|
|
53
|
+
{
|
|
54
|
+
name: 'schema_overview',
|
|
55
|
+
description: 'List tables, columns, relations, indexes, and estimated row counts for the configured schema.',
|
|
56
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'table_detail',
|
|
60
|
+
description: 'Show columns, indexes, and relations for one table.',
|
|
61
|
+
inputSchema: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: { table: { type: 'string' } },
|
|
64
|
+
required: ['table'],
|
|
65
|
+
additionalProperties: false,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'migrate_status',
|
|
70
|
+
description: 'Read migration files and the existing migration tracking table without applying migrations.',
|
|
71
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'doctor_report',
|
|
75
|
+
description: 'Report missing relation indexes using Turbine metadata and the index advisor.',
|
|
76
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'explain_query',
|
|
80
|
+
description: 'Run EXPLAIN (FORMAT JSON) for a schema-validated findMany query. Pass table + optional where/orderBy/limit/select — free-form SQL is rejected.',
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: {
|
|
84
|
+
table: { type: 'string', description: 'Table name (must exist in the introspected schema).' },
|
|
85
|
+
where: {
|
|
86
|
+
type: 'object',
|
|
87
|
+
description: 'findMany-style where clause; field names validated against the schema.',
|
|
88
|
+
},
|
|
89
|
+
orderBy: {
|
|
90
|
+
description: 'findMany-style orderBy (object or array of objects); field names validated against the schema.',
|
|
91
|
+
},
|
|
92
|
+
limit: { type: 'number', minimum: 1, description: 'Optional row limit for the planned query.' },
|
|
93
|
+
select: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
description: 'Optional field selection map (camelCase or column names → true).',
|
|
96
|
+
additionalProperties: { type: 'boolean' },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
required: ['table'],
|
|
100
|
+
additionalProperties: false,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: 'sample_rows',
|
|
105
|
+
description: 'Read up to 50 rows from a validated table.',
|
|
106
|
+
inputSchema: {
|
|
107
|
+
type: 'object',
|
|
108
|
+
properties: { table: { type: 'string' }, limit: { type: 'number', minimum: 1, maximum: 50 } },
|
|
109
|
+
required: ['table'],
|
|
110
|
+
additionalProperties: false,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
function startMcpServer(options, transport = {}) {
|
|
115
|
+
const input = transport.input ?? process.stdin;
|
|
116
|
+
const output = transport.output ?? process.stdout;
|
|
117
|
+
const ctx = {
|
|
118
|
+
options,
|
|
119
|
+
pool: new pg_1.default.Pool({ connectionString: options.url, max: 2, idleTimeoutMillis: 10_000 }),
|
|
120
|
+
};
|
|
121
|
+
let buffer = '';
|
|
122
|
+
let disposed = false;
|
|
123
|
+
const write = (payload) => {
|
|
124
|
+
output.write(`${JSON.stringify(payload)}\n`);
|
|
125
|
+
};
|
|
126
|
+
const onData = (chunk) => {
|
|
127
|
+
buffer += chunk.toString();
|
|
128
|
+
let newlineIndex = buffer.indexOf('\n');
|
|
129
|
+
while (newlineIndex !== -1) {
|
|
130
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
131
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
132
|
+
if (line) {
|
|
133
|
+
handleLine(line, ctx, write).catch((err) => {
|
|
134
|
+
write(errorResponse(null, -32603, 'Internal error', errorMessage(err)));
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
newlineIndex = buffer.indexOf('\n');
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
input.on('data', onData);
|
|
141
|
+
return {
|
|
142
|
+
dispose: async () => {
|
|
143
|
+
if (disposed)
|
|
144
|
+
return;
|
|
145
|
+
disposed = true;
|
|
146
|
+
input.off('data', onData);
|
|
147
|
+
await ctx.pool.end();
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
async function handleLine(line, ctx, write) {
|
|
152
|
+
let message;
|
|
153
|
+
try {
|
|
154
|
+
message = JSON.parse(line);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
write(errorResponse(null, -32700, 'Parse error', errorMessage(err)));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (!isJsonRpcRequest(message)) {
|
|
161
|
+
write(errorResponse(null, -32600, 'Invalid Request'));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const request = message;
|
|
165
|
+
const isNotification = request.id === undefined;
|
|
166
|
+
try {
|
|
167
|
+
const result = await dispatch(request, ctx);
|
|
168
|
+
if (!isNotification)
|
|
169
|
+
write({ jsonrpc: '2.0', id: request.id, result });
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
if (!isNotification) {
|
|
173
|
+
const rpcError = toJsonRpcError(err);
|
|
174
|
+
write({ jsonrpc: '2.0', id: request.id, error: rpcError });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async function dispatch(request, ctx) {
|
|
179
|
+
switch (request.method) {
|
|
180
|
+
case 'initialize':
|
|
181
|
+
return {
|
|
182
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
183
|
+
serverInfo: { name: 'turbine-orm', version: readOwnVersion() },
|
|
184
|
+
capabilities: { tools: {} },
|
|
185
|
+
};
|
|
186
|
+
case 'notifications/initialized':
|
|
187
|
+
return null;
|
|
188
|
+
case 'tools/list':
|
|
189
|
+
return { tools: TOOLS };
|
|
190
|
+
case 'tools/call':
|
|
191
|
+
return callTool(request.params, ctx);
|
|
192
|
+
case 'shutdown':
|
|
193
|
+
return null;
|
|
194
|
+
default:
|
|
195
|
+
throw jsonRpcError(-32601, `Method not found: ${request.method}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function callTool(params, ctx) {
|
|
199
|
+
if (!isObject(params) || typeof params.name !== 'string') {
|
|
200
|
+
throw jsonRpcError(-32602, 'tools/call requires a string tool name');
|
|
201
|
+
}
|
|
202
|
+
const args = isObject(params.arguments) ? params.arguments : {};
|
|
203
|
+
let result;
|
|
204
|
+
switch (params.name) {
|
|
205
|
+
case 'schema_overview':
|
|
206
|
+
result = await schemaOverview(ctx);
|
|
207
|
+
break;
|
|
208
|
+
case 'table_detail':
|
|
209
|
+
result = await tableDetail(ctx, requiredString(args, 'table'));
|
|
210
|
+
break;
|
|
211
|
+
case 'migrate_status':
|
|
212
|
+
result = await migrationStatus(ctx);
|
|
213
|
+
break;
|
|
214
|
+
case 'doctor_report':
|
|
215
|
+
result = await doctorReport(ctx);
|
|
216
|
+
break;
|
|
217
|
+
case 'explain_query':
|
|
218
|
+
result = await explainQuery(ctx, args);
|
|
219
|
+
break;
|
|
220
|
+
case 'sample_rows':
|
|
221
|
+
result = await sampleRows(ctx, requiredString(args, 'table'), optionalLimit(args.limit));
|
|
222
|
+
break;
|
|
223
|
+
default:
|
|
224
|
+
throw jsonRpcError(-32602, `Unknown tool: ${params.name}`);
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
async function schemaOverview(ctx) {
|
|
231
|
+
return withReadOnly(ctx, async (client) => {
|
|
232
|
+
const metadata = await loadSchemaMetadata(client, ctx.options);
|
|
233
|
+
const rowCounts = await estimateRows(client, ctx.options.schema);
|
|
234
|
+
return {
|
|
235
|
+
schema: ctx.options.schema,
|
|
236
|
+
tables: Object.values(metadata.tables).map((table) => ({
|
|
237
|
+
name: table.name,
|
|
238
|
+
estimatedRows: rowCounts.get(table.name) ?? 0,
|
|
239
|
+
columns: table.columns.length,
|
|
240
|
+
primaryKey: table.primaryKey,
|
|
241
|
+
indexes: table.indexes.length,
|
|
242
|
+
relations: Object.keys(table.relations).length,
|
|
243
|
+
})),
|
|
244
|
+
enums: metadata.enums,
|
|
245
|
+
};
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
async function tableDetail(ctx, tableName) {
|
|
249
|
+
return withReadOnly(ctx, async (client) => {
|
|
250
|
+
const metadata = await loadSchemaMetadata(client, ctx.options);
|
|
251
|
+
const table = requireTable(metadata, tableName);
|
|
252
|
+
return {
|
|
253
|
+
name: table.name,
|
|
254
|
+
primaryKey: table.primaryKey,
|
|
255
|
+
columns: table.columns.map((column) => ({
|
|
256
|
+
name: column.name,
|
|
257
|
+
field: column.field,
|
|
258
|
+
pgType: column.pgType,
|
|
259
|
+
tsType: column.tsType,
|
|
260
|
+
nullable: column.nullable,
|
|
261
|
+
hasDefault: column.hasDefault,
|
|
262
|
+
isGenerated: column.isGenerated ?? false,
|
|
263
|
+
isArray: column.isArray,
|
|
264
|
+
maxLength: column.maxLength,
|
|
265
|
+
})),
|
|
266
|
+
indexes: table.indexes,
|
|
267
|
+
relations: Object.values(table.relations).map((relation) => ({
|
|
268
|
+
name: relation.name,
|
|
269
|
+
type: relation.type,
|
|
270
|
+
from: relation.from,
|
|
271
|
+
to: relation.to,
|
|
272
|
+
foreignKey: relation.foreignKey,
|
|
273
|
+
referenceKey: relation.referenceKey,
|
|
274
|
+
through: relation.through,
|
|
275
|
+
})),
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
async function migrationStatus(ctx) {
|
|
280
|
+
return withReadOnly(ctx, async (client) => {
|
|
281
|
+
const files = (0, migrate_js_1.listMigrationFiles)(ctx.options.migrationsDir);
|
|
282
|
+
const trackingExists = await client.query(`SELECT to_regclass($1)::text IS NOT NULL AS exists`, [TRACKING_TABLE]);
|
|
283
|
+
const applied = new Map();
|
|
284
|
+
if (trackingExists.rows[0]?.exists) {
|
|
285
|
+
const result = await client.query(`SELECT name, applied_at, checksum FROM ${(0, index_js_1.quoteIdent)(TRACKING_TABLE)} ORDER BY name`);
|
|
286
|
+
for (const row of result.rows) {
|
|
287
|
+
applied.set(row.name, { appliedAt: row.applied_at, checksum: row.checksum });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const statuses = files.map((file) => {
|
|
291
|
+
const record = applied.get(file.name);
|
|
292
|
+
const checksum = sha256((0, node_fs_1.readFileSync)(file.path, 'utf-8'));
|
|
293
|
+
return {
|
|
294
|
+
migration: file.filename,
|
|
295
|
+
applied: !!record,
|
|
296
|
+
appliedAt: record?.appliedAt?.toISOString(),
|
|
297
|
+
checksumValid: record ? checksum === record.checksum : undefined,
|
|
298
|
+
};
|
|
299
|
+
});
|
|
300
|
+
return {
|
|
301
|
+
migrationsDir: ctx.options.migrationsDir,
|
|
302
|
+
trackingTableExists: trackingExists.rows[0]?.exists ?? false,
|
|
303
|
+
applied: statuses.filter((status) => status.applied).length,
|
|
304
|
+
pending: statuses.filter((status) => !status.applied).length,
|
|
305
|
+
drifted: statuses.filter((status) => status.checksumValid === false).length,
|
|
306
|
+
migrations: statuses,
|
|
307
|
+
};
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
async function doctorReport(ctx) {
|
|
311
|
+
return withReadOnly(ctx, async (client) => {
|
|
312
|
+
const metadata = await loadSchemaMetadata(client, ctx.options);
|
|
313
|
+
const rowCounts = await estimateRows(client, ctx.options.schema);
|
|
314
|
+
const missing = (0, index_advisor_js_1.findMissingRelationIndexes)(metadata).sort((a, b) => (rowCounts.get(b.table) ?? 0) - (rowCounts.get(a.table) ?? 0));
|
|
315
|
+
return {
|
|
316
|
+
schema: ctx.options.schema,
|
|
317
|
+
ok: missing.length === 0,
|
|
318
|
+
missingRelationIndexes: missing.map((entry) => ({
|
|
319
|
+
table: entry.table,
|
|
320
|
+
estimatedRows: rowCounts.get(entry.table) ?? 0,
|
|
321
|
+
columns: entry.columns,
|
|
322
|
+
probes: entry.probes,
|
|
323
|
+
suggestedIndexName: entry.indexName,
|
|
324
|
+
createSql: entry.createSql,
|
|
325
|
+
})),
|
|
326
|
+
};
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* EXPLAIN a schema-validated findMany query. Free-form SQL is never accepted —
|
|
331
|
+
* table/field identifiers are checked against introspected metadata and the
|
|
332
|
+
* SELECT is compiled by QueryInterface (same stance as Studio `/api/builder`).
|
|
333
|
+
*/
|
|
334
|
+
async function explainQuery(ctx, args) {
|
|
335
|
+
// Explicit rejection so agents that still send the old `{ sql }` shape get a
|
|
336
|
+
// clear migration error instead of a silent "table is required".
|
|
337
|
+
if ('sql' in args) {
|
|
338
|
+
throw jsonRpcError(-32602, 'explain_query no longer accepts free-form SQL; pass table + findMany-style args (where/orderBy/limit/select)');
|
|
339
|
+
}
|
|
340
|
+
const tableName = requiredString(args, 'table');
|
|
341
|
+
const findManyArgs = parseExplainFindManyArgs(args);
|
|
342
|
+
return withReadOnly(ctx, async (client) => {
|
|
343
|
+
const metadata = await loadSchemaMetadata(client, ctx.options);
|
|
344
|
+
const table = requireTable(metadata, tableName);
|
|
345
|
+
let deferred;
|
|
346
|
+
try {
|
|
347
|
+
// Build-only: pool is unused for SQL generation (mirrors Studio).
|
|
348
|
+
const qi = new index_js_1.QueryInterface(ctx.pool, table.name, metadata, [], {
|
|
349
|
+
warnOnUnlimited: false,
|
|
350
|
+
sqlCache: false,
|
|
351
|
+
preparedStatements: false,
|
|
352
|
+
});
|
|
353
|
+
deferred = qi.buildFindMany(findManyArgs);
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
// Unknown columns/operators/relations → invalid params, not internal error.
|
|
357
|
+
throw jsonRpcError(-32602, err instanceof Error ? err.message : String(err));
|
|
358
|
+
}
|
|
359
|
+
// QueryInterface emits unqualified identifiers; pin search_path like Studio.
|
|
360
|
+
await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
|
|
361
|
+
const result = await client.query(`EXPLAIN (FORMAT JSON) ${deferred.sql}`, deferred.params);
|
|
362
|
+
return {
|
|
363
|
+
table: table.name,
|
|
364
|
+
sql: deferred.sql,
|
|
365
|
+
params: deferred.params,
|
|
366
|
+
plan: result.rows[0]?.['QUERY PLAN'] ?? null,
|
|
367
|
+
};
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Extract the allowed findMany subset for explain_query (no `with` / raw SQL).
|
|
372
|
+
* Returns a plain object cast at the buildFindMany call site — same pattern as Studio.
|
|
373
|
+
*/
|
|
374
|
+
function parseExplainFindManyArgs(args) {
|
|
375
|
+
const findManyArgs = {};
|
|
376
|
+
if (args.where !== undefined) {
|
|
377
|
+
if (!isObject(args.where))
|
|
378
|
+
throw jsonRpcError(-32602, 'where must be an object');
|
|
379
|
+
findManyArgs.where = args.where;
|
|
380
|
+
}
|
|
381
|
+
if (args.orderBy !== undefined) {
|
|
382
|
+
if (typeof args.orderBy !== 'object' || args.orderBy === null) {
|
|
383
|
+
throw jsonRpcError(-32602, 'orderBy must be an object or array of objects');
|
|
384
|
+
}
|
|
385
|
+
findManyArgs.orderBy = args.orderBy;
|
|
386
|
+
}
|
|
387
|
+
if (args.limit !== undefined) {
|
|
388
|
+
if (typeof args.limit !== 'number' || !Number.isInteger(args.limit) || args.limit < 1) {
|
|
389
|
+
throw jsonRpcError(-32602, 'limit must be a positive integer');
|
|
390
|
+
}
|
|
391
|
+
findManyArgs.limit = args.limit;
|
|
392
|
+
}
|
|
393
|
+
if (args.select !== undefined) {
|
|
394
|
+
if (!isObject(args.select))
|
|
395
|
+
throw jsonRpcError(-32602, 'select must be an object');
|
|
396
|
+
for (const [key, value] of Object.entries(args.select)) {
|
|
397
|
+
if (typeof value !== 'boolean') {
|
|
398
|
+
throw jsonRpcError(-32602, `select.${key} must be a boolean`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
findManyArgs.select = args.select;
|
|
402
|
+
}
|
|
403
|
+
return findManyArgs;
|
|
404
|
+
}
|
|
405
|
+
async function sampleRows(ctx, tableName, limit) {
|
|
406
|
+
return withReadOnly(ctx, async (client) => {
|
|
407
|
+
const metadata = await loadSchemaMetadata(client, ctx.options);
|
|
408
|
+
const table = requireTable(metadata, tableName);
|
|
409
|
+
const qualifiedTable = `${(0, index_js_1.quoteIdent)(ctx.options.schema)}.${(0, index_js_1.quoteIdent)(table.name)}`;
|
|
410
|
+
const result = await client.query(`SELECT * FROM ${qualifiedTable} LIMIT $1`, [limit]);
|
|
411
|
+
return {
|
|
412
|
+
table: table.name,
|
|
413
|
+
limit,
|
|
414
|
+
columns: result.fields.map((field) => ({ name: field.name, dataTypeID: field.dataTypeID })),
|
|
415
|
+
rows: result.rows,
|
|
416
|
+
rowCount: result.rowCount ?? result.rows.length,
|
|
417
|
+
};
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
async function withReadOnly(ctx, fn) {
|
|
421
|
+
const client = await ctx.pool.connect();
|
|
422
|
+
try {
|
|
423
|
+
await client.query('BEGIN READ ONLY');
|
|
424
|
+
await client.query(`SELECT set_config('statement_timeout', $1, true)`, [STATEMENT_TIMEOUT]);
|
|
425
|
+
const result = await fn(client);
|
|
426
|
+
await client.query('COMMIT');
|
|
427
|
+
return result;
|
|
428
|
+
}
|
|
429
|
+
catch (err) {
|
|
430
|
+
try {
|
|
431
|
+
await client.query('ROLLBACK');
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
// ignore rollback errors; the original error is more useful.
|
|
435
|
+
}
|
|
436
|
+
throw err;
|
|
437
|
+
}
|
|
438
|
+
finally {
|
|
439
|
+
client.release();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
async function loadSchemaMetadata(client, options) {
|
|
443
|
+
const [tablesResult, columnsResult, pkResult, fkResult, uniqueResult, indexResult, enumResult] = await Promise.all([
|
|
444
|
+
client.query(`SELECT table_name
|
|
445
|
+
FROM information_schema.tables
|
|
446
|
+
WHERE table_schema = $1 AND table_type = 'BASE TABLE'
|
|
447
|
+
ORDER BY table_name`, [options.schema]),
|
|
448
|
+
client.query(`SELECT table_name, column_name, udt_name, data_type, is_nullable, column_default, is_identity,
|
|
449
|
+
character_maximum_length
|
|
450
|
+
FROM information_schema.columns
|
|
451
|
+
WHERE table_schema = $1
|
|
452
|
+
ORDER BY table_name, ordinal_position`, [options.schema]),
|
|
453
|
+
client.query(`SELECT tc.table_name, kcu.column_name
|
|
454
|
+
FROM information_schema.table_constraints tc
|
|
455
|
+
JOIN information_schema.key_column_usage kcu
|
|
456
|
+
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
457
|
+
WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = $1
|
|
458
|
+
ORDER BY tc.table_name, kcu.ordinal_position`, [options.schema]),
|
|
459
|
+
client.query(`SELECT tc.table_name AS source_table, kcu.column_name AS source_column,
|
|
460
|
+
ccu.table_name AS target_table, ccu.column_name AS target_column, tc.constraint_name
|
|
461
|
+
FROM information_schema.table_constraints tc
|
|
462
|
+
JOIN information_schema.key_column_usage kcu
|
|
463
|
+
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
464
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
465
|
+
ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema
|
|
466
|
+
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = $1`, [options.schema]),
|
|
467
|
+
client.query(`SELECT tc.table_name, tc.constraint_name, kcu.column_name
|
|
468
|
+
FROM information_schema.table_constraints tc
|
|
469
|
+
JOIN information_schema.key_column_usage kcu
|
|
470
|
+
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
471
|
+
WHERE tc.constraint_type = 'UNIQUE' AND tc.table_schema = $1
|
|
472
|
+
ORDER BY tc.table_name, tc.constraint_name, kcu.ordinal_position`, [options.schema]),
|
|
473
|
+
client.query(`SELECT tablename, indexname, indexdef FROM pg_indexes WHERE schemaname = $1`, [options.schema]),
|
|
474
|
+
client.query(`SELECT t.typname, e.enumlabel
|
|
475
|
+
FROM pg_type t
|
|
476
|
+
JOIN pg_enum e ON t.oid = e.enumtypid
|
|
477
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
|
478
|
+
WHERE n.nspname = $1
|
|
479
|
+
ORDER BY t.typname, e.enumsortorder`, [options.schema]),
|
|
480
|
+
]);
|
|
481
|
+
let tableNames = tablesResult.rows.map((row) => row.table_name);
|
|
482
|
+
if (options.include?.length) {
|
|
483
|
+
const include = new Set(options.include);
|
|
484
|
+
tableNames = tableNames.filter((table) => include.has(table));
|
|
485
|
+
}
|
|
486
|
+
if (options.exclude?.length) {
|
|
487
|
+
const exclude = new Set(options.exclude);
|
|
488
|
+
tableNames = tableNames.filter((table) => !exclude.has(table));
|
|
489
|
+
}
|
|
490
|
+
const tableSet = new Set(tableNames);
|
|
491
|
+
const columnsByTable = new Map();
|
|
492
|
+
for (const row of columnsResult.rows) {
|
|
493
|
+
if (!tableSet.has(row.table_name))
|
|
494
|
+
continue;
|
|
495
|
+
const isNullable = row.is_nullable === 'YES';
|
|
496
|
+
const isArray = row.data_type === 'ARRAY';
|
|
497
|
+
const baseType = isArray ? row.udt_name.slice(1) : row.udt_name;
|
|
498
|
+
const column = {
|
|
499
|
+
name: row.column_name,
|
|
500
|
+
field: (0, schema_js_1.snakeToCamel)(row.column_name),
|
|
501
|
+
dialectType: row.udt_name,
|
|
502
|
+
pgType: row.udt_name,
|
|
503
|
+
tsType: (0, schema_js_1.pgTypeToTs)(isArray ? row.udt_name : baseType, isNullable),
|
|
504
|
+
nullable: isNullable,
|
|
505
|
+
hasDefault: row.column_default !== null,
|
|
506
|
+
isGenerated: (typeof row.column_default === 'string' && row.column_default.includes('nextval(')) ||
|
|
507
|
+
row.is_identity === 'YES',
|
|
508
|
+
isArray,
|
|
509
|
+
arrayType: (0, schema_js_1.pgArrayType)(baseType),
|
|
510
|
+
pgArrayType: (0, schema_js_1.pgArrayType)(baseType),
|
|
511
|
+
maxLength: row.character_maximum_length ?? undefined,
|
|
512
|
+
};
|
|
513
|
+
const columns = columnsByTable.get(row.table_name) ?? [];
|
|
514
|
+
columns.push(column);
|
|
515
|
+
columnsByTable.set(row.table_name, columns);
|
|
516
|
+
}
|
|
517
|
+
const pkByTable = new Map();
|
|
518
|
+
for (const row of pkResult.rows) {
|
|
519
|
+
if (!tableSet.has(row.table_name))
|
|
520
|
+
continue;
|
|
521
|
+
const columns = pkByTable.get(row.table_name) ?? [];
|
|
522
|
+
columns.push(row.column_name);
|
|
523
|
+
pkByTable.set(row.table_name, columns);
|
|
524
|
+
}
|
|
525
|
+
const uniqueGroups = new Map();
|
|
526
|
+
for (const row of uniqueResult.rows) {
|
|
527
|
+
if (!tableSet.has(row.table_name))
|
|
528
|
+
continue;
|
|
529
|
+
const key = `${row.table_name}::${row.constraint_name}`;
|
|
530
|
+
const group = uniqueGroups.get(key) ?? { table: row.table_name, columns: [] };
|
|
531
|
+
group.columns.push(row.column_name);
|
|
532
|
+
uniqueGroups.set(key, group);
|
|
533
|
+
}
|
|
534
|
+
const uniqueByTable = new Map();
|
|
535
|
+
for (const group of uniqueGroups.values()) {
|
|
536
|
+
const entries = uniqueByTable.get(group.table) ?? [];
|
|
537
|
+
entries.push(group.columns);
|
|
538
|
+
uniqueByTable.set(group.table, entries);
|
|
539
|
+
}
|
|
540
|
+
const indexesByTable = new Map();
|
|
541
|
+
for (const row of indexResult.rows) {
|
|
542
|
+
if (!tableSet.has(row.tablename))
|
|
543
|
+
continue;
|
|
544
|
+
const columns = extractIndexColumns(row.indexdef);
|
|
545
|
+
const indexes = indexesByTable.get(row.tablename) ?? [];
|
|
546
|
+
indexes.push({
|
|
547
|
+
name: row.indexname,
|
|
548
|
+
columns,
|
|
549
|
+
unique: row.indexdef.includes('UNIQUE'),
|
|
550
|
+
definition: row.indexdef,
|
|
551
|
+
});
|
|
552
|
+
indexesByTable.set(row.tablename, indexes);
|
|
553
|
+
}
|
|
554
|
+
const enums = {};
|
|
555
|
+
for (const row of enumResult.rows) {
|
|
556
|
+
const labels = enums[row.typname] ?? [];
|
|
557
|
+
labels.push(row.enumlabel);
|
|
558
|
+
enums[row.typname] = labels;
|
|
559
|
+
}
|
|
560
|
+
const relationsByTable = buildRelations(tableNames, columnsByTable, pkByTable, fkResult.rows);
|
|
561
|
+
const tables = {};
|
|
562
|
+
for (const tableName of tableNames) {
|
|
563
|
+
const columns = columnsByTable.get(tableName) ?? [];
|
|
564
|
+
const columnMap = {};
|
|
565
|
+
const reverseColumnMap = {};
|
|
566
|
+
const dateColumns = new Set();
|
|
567
|
+
const dialectTypes = {};
|
|
568
|
+
const pgTypes = {};
|
|
569
|
+
const allColumns = [];
|
|
570
|
+
for (const column of columns) {
|
|
571
|
+
columnMap[column.field] = column.name;
|
|
572
|
+
reverseColumnMap[column.name] = column.field;
|
|
573
|
+
allColumns.push(column.name);
|
|
574
|
+
dialectTypes[column.name] = column.dialectType ?? column.pgType;
|
|
575
|
+
pgTypes[column.name] = column.pgType;
|
|
576
|
+
const baseType = column.isArray
|
|
577
|
+
? (column.dialectType ?? column.pgType).slice(1)
|
|
578
|
+
: (column.dialectType ?? column.pgType);
|
|
579
|
+
if ((0, schema_js_1.isDateType)(baseType))
|
|
580
|
+
dateColumns.add(column.name);
|
|
581
|
+
}
|
|
582
|
+
tables[tableName] = {
|
|
583
|
+
name: tableName,
|
|
584
|
+
columns,
|
|
585
|
+
columnMap,
|
|
586
|
+
reverseColumnMap,
|
|
587
|
+
dateColumns,
|
|
588
|
+
dialectTypes,
|
|
589
|
+
pgTypes,
|
|
590
|
+
allColumns,
|
|
591
|
+
primaryKey: pkByTable.get(tableName) ?? [],
|
|
592
|
+
uniqueColumns: uniqueByTable.get(tableName) ?? [],
|
|
593
|
+
relations: relationsByTable.get(tableName) ?? {},
|
|
594
|
+
indexes: indexesByTable.get(tableName) ?? [],
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
return { tables, enums };
|
|
598
|
+
}
|
|
599
|
+
function buildRelations(tableNames, columnsByTable, pkByTable, rows) {
|
|
600
|
+
const tableSet = new Set(tableNames);
|
|
601
|
+
const groups = new Map();
|
|
602
|
+
for (const row of rows) {
|
|
603
|
+
if (!tableSet.has(row.source_table) || !tableSet.has(row.target_table))
|
|
604
|
+
continue;
|
|
605
|
+
const group = groups.get(row.constraint_name) ?? {
|
|
606
|
+
sourceTable: row.source_table,
|
|
607
|
+
sourceColumns: [],
|
|
608
|
+
targetTable: row.target_table,
|
|
609
|
+
targetColumns: [],
|
|
610
|
+
constraintName: row.constraint_name,
|
|
611
|
+
};
|
|
612
|
+
group.sourceColumns.push(row.source_column);
|
|
613
|
+
group.targetColumns.push(row.target_column);
|
|
614
|
+
groups.set(row.constraint_name, group);
|
|
615
|
+
}
|
|
616
|
+
const foreignKeys = [...groups.values()];
|
|
617
|
+
const fkCounts = new Map();
|
|
618
|
+
for (const fk of foreignKeys) {
|
|
619
|
+
const key = `${fk.sourceTable}→${fk.targetTable}`;
|
|
620
|
+
fkCounts.set(key, (fkCounts.get(key) ?? 0) + 1);
|
|
621
|
+
}
|
|
622
|
+
const relations = new Map();
|
|
623
|
+
for (const fk of foreignKeys) {
|
|
624
|
+
const pairKey = `${fk.sourceTable}→${fk.targetTable}`;
|
|
625
|
+
const needsDisambiguation = (fkCounts.get(pairKey) ?? 0) > 1;
|
|
626
|
+
const foreignKey = oneOrMany(fk.sourceColumns);
|
|
627
|
+
const referenceKey = oneOrMany(fk.targetColumns);
|
|
628
|
+
const belongsToName = needsDisambiguation
|
|
629
|
+
? fk.sourceColumns.length === 1
|
|
630
|
+
? (0, schema_js_1.snakeToCamel)(fk.sourceColumns[0].replace(/_id$/, ''))
|
|
631
|
+
: (0, schema_js_1.snakeToCamel)(fk.constraintName.replace(/^fk_/, '').replace(/_fkey$/, ''))
|
|
632
|
+
: (0, schema_js_1.singularize)((0, schema_js_1.snakeToCamel)(fk.targetTable));
|
|
633
|
+
const hasManyName = needsDisambiguation
|
|
634
|
+
? fk.sourceColumns.length === 1
|
|
635
|
+
? (0, schema_js_1.snakeToCamel)(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
|
|
636
|
+
: (0, schema_js_1.snakeToCamel)(`${fk.sourceTable}_by_${fk.constraintName.replace(/^fk_/, '').replace(/_fkey$/, '')}`)
|
|
637
|
+
: (0, schema_js_1.snakeToCamel)(fk.sourceTable);
|
|
638
|
+
const sourceRels = relations.get(fk.sourceTable) ?? {};
|
|
639
|
+
sourceRels[belongsToName] = {
|
|
640
|
+
type: 'belongsTo',
|
|
641
|
+
name: belongsToName,
|
|
642
|
+
from: fk.sourceTable,
|
|
643
|
+
to: fk.targetTable,
|
|
644
|
+
foreignKey,
|
|
645
|
+
referenceKey,
|
|
646
|
+
};
|
|
647
|
+
relations.set(fk.sourceTable, sourceRels);
|
|
648
|
+
const targetRels = relations.get(fk.targetTable) ?? {};
|
|
649
|
+
targetRels[hasManyName] = {
|
|
650
|
+
type: 'hasMany',
|
|
651
|
+
name: hasManyName,
|
|
652
|
+
from: fk.targetTable,
|
|
653
|
+
to: fk.sourceTable,
|
|
654
|
+
foreignKey,
|
|
655
|
+
referenceKey,
|
|
656
|
+
};
|
|
657
|
+
relations.set(fk.targetTable, targetRels);
|
|
658
|
+
}
|
|
659
|
+
addManyToManyRelations(tableNames, columnsByTable, pkByTable, foreignKeys, relations);
|
|
660
|
+
return relations;
|
|
661
|
+
}
|
|
662
|
+
function addManyToManyRelations(tableNames, columnsByTable, pkByTable, foreignKeys, relations) {
|
|
663
|
+
for (const tableName of tableNames) {
|
|
664
|
+
const pk = pkByTable.get(tableName) ?? [];
|
|
665
|
+
if (pk.length !== 2)
|
|
666
|
+
continue;
|
|
667
|
+
const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
|
|
668
|
+
if (tableFks.length !== 2 || tableFks.some((fk) => fk.sourceColumns.length !== 1))
|
|
669
|
+
continue;
|
|
670
|
+
const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
|
|
671
|
+
const pkSet = new Set(pk);
|
|
672
|
+
if (!fkCols.every((column) => pkSet.has(column)) || new Set(fkCols).size !== 2)
|
|
673
|
+
continue;
|
|
674
|
+
const [fkA, fkB] = tableFks;
|
|
675
|
+
if (fkA.targetTable === fkB.targetTable)
|
|
676
|
+
continue;
|
|
677
|
+
const junctionColumns = (columnsByTable.get(tableName) ?? []).map((column) => column.name);
|
|
678
|
+
if (junctionColumns.length !== 2)
|
|
679
|
+
continue;
|
|
680
|
+
addManyToManyDirection(relations, tableName, fkA, fkB);
|
|
681
|
+
addManyToManyDirection(relations, tableName, fkB, fkA);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
function addManyToManyDirection(relations, junctionTable, self, other) {
|
|
685
|
+
const sourceTable = self.targetTable;
|
|
686
|
+
const targetTable = other.targetTable;
|
|
687
|
+
const relName = (0, schema_js_1.snakeToCamel)(targetTable);
|
|
688
|
+
const tableRelations = relations.get(sourceTable) ?? {};
|
|
689
|
+
if (tableRelations[relName])
|
|
690
|
+
return;
|
|
691
|
+
tableRelations[relName] = {
|
|
692
|
+
type: 'manyToMany',
|
|
693
|
+
name: relName,
|
|
694
|
+
from: sourceTable,
|
|
695
|
+
to: targetTable,
|
|
696
|
+
referenceKey: oneOrMany(self.targetColumns),
|
|
697
|
+
foreignKey: oneOrMany(self.targetColumns),
|
|
698
|
+
through: {
|
|
699
|
+
table: junctionTable,
|
|
700
|
+
sourceKey: self.sourceColumns[0],
|
|
701
|
+
targetKey: other.sourceColumns[0],
|
|
702
|
+
},
|
|
703
|
+
};
|
|
704
|
+
relations.set(sourceTable, tableRelations);
|
|
705
|
+
}
|
|
706
|
+
async function estimateRows(client, schema) {
|
|
707
|
+
const result = await client.query(`SELECT c.relname, c.reltuples::bigint::text AS reltuples
|
|
708
|
+
FROM pg_class c
|
|
709
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
710
|
+
WHERE n.nspname = $1 AND c.relkind = 'r'`, [schema]);
|
|
711
|
+
const counts = new Map();
|
|
712
|
+
for (const row of result.rows)
|
|
713
|
+
counts.set(row.relname, Math.max(0, Number(row.reltuples)));
|
|
714
|
+
return counts;
|
|
715
|
+
}
|
|
716
|
+
function requireTable(metadata, tableName) {
|
|
717
|
+
const table = metadata.tables[tableName];
|
|
718
|
+
if (!table) {
|
|
719
|
+
const available = Object.keys(metadata.tables).join(', ') || '(none)';
|
|
720
|
+
throw jsonRpcError(-32602, `Unknown table "${tableName}". Available: ${available}`);
|
|
721
|
+
}
|
|
722
|
+
return table;
|
|
723
|
+
}
|
|
724
|
+
function extractIndexColumns(indexdef) {
|
|
725
|
+
const match = indexdef.match(/\((.+)\)/);
|
|
726
|
+
if (!match)
|
|
727
|
+
return [];
|
|
728
|
+
return match[1].split(',').map((column) => column
|
|
729
|
+
.trim()
|
|
730
|
+
.replace(/ (ASC|DESC)$/i, '')
|
|
731
|
+
.replace(/^"|"$/g, ''));
|
|
732
|
+
}
|
|
733
|
+
function oneOrMany(columns) {
|
|
734
|
+
return columns.length === 1 ? columns[0] : columns;
|
|
735
|
+
}
|
|
736
|
+
function optionalLimit(value) {
|
|
737
|
+
if (value === undefined)
|
|
738
|
+
return 50;
|
|
739
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 50) {
|
|
740
|
+
throw jsonRpcError(-32602, 'limit must be an integer between 1 and 50');
|
|
741
|
+
}
|
|
742
|
+
return value;
|
|
743
|
+
}
|
|
744
|
+
function requiredString(args, key) {
|
|
745
|
+
const value = args[key];
|
|
746
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
747
|
+
throw jsonRpcError(-32602, `${key} is required`);
|
|
748
|
+
}
|
|
749
|
+
return value;
|
|
750
|
+
}
|
|
751
|
+
function sha256(content) {
|
|
752
|
+
return (0, node_crypto_1.createHash)('sha256').update(content, 'utf-8').digest('hex');
|
|
753
|
+
}
|
|
754
|
+
function isJsonRpcRequest(value) {
|
|
755
|
+
return isObject(value) && value.jsonrpc === '2.0' && typeof value.method === 'string';
|
|
756
|
+
}
|
|
757
|
+
function isObject(value) {
|
|
758
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
759
|
+
}
|
|
760
|
+
function errorMessage(err) {
|
|
761
|
+
return err instanceof Error ? err.message : String(err);
|
|
762
|
+
}
|
|
763
|
+
function jsonRpcError(code, message, data) {
|
|
764
|
+
const err = new Error(message);
|
|
765
|
+
err.rpcError = data === undefined ? { code, message } : { code, message, data };
|
|
766
|
+
return err;
|
|
767
|
+
}
|
|
768
|
+
function toJsonRpcError(err) {
|
|
769
|
+
if (err instanceof Error && 'rpcError' in err) {
|
|
770
|
+
return err.rpcError;
|
|
771
|
+
}
|
|
772
|
+
return { code: -32603, message: 'Internal error', data: errorMessage(err) };
|
|
773
|
+
}
|
|
774
|
+
function errorResponse(id, code, message, data) {
|
|
775
|
+
return { jsonrpc: '2.0', id, error: data === undefined ? { code, message } : { code, message, data } };
|
|
776
|
+
}
|
|
777
|
+
async function runMcpServer(options) {
|
|
778
|
+
const handle = startMcpServer(options);
|
|
779
|
+
await new Promise((resolve) => {
|
|
780
|
+
const shutdown = async () => {
|
|
781
|
+
await handle.dispose();
|
|
782
|
+
resolve();
|
|
783
|
+
};
|
|
784
|
+
process.once('SIGINT', shutdown);
|
|
785
|
+
process.once('SIGTERM', shutdown);
|
|
786
|
+
process.stdin.once('end', shutdown);
|
|
787
|
+
});
|
|
788
|
+
}
|