@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/lib/index.js CHANGED
@@ -1,43 +1,108 @@
1
- import { a as DEFAULT_PRESET_ID, d as clientsSchema, i as DEFAULT_MAX_RESULT_CHARS, o as DEFAULT_QUERY_TIMEOUT_MS, t as DEFAULT_CONNECT_TIMEOUT_MS } from "./defaults-Bac6QvNt.js";
2
- import { access, cp, mkdir } from "node:fs/promises";
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
- /** Build the password-stripped copy of one connection. */
8
- function summarize(connection) {
9
- const summary = {
10
- type: connection.type,
11
- database: connection.database
12
- };
13
- if (connection.host !== void 0) summary.host = connection.host;
14
- if (connection.port !== void 0) summary.port = connection.port;
15
- if (connection.user !== void 0) summary.user = connection.user;
16
- if (connection.readonly !== void 0) summary.readonly = connection.readonly;
17
- if (connection.tables !== void 0) summary.tables = [...connection.tables];
18
- return summary;
19
- }
20
- /** Create a fresh connection store (per-process singleton, one per plugin instance). */
21
- function createConnectionStore() {
22
- const connections = /* @__PURE__ */ new Map();
23
- /** Exact entry first; the wildcard (`'*'`) entry is the fallback. */
24
- const exactOrWildcard = (sessionId) => connections.get(sessionId) ?? connections.get("*");
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
- set(sessionId, connection) {
27
- connections.set(sessionId, connection);
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
- get(sessionId) {
30
- const connection = exactOrWildcard(sessionId);
31
- return connection === void 0 ? void 0 : summarize(connection);
92
+ getBinding(sessionId) {
93
+ return bindings.get(sessionId);
32
94
  },
33
- getWithSecret(sessionId) {
34
- return exactOrWildcard(sessionId);
95
+ putBinding(sessionId, binding) {
96
+ return bindings.put(sessionId, binding);
35
97
  },
36
- has(sessionId) {
37
- return exactOrWildcard(sessionId) !== void 0;
98
+ deleteBinding(sessionId) {
99
+ return bindings.delete(sessionId);
38
100
  },
39
- clear(sessionId) {
40
- connections.delete(sessionId);
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 (session-scoped in-memory store; passwords
49
- * never leave memory), seeds config connections (`connections`, `'*'` =
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 数据Agent preset", presetId, targetDir, error instanceof Error ? error.message : String(error));
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 store = createConnectionStore();
143
- ctx.provide("dataAgentConnections", store);
144
- for (const [sessionId, spec] of Object.entries(config.connections)) {
145
- const connection = {
146
- type: spec.type,
147
- database: spec.type === "sqlite" ? resolve(process.cwd(), spec.database) : spec.database,
148
- ...spec.host !== void 0 ? { host: spec.host } : {},
149
- ...spec.port !== void 0 ? { port: spec.port } : {},
150
- ...spec.user !== void 0 ? { user: spec.user } : {},
151
- ...spec.readonly !== void 0 ? { readonly: spec.readonly } : {}
152
- };
153
- store.set(sessionId, connection);
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 (resolved.installPreset) installPreset(ctx, resolved.presetId);
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 { _ as sanitizeIdentifier, g as parseTableListing, h as parseListing, i as DEFAULT_MAX_RESULT_CHARS, m as parseColumns, o as DEFAULT_QUERY_TIMEOUT_MS, p as metadataQuery, r as DEFAULT_MAX_QUERY_CHARS, t as DEFAULT_CONNECT_TIMEOUT_MS, u as classifyStatement, v as tableListingSql, y as assertSingleStatement } from "./defaults-Bac6QvNt.js";
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 !== "mysql" && type !== "postgres" && type !== "sqlite" && type !== "oracle" && type !== "hive" && type !== "impala") throw new Error("type 必须是 \"mysql\"、\"postgres\"、\"sqlite\"、\"oracle\"、\"hive\" 或 \"impala\"");
40
- const database = candidate.database;
41
- if (typeof database !== "string" || database.length === 0) throw new Error("database 必须是非空字符串" + (type === "sqlite" ? "(SQLite 为数据库文件路径)" : ""));
42
- if (type === "sqlite") return {
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
- const host = candidate.host;
48
- if (host !== void 0 && typeof host !== "string") throw new Error("host 必须是字符串");
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
- const user = candidate.user;
52
- if (user !== void 0 && typeof user !== "string") throw new Error("user 必须是字符串");
53
- const password = candidate.password;
54
- if (password !== void 0 && typeof password !== "string") throw new Error("password 必须是字符串");
55
- const readonly = candidate.readonly;
56
- if (readonly !== void 0 && typeof readonly !== "boolean") throw new Error("readonly 必须是布尔值");
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
- /** Validate one schema/table identifier: reuse the clients' sanitizer (validation + quoting). */
78
- function requireIdentifier(type, value, label) {
79
- if (value === null || value.length === 0) throw new Error(`${label} 不能为空`);
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
- if (req.method === "POST" && segments.length === 1 && segments[0] === "connect") {
147
- const request = validateConnectBody(await readJson(req));
148
- const connection = {
149
- type: request.type,
150
- database: request.database,
151
- ...request.host !== void 0 ? { host: request.host } : {},
152
- ...request.port !== void 0 ? { port: request.port } : {},
153
- ...request.user !== void 0 ? { user: request.user } : {},
154
- ...request.password !== void 0 ? { password: request.password } : {},
155
- ...request.readonly !== void 0 ? { readonly: request.readonly } : {}
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: `数据库连接验证失败(exit ${listing.exitCode}):${detail}`
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.length === 1 && segments[0] === "disconnect") {
176
- const sessionId = (await readJson(req)).sessionId;
177
- if (typeof sessionId !== "string" || sessionId.length === 0) throw new Error("sessionId 必须是非空字符串");
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.length === 1 && segments[0] === "status") {
183
- const sessionId = url.searchParams.get("sessionId") ?? "";
184
- const summary = store.get(sessionId);
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.length === 1 && segments[0] === "schemas") {
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: parseListing(connection.type, stdout).slice(0, introspectMaxTables)
103
+ schemas: await scope.dataAgentConnections.listSchemas(sessionId, signal)
199
104
  });
200
105
  return;
201
106
  }
202
- if (req.method === "GET" && segments.length === 1 && segments[0] === "tables") {
203
- const sessionId = url.searchParams.get("sessionId") ?? "";
204
- if (sessionId.length === 0) throw new Error("sessionId 不能为空");
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: parseListing(connection.type, stdout).slice(0, introspectMaxTables)
112
+ tables: await scope.dataAgentConnections.listTables(sessionId, schema, signal)
211
113
  });
212
114
  return;
213
115
  }
214
- if (req.method === "GET" && segments.length === 1 && segments[0] === "describe") {
215
- const sessionId = url.searchParams.get("sessionId") ?? "";
216
- if (sessionId.length === 0) throw new Error("sessionId 不能为空");
217
- const connection = requireConnection(sessionId);
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: parseColumns(connection.type, stdout)
122
+ columns: await scope.dataAgentConnections.describe(sessionId, schema, table, signal)
224
123
  });
225
124
  return;
226
125
  }
227
- if (req.method === "POST" && segments.length === 1 && segments[0] === "query") {
126
+ if (req.method === "POST" && routeIs(segments, "query")) {
228
127
  const body = await readJson(req);
229
- const sessionId = body.sessionId;
230
- if (typeof sessionId !== "string" || sessionId.length === 0) throw new Error("sessionId 必须是非空字符串");
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 runClientQuery(scope, connection, sql, queryOptions, new AbortController().signal)
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 };