@yejiming/dsh-data-agent 0.0.5 → 0.0.9
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.en.md +127 -128
- package/README.md +123 -127
- package/lib/client.js +42071 -114
- package/lib/client.js.map +1 -1
- package/lib/command.js +875 -0
- package/lib/{defaults-Bac6QvNt.js → connections-DeauhaZi.js} +427 -19
- package/lib/defaults-DP4RyRh1.js +21 -0
- package/lib/index.js +185 -51
- package/lib/routes.js +94 -170
- package/lib/tool.js +751 -50
- package/lib/types/analysis.d.ts +1071 -0
- package/lib/types/client/AnalysisChart.d.ts +26 -0
- package/lib/types/client/AnalysisDashboard.d.ts +30 -0
- package/lib/types/client/analysis-charts.d.ts +40 -0
- package/lib/types/client/analysis-view-model.d.ts +44 -0
- package/lib/types/client/locales.d.ts +48 -0
- package/lib/types/client/persistence.d.ts +6 -1
- package/lib/types/clients.d.ts +10 -9
- package/lib/types/command.d.ts +41 -0
- package/lib/types/connections.d.ts +115 -40
- package/lib/types/index.d.ts +67 -47
- package/lib/types/routes.d.ts +25 -77
- package/lib/types/sql.d.ts +1 -1
- package/lib/types/storage.d.ts +70 -0
- package/lib/types/structured-read.d.ts +50 -0
- package/lib/types/tool.d.ts +25 -20
- package/lib/types/tui-connection-form.d.ts +98 -0
- package/package.json +61 -4
- package/preset/data-agent/agent.cordis.yml +34 -18
- package/preset/data-agent/preset.yml +2 -2
- package/lib/query-CmhTFklw.js +0 -86
package/lib/index.js
CHANGED
|
@@ -1,43 +1,108 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { o as clientsSchema, t as createConnectionService } from "./connections-DeauhaZi.js";
|
|
2
|
+
import { a as DEFAULT_PRESET_ID, i as DEFAULT_MAX_RESULT_CHARS, o as DEFAULT_QUERY_TIMEOUT_MS, r as DEFAULT_MAX_QUERY_CHARS, t as DEFAULT_CONNECT_TIMEOUT_MS } from "./defaults-DP4RyRh1.js";
|
|
3
|
+
import { access, cp, mkdir, readFile } from "node:fs/promises";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
5
|
import { join, resolve } from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import Storage from "@deepseek-ai/dsh-storage";
|
|
8
|
+
import * as storageDomainPlugin from "@deepseek-ai/dsh-storage-domain";
|
|
9
|
+
import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
|
|
10
|
+
import * as storageJsonPlugin from "@deepseek-ai/dsh-storage-json";
|
|
6
11
|
import z from "schemastery";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
12
|
+
import { z as z$1 } from "zod";
|
|
13
|
+
//#region src/storage.ts
|
|
14
|
+
/**
|
|
15
|
+
* Durable, non-secret connection profiles, session bindings, and form drafts.
|
|
16
|
+
*
|
|
17
|
+
* The domain intentionally excludes passwords, resolved credentials, SQL,
|
|
18
|
+
* table metadata, and client output. Form drafts likewise accept no secret
|
|
19
|
+
* fields. Runtime secrets stay in
|
|
20
|
+
* {@link DataAgentConnectionService}; durable records only retain enough
|
|
21
|
+
* information to rebuild a connection description in another DSH surface.
|
|
22
|
+
* @module @yejiming/dsh-data-agent/storage
|
|
23
|
+
*/
|
|
24
|
+
/** Storage-domain identity. Bump the version only with an explicit migration. */
|
|
25
|
+
const CONNECTION_STORAGE_DOMAIN = "data_agent_connections";
|
|
26
|
+
/** Durable profile schema. There is deliberately no `password` field. */
|
|
27
|
+
const persistedConnectionProfileSchema = z$1.object({
|
|
28
|
+
name: z$1.string().min(1).optional(),
|
|
29
|
+
type: z$1.enum([
|
|
30
|
+
"mysql",
|
|
31
|
+
"postgres",
|
|
32
|
+
"sqlite",
|
|
33
|
+
"oracle",
|
|
34
|
+
"hive",
|
|
35
|
+
"impala"
|
|
36
|
+
]),
|
|
37
|
+
host: z$1.string().optional(),
|
|
38
|
+
port: z$1.number().int().min(1).max(65535).optional(),
|
|
39
|
+
user: z$1.string().optional(),
|
|
40
|
+
database: z$1.string().min(1),
|
|
41
|
+
readonly: z$1.boolean().optional(),
|
|
42
|
+
passwordRef: z$1.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/).optional(),
|
|
43
|
+
updatedAt: z$1.string().min(1)
|
|
44
|
+
}).strict();
|
|
45
|
+
/** Durable session-to-profile binding schema. */
|
|
46
|
+
const sessionConnectionBindingSchema = z$1.object({
|
|
47
|
+
profileId: z$1.string().min(1),
|
|
48
|
+
updatedAt: z$1.string().min(1)
|
|
49
|
+
}).strict();
|
|
50
|
+
/** Session form draft schema. Secret-shaped fields are rejected by strict mode. */
|
|
51
|
+
const persistedConnectionFormDraftSchema = z$1.object({
|
|
52
|
+
type: z$1.enum([
|
|
53
|
+
"mysql",
|
|
54
|
+
"postgres",
|
|
55
|
+
"sqlite",
|
|
56
|
+
"oracle",
|
|
57
|
+
"hive",
|
|
58
|
+
"impala"
|
|
59
|
+
]),
|
|
60
|
+
host: z$1.string(),
|
|
61
|
+
port: z$1.string(),
|
|
62
|
+
user: z$1.string(),
|
|
63
|
+
database: z$1.string(),
|
|
64
|
+
readonly: z$1.boolean(),
|
|
65
|
+
updatedAt: z$1.string().min(1)
|
|
66
|
+
}).strict();
|
|
67
|
+
/** Single source of truth for the storage layout and durable validation. */
|
|
68
|
+
const connectionStorageSpec = defineDomain({
|
|
69
|
+
name: CONNECTION_STORAGE_DOMAIN,
|
|
70
|
+
version: 1,
|
|
71
|
+
tables: {
|
|
72
|
+
profiles: domainTable(persistedConnectionProfileSchema),
|
|
73
|
+
bindings: domainTable(sessionConnectionBindingSchema),
|
|
74
|
+
drafts: domainTable(persistedConnectionFormDraftSchema)
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
/** Project a typed DSH domain handle onto the service's persistence seam. */
|
|
78
|
+
function createDomainConnectionPersistence(domain) {
|
|
79
|
+
const profiles = domain.table("profiles");
|
|
80
|
+
const bindings = domain.table("bindings");
|
|
81
|
+
const drafts = domain.table("drafts");
|
|
25
82
|
return {
|
|
26
|
-
|
|
27
|
-
|
|
83
|
+
getProfile(profileId) {
|
|
84
|
+
return profiles.get(profileId);
|
|
85
|
+
},
|
|
86
|
+
putProfile(profileId, profile) {
|
|
87
|
+
return profiles.put(profileId, profile);
|
|
88
|
+
},
|
|
89
|
+
deleteProfile(profileId) {
|
|
90
|
+
return profiles.delete(profileId);
|
|
28
91
|
},
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return connection === void 0 ? void 0 : summarize(connection);
|
|
92
|
+
getBinding(sessionId) {
|
|
93
|
+
return bindings.get(sessionId);
|
|
32
94
|
},
|
|
33
|
-
|
|
34
|
-
return
|
|
95
|
+
putBinding(sessionId, binding) {
|
|
96
|
+
return bindings.put(sessionId, binding);
|
|
35
97
|
},
|
|
36
|
-
|
|
37
|
-
return
|
|
98
|
+
deleteBinding(sessionId) {
|
|
99
|
+
return bindings.delete(sessionId);
|
|
38
100
|
},
|
|
39
|
-
|
|
40
|
-
|
|
101
|
+
getDraft(sessionId) {
|
|
102
|
+
return drafts.get(sessionId);
|
|
103
|
+
},
|
|
104
|
+
putDraft(sessionId, draft) {
|
|
105
|
+
return drafts.put(sessionId, draft);
|
|
41
106
|
}
|
|
42
107
|
};
|
|
43
108
|
}
|
|
@@ -45,8 +110,8 @@ function createConnectionStore() {
|
|
|
45
110
|
//#region src/index.ts
|
|
46
111
|
/**
|
|
47
112
|
* Data Agent server half for the dsh web GUI. The host row provides the
|
|
48
|
-
* `dataAgentConnections` service (
|
|
49
|
-
*
|
|
113
|
+
* `dataAgentConnections` service (shared non-secret profile/binding storage;
|
|
114
|
+
* temporary passwords stay process-local), seeds config connections (`connections`, `'*'` =
|
|
50
115
|
* wildcard default), and installs the `data-agent` agent preset into
|
|
51
116
|
* `$DSH_HOME/.agent-presets/` (idempotent, never overwrites a user-edited
|
|
52
117
|
* directory).
|
|
@@ -61,7 +126,7 @@ function createConnectionStore() {
|
|
|
61
126
|
/** Cordis plugin name (diagnostics only). */
|
|
62
127
|
const name = "data-agent";
|
|
63
128
|
/** Services required before the store can serve. */
|
|
64
|
-
const inject = ["subprocess"];
|
|
129
|
+
const inject = ["subprocess", "credentials"];
|
|
65
130
|
/** Loader schema with deployment defaults (no library defaults). */
|
|
66
131
|
const Config = z.object({
|
|
67
132
|
presetId: z.string().default(DEFAULT_PRESET_ID),
|
|
@@ -70,7 +135,9 @@ const Config = z.object({
|
|
|
70
135
|
introspectMaxTables: z.number().step(1).min(1).default(500),
|
|
71
136
|
queryTimeoutMs: z.number().step(1).min(1e3).default(DEFAULT_QUERY_TIMEOUT_MS),
|
|
72
137
|
maxResultChars: z.number().step(1).min(1024).default(DEFAULT_MAX_RESULT_CHARS),
|
|
138
|
+
maxQueryChars: z.number().step(1).min(1024).default(DEFAULT_MAX_QUERY_CHARS),
|
|
73
139
|
readonly: z.boolean().default(false),
|
|
140
|
+
persistConnections: z.boolean().default(true),
|
|
74
141
|
clients: clientsSchema,
|
|
75
142
|
connections: z.dict(z.object({
|
|
76
143
|
type: z.union([
|
|
@@ -85,7 +152,9 @@ const Config = z.object({
|
|
|
85
152
|
port: z.natural(),
|
|
86
153
|
user: z.string(),
|
|
87
154
|
database: z.string(),
|
|
88
|
-
readonly: z.boolean()
|
|
155
|
+
readonly: z.boolean(),
|
|
156
|
+
passwordRef: z.string().pattern(/^[A-Za-z_][A-Za-z0-9_]*$/),
|
|
157
|
+
password: z.never().hidden()
|
|
89
158
|
})).default({})
|
|
90
159
|
});
|
|
91
160
|
/**
|
|
@@ -109,6 +178,7 @@ async function installPreset(ctx, presetId) {
|
|
|
109
178
|
try {
|
|
110
179
|
await access(targetDir);
|
|
111
180
|
ctx.logger.info("data-agent: preset \"%s\" already present at %s, skipping install", presetId, targetDir);
|
|
181
|
+
await diagnoseExistingPreset(ctx, targetDir);
|
|
112
182
|
return;
|
|
113
183
|
} catch {}
|
|
114
184
|
const sourceDir = fileURLToPath(new URL("../preset/data-agent/", import.meta.url));
|
|
@@ -117,7 +187,27 @@ async function installPreset(ctx, presetId) {
|
|
|
117
187
|
await cp(sourceDir, targetDir, { recursive: true });
|
|
118
188
|
ctx.logger.info("data-agent: installed preset \"%s\" to %s", presetId, targetDir);
|
|
119
189
|
} catch (error) {
|
|
120
|
-
ctx.logger.warn("data-agent: failed to install preset \"%s\" to %s (%s); copy preset/data-agent/ manually to enable the
|
|
190
|
+
ctx.logger.warn("data-agent: failed to install preset \"%s\" to %s (%s); copy preset/data-agent/ manually to enable the 数据模式 preset", presetId, targetDir, error instanceof Error ? error.message : String(error));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Exact profile-local package installation command used by diagnostics/docs. */
|
|
194
|
+
function profileInstallCommand(profile) {
|
|
195
|
+
return `dsh plugin --profile ${profile} add @yejiming/dsh-data-agent`;
|
|
196
|
+
}
|
|
197
|
+
/** Actionable diagnostic for a roster-visible preset whose profile lacks this package. */
|
|
198
|
+
function missingProfileDependencyMessage(profile) {
|
|
199
|
+
return `data-agent preset is visible, but profile "${profile}" cannot resolve @yejiming/dsh-data-agent/tool or /command. Run: ${profileInstallCommand(profile)}`;
|
|
200
|
+
}
|
|
201
|
+
/** Warn without overwriting when a pre-existing user preset lacks the command row. */
|
|
202
|
+
async function diagnoseExistingPreset(ctx, targetDir) {
|
|
203
|
+
const composition = join(targetDir, "agent.cordis.yml");
|
|
204
|
+
try {
|
|
205
|
+
if ((await readFile(composition, "utf8")).includes("@yejiming/dsh-data-agent/command")) return;
|
|
206
|
+
const profile = process.env.DSH_PROFILE?.trim();
|
|
207
|
+
const installHint = profile !== void 0 && profile.length > 0 ? profileInstallCommand(profile) : `${profileInstallCommand("web")};${profileInstallCommand("dsh-tui")}`;
|
|
208
|
+
ctx.logger.warn("data-agent: existing user preset at %s does not contain the database-command row; the file was not overwritten. Back it up, then add name: \"@yejiming/dsh-data-agent/command\". Also install this package in the target profile: %s", composition, installHint);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
ctx.logger.warn("data-agent: could not inspect existing preset %s (%s); it was not overwritten", composition, error instanceof Error ? error.message : String(error));
|
|
121
211
|
}
|
|
122
212
|
}
|
|
123
213
|
/**
|
|
@@ -127,7 +217,7 @@ async function installPreset(ctx, presetId) {
|
|
|
127
217
|
* @param ctx - host cordis context.
|
|
128
218
|
* @param config - validated loader configuration.
|
|
129
219
|
*/
|
|
130
|
-
function apply(ctx, config) {
|
|
220
|
+
async function apply(ctx, config) {
|
|
131
221
|
const resolved = {
|
|
132
222
|
presetId: config.presetId,
|
|
133
223
|
installPreset: config.installPreset,
|
|
@@ -135,24 +225,68 @@ function apply(ctx, config) {
|
|
|
135
225
|
introspectMaxTables: config.introspectMaxTables,
|
|
136
226
|
queryTimeoutMs: config.queryTimeoutMs,
|
|
137
227
|
maxResultChars: config.maxResultChars,
|
|
228
|
+
maxQueryChars: config.maxQueryChars,
|
|
138
229
|
readonly: config.readonly,
|
|
230
|
+
persistConnections: config.persistConnections,
|
|
139
231
|
clients: config.clients,
|
|
140
232
|
connections: config.connections
|
|
141
233
|
};
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
234
|
+
const mountService = (scope, persistence) => {
|
|
235
|
+
const store = createConnectionService(scope, {
|
|
236
|
+
connectTimeoutMs: resolved.connectTimeoutMs,
|
|
237
|
+
queryTimeoutMs: resolved.queryTimeoutMs,
|
|
238
|
+
maxResultChars: resolved.maxResultChars,
|
|
239
|
+
maxQueryChars: resolved.maxQueryChars,
|
|
240
|
+
introspectMaxTables: resolved.introspectMaxTables,
|
|
241
|
+
readonly: resolved.readonly,
|
|
242
|
+
clients: resolved.clients
|
|
243
|
+
}, persistence);
|
|
244
|
+
scope.provide("dataAgentConnections", store);
|
|
245
|
+
for (const [sessionId, spec] of Object.entries(resolved.connections)) {
|
|
246
|
+
const connection = {
|
|
247
|
+
type: spec.type,
|
|
248
|
+
database: spec.type === "sqlite" ? resolve(process.cwd(), spec.database) : spec.database,
|
|
249
|
+
...spec.host !== void 0 ? { host: spec.host } : {},
|
|
250
|
+
...spec.port !== void 0 ? { port: spec.port } : {},
|
|
251
|
+
...spec.user !== void 0 ? { user: spec.user } : {},
|
|
252
|
+
...spec.passwordRef !== void 0 ? { passwordRef: spec.passwordRef } : {},
|
|
253
|
+
...spec.readonly !== void 0 ? { readonly: spec.readonly } : {}
|
|
254
|
+
};
|
|
255
|
+
store.set(sessionId, connection);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
if (resolved.persistConnections) {
|
|
259
|
+
const domain = await (await ensureStorageDomain(ctx)).open(connectionStorageSpec);
|
|
260
|
+
ctx.effect(() => () => domain.close(), "data-agent: close connection storage domain");
|
|
261
|
+
mountService(ctx, createDomainConnectionPersistence(domain));
|
|
262
|
+
} else {
|
|
263
|
+
ctx.logger.warn("data-agent: persistConnections=false; connection state is process-local and cannot restore across Web/TUI");
|
|
264
|
+
mountService(ctx);
|
|
265
|
+
}
|
|
266
|
+
if (resolved.installPreset) await installPreset(ctx, resolved.presetId);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Reuse a surface-provided storage stack (Web) or mount the same JSON stack
|
|
270
|
+
* when an interactive profile such as dsh-tui does not ship one.
|
|
271
|
+
*/
|
|
272
|
+
async function ensureStorageDomain(ctx) {
|
|
273
|
+
const existing = ctx.get("storageDomain");
|
|
274
|
+
if (existing !== void 0) return existing;
|
|
275
|
+
ctx.logger.info("data-agent: storageDomain is absent; mounting the JSON storage stack for this profile");
|
|
276
|
+
let storage = ctx.get("storage");
|
|
277
|
+
if (storage === void 0) {
|
|
278
|
+
await ctx.plugin(Storage);
|
|
279
|
+
storage = ctx.get("storage");
|
|
280
|
+
}
|
|
281
|
+
if (storage === void 0) throw new Error("data-agent: failed to mount DSH storage hub");
|
|
282
|
+
if (!storage.backend.names().includes("json")) await ctx.plugin(storageJsonPlugin, { root: join(resolveDshHome(), "storages") });
|
|
283
|
+
let facility = ctx.get("storageDomain");
|
|
284
|
+
if (facility === void 0) {
|
|
285
|
+
await ctx.plugin(storageDomainPlugin, { backend: "json" });
|
|
286
|
+
facility = ctx.get("storageDomain");
|
|
154
287
|
}
|
|
155
|
-
if (
|
|
288
|
+
if (facility === void 0) throw new Error("data-agent: failed to mount DSH storage-domain facility");
|
|
289
|
+
return facility;
|
|
156
290
|
}
|
|
157
291
|
//#endregion
|
|
158
|
-
export { Config, apply, inject, installPreset, name, resolveDshHome };
|
|
292
|
+
export { Config, apply, inject, installPreset, missingProfileDependencyMessage, name, profileInstallCommand, resolveDshHome };
|
package/lib/routes.js
CHANGED
|
@@ -1,20 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { t as runClientQuery } from "./query-CmhTFklw.js";
|
|
1
|
+
import { i as DEFAULT_MAX_RESULT_CHARS, o as DEFAULT_QUERY_TIMEOUT_MS, r as DEFAULT_MAX_QUERY_CHARS, t as DEFAULT_CONNECT_TIMEOUT_MS } from "./defaults-DP4RyRh1.js";
|
|
3
2
|
import { resolve } from "node:path";
|
|
4
3
|
import z from "schemastery";
|
|
5
4
|
//#region src/routes.ts
|
|
6
|
-
/** Cordis plugin name (diagnostics only). */
|
|
7
5
|
const name = "data-agent-routes";
|
|
8
|
-
/**
|
|
9
|
-
* No top-level `inject` export: the row must ACTIVATE even in headless
|
|
10
|
-
* profiles where `webServer` never exists (a permanently pending entry
|
|
11
|
-
* breaks one-shot runs). The routes register through a nested inject fiber
|
|
12
|
-
* the moment the webserver and the connection store are both available.
|
|
13
|
-
*/
|
|
6
|
+
/** Headless profiles activate this row without waiting forever for webServer. */
|
|
14
7
|
const inject = [];
|
|
15
|
-
/** Route prefix owned by this plugin (the browser half calls under it). */
|
|
16
8
|
const DATA_AGENT_PATH = "/plugins/data-agent";
|
|
17
|
-
/** Loader schema with deployment defaults (no library defaults). */
|
|
18
9
|
const Config = z.object({
|
|
19
10
|
connectTimeoutMs: z.number().step(1).min(1e3).default(DEFAULT_CONNECT_TIMEOUT_MS),
|
|
20
11
|
introspectMaxTables: z.number().step(1).min(1).default(500),
|
|
@@ -23,114 +14,43 @@ const Config = z.object({
|
|
|
23
14
|
maxQueryChars: z.number().step(1).min(1024).default(DEFAULT_MAX_QUERY_CHARS),
|
|
24
15
|
readonly: z.boolean().default(false)
|
|
25
16
|
});
|
|
26
|
-
/**
|
|
27
|
-
* Validate an untrusted /connect body; sqlite paths resolve to absolute
|
|
28
|
-
* (the client resolves the path relative to its own cwd, so the server pins
|
|
29
|
-
* it at connect time). Oracle/Hive/Impala follow the mysql/postgres shape:
|
|
30
|
-
* host/port/user/database (Oracle database = service name/SID, Hive/Impala
|
|
31
|
-
* database = default schema).
|
|
32
|
-
*/
|
|
17
|
+
/** Validate the Web wire shape while retaining temporary-password compatibility. */
|
|
33
18
|
function validateConnectBody(value, cwd = process.cwd()) {
|
|
34
19
|
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("请求体必须是 JSON 对象");
|
|
35
20
|
const candidate = value;
|
|
36
|
-
const sessionId = candidate.sessionId;
|
|
37
|
-
if (typeof sessionId !== "string" || sessionId.length === 0) throw new Error("sessionId 必须是非空字符串");
|
|
21
|
+
const sessionId = requireString(candidate.sessionId, "sessionId");
|
|
38
22
|
const type = candidate.type;
|
|
39
|
-
if (type
|
|
40
|
-
const database = candidate.database;
|
|
41
|
-
|
|
42
|
-
|
|
23
|
+
if (!isDatabaseType(type)) throw new Error("type 必须是 \"mysql\"、\"postgres\"、\"sqlite\"、\"oracle\"、\"hive\" 或 \"impala\"");
|
|
24
|
+
const database = requireString(candidate.database, "database");
|
|
25
|
+
const password = optionalString(candidate.password, "password");
|
|
26
|
+
const passwordRef = optionalString(candidate.passwordRef, "passwordRef");
|
|
27
|
+
if (password !== void 0 && passwordRef !== void 0) throw new Error("password 与 passwordRef 不能同时提供");
|
|
28
|
+
const readonly = optionalBoolean(candidate.readonly, "readonly");
|
|
29
|
+
const profileId = optionalString(candidate.profileId, "profileId");
|
|
30
|
+
const profileName = optionalString(candidate.name, "name");
|
|
31
|
+
const request = {
|
|
43
32
|
sessionId,
|
|
44
33
|
type,
|
|
45
|
-
database: resolve(cwd, database)
|
|
34
|
+
database: type === "sqlite" ? resolve(cwd, database) : database
|
|
46
35
|
};
|
|
47
|
-
|
|
48
|
-
if (
|
|
36
|
+
if (readonly !== void 0) request.readonly = readonly;
|
|
37
|
+
if (profileId !== void 0) request.profileId = profileId;
|
|
38
|
+
if (profileName !== void 0) request.name = profileName;
|
|
39
|
+
if (type === "sqlite") return request;
|
|
40
|
+
const host = optionalString(candidate.host, "host");
|
|
41
|
+
const user = optionalString(candidate.user, "user");
|
|
49
42
|
const port = candidate.port;
|
|
50
43
|
if (port !== void 0 && (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535)) throw new Error("port 必须是 1-65535 的整数");
|
|
51
|
-
|
|
52
|
-
if (user !== void 0
|
|
53
|
-
|
|
54
|
-
if (password !== void 0
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const connection = {
|
|
58
|
-
type,
|
|
59
|
-
database
|
|
60
|
-
};
|
|
61
|
-
if (typeof host === "string" && host.length > 0) connection.host = host;
|
|
62
|
-
if (port !== void 0) connection.port = port;
|
|
63
|
-
if (typeof user === "string" && user.length > 0) connection.user = user;
|
|
64
|
-
if (typeof password === "string" && password.length > 0) connection.password = password;
|
|
65
|
-
if (readonly !== void 0) connection.readonly = readonly;
|
|
66
|
-
return {
|
|
67
|
-
sessionId,
|
|
68
|
-
type,
|
|
69
|
-
database,
|
|
70
|
-
...connection.host !== void 0 ? { host: connection.host } : {},
|
|
71
|
-
...connection.port !== void 0 ? { port: connection.port } : {},
|
|
72
|
-
...connection.user !== void 0 ? { user: connection.user } : {},
|
|
73
|
-
...connection.password !== void 0 ? { password: connection.password } : {},
|
|
74
|
-
...connection.readonly !== void 0 ? { readonly: connection.readonly } : {}
|
|
75
|
-
};
|
|
44
|
+
if (host !== void 0) request.host = host;
|
|
45
|
+
if (user !== void 0) request.user = user;
|
|
46
|
+
if (typeof port === "number") request.port = port;
|
|
47
|
+
if (password !== void 0) request.password = password;
|
|
48
|
+
if (passwordRef !== void 0) request.passwordRef = passwordRef;
|
|
49
|
+
return request;
|
|
76
50
|
}
|
|
77
|
-
/**
|
|
78
|
-
function
|
|
79
|
-
|
|
80
|
-
sanitizeIdentifier(type, value);
|
|
81
|
-
return value;
|
|
82
|
-
}
|
|
83
|
-
/**
|
|
84
|
-
* Mount the data-agent routes against the host webserver, when one exists.
|
|
85
|
-
* The registration rides a nested inject fiber so this row activates in every
|
|
86
|
-
* profile; headless profiles simply never get routes.
|
|
87
|
-
* @param ctx - host cordis context.
|
|
88
|
-
* @param config - validated loader configuration.
|
|
89
|
-
*/
|
|
90
|
-
function apply(ctx, config) {
|
|
91
|
-
ctx.inject([
|
|
92
|
-
"webServer",
|
|
93
|
-
"subprocess",
|
|
94
|
-
"dataAgentConnections"
|
|
95
|
-
], (scope) => {
|
|
96
|
-
const store = scope.dataAgentConnections;
|
|
97
|
-
const connectOptions = {
|
|
98
|
-
clients: {},
|
|
99
|
-
timeoutMs: config.connectTimeoutMs,
|
|
100
|
-
maxResultChars: config.maxResultChars
|
|
101
|
-
};
|
|
102
|
-
const queryOptions = {
|
|
103
|
-
clients: {},
|
|
104
|
-
timeoutMs: config.queryTimeoutMs,
|
|
105
|
-
maxResultChars: config.maxResultChars
|
|
106
|
-
};
|
|
107
|
-
const introspectMaxTables = config.introspectMaxTables;
|
|
108
|
-
/** Collect the request body into a parsed JSON value. */
|
|
109
|
-
const readJson = async (req) => {
|
|
110
|
-
const chunks = [];
|
|
111
|
-
for await (const chunk of req) chunks.push(chunk);
|
|
112
|
-
const raw = Buffer.concat(chunks).toString("utf8");
|
|
113
|
-
if (raw.length === 0) return {};
|
|
114
|
-
return JSON.parse(raw);
|
|
115
|
-
};
|
|
116
|
-
/** The stored connection for one session, failing loud when absent. */
|
|
117
|
-
const requireConnection = (sessionId) => {
|
|
118
|
-
const connection = store.getWithSecret(sessionId);
|
|
119
|
-
if (connection === void 0) throw new Error("请先连接数据库(未找到当前会话的连接),再执行该操作");
|
|
120
|
-
return connection;
|
|
121
|
-
};
|
|
122
|
-
/**
|
|
123
|
-
* Run one metadata query in machine-readable mode and return its stdout;
|
|
124
|
-
* a non-zero exit throws with the client's stderr as the message.
|
|
125
|
-
*/
|
|
126
|
-
const runMetadata = async (connection, kind, schema, table) => {
|
|
127
|
-
const result = await runClientQuery(scope, connection, metadataQuery(kind, connection.type, schema, table), queryOptions, new AbortController().signal, true);
|
|
128
|
-
if (result.exitCode !== 0) {
|
|
129
|
-
const detail = result.stderr.trim() !== "" ? result.stderr.trim() : result.stdout.trim();
|
|
130
|
-
throw new Error(`元数据查询失败(exit ${result.exitCode}):${detail}`);
|
|
131
|
-
}
|
|
132
|
-
return result.stdout;
|
|
133
|
-
};
|
|
51
|
+
/** Register Web routes only when both the webserver and shared service exist. */
|
|
52
|
+
function apply(ctx, _config) {
|
|
53
|
+
ctx.inject(["webServer", "dataAgentConnections"], (scope) => {
|
|
134
54
|
scope.effect(() => {
|
|
135
55
|
const dispose = scope.webServer.register({
|
|
136
56
|
kind: "prefix",
|
|
@@ -143,100 +63,73 @@ function apply(ctx, config) {
|
|
|
143
63
|
try {
|
|
144
64
|
const url = new URL(req.url ?? "/", "http://dsh.internal");
|
|
145
65
|
const segments = url.pathname.slice(19).split("/").filter(Boolean);
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
const listing = await runClientQuery(scope, connection, tableListingSql(connection.type, connection), connectOptions, new AbortController().signal, true);
|
|
158
|
-
if (listing.exitCode !== 0) {
|
|
159
|
-
const detail = listing.stderr.trim() !== "" ? listing.stderr.trim() : listing.stdout.trim();
|
|
66
|
+
const signal = requestSignal(req);
|
|
67
|
+
if (req.method === "POST" && routeIs(segments, "connect")) {
|
|
68
|
+
try {
|
|
69
|
+
const { sessionId, ...input } = validateConnectBody(await readJson(req));
|
|
70
|
+
const result = await scope.dataAgentConnections.connect(sessionId, input, signal);
|
|
71
|
+
writeJson(200, {
|
|
72
|
+
ok: true,
|
|
73
|
+
tables: result.tables,
|
|
74
|
+
summary: result.summary
|
|
75
|
+
});
|
|
76
|
+
} catch (error) {
|
|
160
77
|
writeJson(200, {
|
|
161
78
|
ok: false,
|
|
162
|
-
error:
|
|
79
|
+
error: error instanceof Error ? error.message : String(error)
|
|
163
80
|
});
|
|
164
|
-
return;
|
|
165
81
|
}
|
|
166
|
-
const tables = parseTableListing(connection.type, listing.stdout).slice(0, introspectMaxTables);
|
|
167
|
-
connection.tables = tables;
|
|
168
|
-
store.set(request.sessionId, connection);
|
|
169
|
-
writeJson(200, {
|
|
170
|
-
ok: true,
|
|
171
|
-
tables
|
|
172
|
-
});
|
|
173
82
|
return;
|
|
174
83
|
}
|
|
175
|
-
if (req.method === "POST" && segments
|
|
176
|
-
const sessionId = (await readJson(req)).sessionId;
|
|
177
|
-
|
|
178
|
-
store.clear(sessionId);
|
|
84
|
+
if (req.method === "POST" && routeIs(segments, "disconnect")) {
|
|
85
|
+
const sessionId = requireString((await readJson(req)).sessionId, "sessionId");
|
|
86
|
+
await scope.dataAgentConnections.disconnect(sessionId);
|
|
179
87
|
writeJson(200, { ok: true });
|
|
180
88
|
return;
|
|
181
89
|
}
|
|
182
|
-
if (req.method === "GET" && segments
|
|
183
|
-
const sessionId = url.searchParams.get("sessionId")
|
|
184
|
-
const summary =
|
|
90
|
+
if (req.method === "GET" && routeIs(segments, "status")) {
|
|
91
|
+
const sessionId = requireString(url.searchParams.get("sessionId"), "sessionId");
|
|
92
|
+
const summary = await scope.dataAgentConnections.status(sessionId);
|
|
185
93
|
writeJson(200, summary === void 0 ? { connected: false } : {
|
|
186
94
|
connected: true,
|
|
187
95
|
summary
|
|
188
96
|
});
|
|
189
97
|
return;
|
|
190
98
|
}
|
|
191
|
-
if (req.method === "GET" && segments
|
|
192
|
-
const sessionId = url.searchParams.get("sessionId")
|
|
193
|
-
if (sessionId.length === 0) throw new Error("sessionId 不能为空");
|
|
194
|
-
const connection = requireConnection(sessionId);
|
|
195
|
-
const stdout = await runMetadata(connection, "schemas");
|
|
99
|
+
if (req.method === "GET" && routeIs(segments, "schemas")) {
|
|
100
|
+
const sessionId = requireString(url.searchParams.get("sessionId"), "sessionId");
|
|
196
101
|
writeJson(200, {
|
|
197
102
|
ok: true,
|
|
198
|
-
schemas:
|
|
103
|
+
schemas: await scope.dataAgentConnections.listSchemas(sessionId, signal)
|
|
199
104
|
});
|
|
200
105
|
return;
|
|
201
106
|
}
|
|
202
|
-
if (req.method === "GET" && segments
|
|
203
|
-
const sessionId = url.searchParams.get("sessionId")
|
|
204
|
-
|
|
205
|
-
const connection = requireConnection(sessionId);
|
|
206
|
-
const schema = connection.type === "sqlite" ? void 0 : requireIdentifier(connection.type, url.searchParams.get("schema"), "schema");
|
|
207
|
-
const stdout = await runMetadata(connection, "tables", schema);
|
|
107
|
+
if (req.method === "GET" && routeIs(segments, "tables")) {
|
|
108
|
+
const sessionId = requireString(url.searchParams.get("sessionId"), "sessionId");
|
|
109
|
+
const schema = url.searchParams.get("schema") ?? void 0;
|
|
208
110
|
writeJson(200, {
|
|
209
111
|
ok: true,
|
|
210
|
-
tables:
|
|
112
|
+
tables: await scope.dataAgentConnections.listTables(sessionId, schema, signal)
|
|
211
113
|
});
|
|
212
114
|
return;
|
|
213
115
|
}
|
|
214
|
-
if (req.method === "GET" && segments
|
|
215
|
-
const sessionId = url.searchParams.get("sessionId")
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
const schema = connection.type === "sqlite" ? void 0 : requireIdentifier(connection.type, url.searchParams.get("schema"), "schema");
|
|
219
|
-
const table = requireIdentifier(connection.type, url.searchParams.get("table"), "table");
|
|
220
|
-
const stdout = await runMetadata(connection, "describe", schema, table);
|
|
116
|
+
if (req.method === "GET" && routeIs(segments, "describe")) {
|
|
117
|
+
const sessionId = requireString(url.searchParams.get("sessionId"), "sessionId");
|
|
118
|
+
const schema = url.searchParams.get("schema") ?? void 0;
|
|
119
|
+
const table = requireString(url.searchParams.get("table"), "table");
|
|
221
120
|
writeJson(200, {
|
|
222
121
|
ok: true,
|
|
223
|
-
columns:
|
|
122
|
+
columns: await scope.dataAgentConnections.describe(sessionId, schema, table, signal)
|
|
224
123
|
});
|
|
225
124
|
return;
|
|
226
125
|
}
|
|
227
|
-
if (req.method === "POST" && segments
|
|
126
|
+
if (req.method === "POST" && routeIs(segments, "query")) {
|
|
228
127
|
const body = await readJson(req);
|
|
229
|
-
const sessionId = body.sessionId;
|
|
230
|
-
|
|
231
|
-
const sql = body.sql;
|
|
232
|
-
if (typeof sql !== "string" || sql.trim().length === 0) throw new Error("sql 必须是非空字符串");
|
|
233
|
-
if (sql.length > config.maxQueryChars) throw new Error(`sql 超过长度上限(${config.maxQueryChars} 字符)`);
|
|
234
|
-
assertSingleStatement(sql, "/query");
|
|
235
|
-
const connection = requireConnection(sessionId);
|
|
236
|
-
if ((connection.readonly ?? config.readonly) && classifyStatement(sql, connection.type) === "write") throw new Error("当前连接为只读模式,拒绝执行非读语句(仅放行 SELECT/SHOW/DESCRIBE/EXPLAIN/PRAGMA 等)");
|
|
128
|
+
const sessionId = requireString(body.sessionId, "sessionId");
|
|
129
|
+
const sql = requireString(body.sql, "sql");
|
|
237
130
|
writeJson(200, {
|
|
238
131
|
ok: true,
|
|
239
|
-
result: await
|
|
132
|
+
result: await scope.dataAgentConnections.query(sessionId, sql, signal)
|
|
240
133
|
});
|
|
241
134
|
return;
|
|
242
135
|
}
|
|
@@ -252,5 +145,36 @@ function apply(ctx, config) {
|
|
|
252
145
|
}, "data-agent-routes: routes");
|
|
253
146
|
});
|
|
254
147
|
}
|
|
148
|
+
function routeIs(segments, expected) {
|
|
149
|
+
return segments.length === 1 && segments[0] === expected;
|
|
150
|
+
}
|
|
151
|
+
async function readJson(req) {
|
|
152
|
+
const chunks = [];
|
|
153
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
154
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
155
|
+
return raw.length === 0 ? {} : JSON.parse(raw);
|
|
156
|
+
}
|
|
157
|
+
function requestSignal(req) {
|
|
158
|
+
const controller = new AbortController();
|
|
159
|
+
req.once("aborted", () => controller.abort(/* @__PURE__ */ new Error("HTTP request aborted")));
|
|
160
|
+
return controller.signal;
|
|
161
|
+
}
|
|
162
|
+
function requireString(value, label) {
|
|
163
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${label} 必须是非空字符串`);
|
|
164
|
+
return value;
|
|
165
|
+
}
|
|
166
|
+
function optionalString(value, label) {
|
|
167
|
+
if (value === void 0 || value === "") return void 0;
|
|
168
|
+
if (typeof value !== "string") throw new Error(`${label} 必须是字符串`);
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
function optionalBoolean(value, label) {
|
|
172
|
+
if (value === void 0) return void 0;
|
|
173
|
+
if (typeof value !== "boolean") throw new Error(`${label} 必须是布尔值`);
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
function isDatabaseType(value) {
|
|
177
|
+
return value === "mysql" || value === "postgres" || value === "sqlite" || value === "oracle" || value === "hive" || value === "impala";
|
|
178
|
+
}
|
|
255
179
|
//#endregion
|
|
256
180
|
export { Config, DATA_AGENT_PATH, apply, inject, name, validateConnectBody };
|