dsh-db-tool 0.1.4 → 0.1.5

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/client/client.js CHANGED
@@ -761,20 +761,23 @@ window.__ModuleLoader__.load({
761
761
  }
762
762
  function toggleSchema(c, d, s) {
763
763
  if (!projectPath) return;
764
+ // PG 系跨库浏览:tables 的 database 传 "库名.schema"(Navicat 官方行为,服务端按库开连接)
764
765
  toggle("s:" + c.id + "/" + d + "/" + s, () =>
765
- api("tables" + qs({ project: projectPath, connId: c.id, database: s })).then((list) => {
766
- setTablesMap((m) => Object.assign({}, m, { [c.id + "/" + s]: list || [] }));
766
+ api("tables" + qs({ project: projectPath, connId: c.id, database: d + "." + s })).then((list) => {
767
+ setTablesMap((m) => Object.assign({}, m, { [c.id + "/" + d + "/" + s]: list || [] }));
767
768
  }));
768
769
  }
769
770
  // 请求序号守卫:快速切换选中表/翻页时,丢弃晚到的旧响应,防止旧数据覆盖新选中项
770
771
  const openSeq = React.useRef(0);
771
772
  const openTable = React.useCallback((s, pg) => {
772
773
  if (!s || !projectPath) return;
774
+ // PG 系跨库:database 传 "库名.schema";其它库传库名
775
+ const dbRef = s.schemaName ? s.db + "." + s.schemaName : s.db;
773
776
  const seq = ++openSeq.current;
774
777
  setBusy("open");
775
778
  Promise.all([
776
- api("schema" + qs({ project: projectPath, connId: s.connId, database: s.db, table: s.table.name })),
777
- api("preview" + qs({ project: projectPath, connId: s.connId, database: s.db, table: s.table.name, limit: PAGE_SIZE, offset: ((pg || 1) - 1) * PAGE_SIZE })),
779
+ api("schema" + qs({ project: projectPath, connId: s.connId, database: dbRef, table: s.table.name })),
780
+ api("preview" + qs({ project: projectPath, connId: s.connId, database: dbRef, table: s.table.name, limit: PAGE_SIZE, offset: ((pg || 1) - 1) * PAGE_SIZE })),
778
781
  ])
779
782
  .then(([sch, prev]) => {
780
783
  if (seq !== openSeq.current) return; // 旧请求晚到,丢弃
@@ -827,16 +830,16 @@ window.__ModuleLoader__.load({
827
830
  const sOpen = !!open[sk];
828
831
  treeRows.push(treerow(sk, 2, sOpen, false, s, () => toggleSchema(c, d, s)));
829
832
  if (!sOpen) continue;
830
- const tlist = tablesMap[c.id + "/" + s];
833
+ const tlist = tablesMap[c.id + "/" + d + "/" + s];
831
834
  if (loading[sk]) { treeRows.push(treerow(sk + ":l", 3, false, true, "…")); continue; }
832
835
  if (error[sk]) { treeRows.push(treerow(sk + ":x", 3, false, true, t("loadFailed") + ":" + error[sk], () => retry(sk, () => toggleSchema(c, d, s)))); continue; }
833
836
  if (!tlist) continue;
834
837
  if (tlist.length === 0) { treeRows.push(treerow(sk + ":e", 3, false, true, t("noTables"))); continue; }
835
838
  for (const tb of tlist) {
836
- const active = !!sel && sel.connId === c.id && sel.db === s && sel.table.name === tb.name;
839
+ const active = !!sel && sel.connId === c.id && sel.db === d && sel.schemaName === s && sel.table.name === tb.name;
837
840
  treeRows.push(treerow("t:" + sk + "/" + tb.name, 3, false, true,
838
841
  tb.name + (tb.type && tb.type !== "table" ? " · " + tb.type : ""),
839
- () => setSel({ connId: c.id, db: s, table: tb }), active));
842
+ () => setSel({ connId: c.id, db: d, schemaName: s, table: tb }), active));
840
843
  }
841
844
  }
842
845
  continue;
@@ -871,7 +874,7 @@ window.__ModuleLoader__.load({
871
874
  ["structure", "preview"].map((v) =>
872
875
  React.createElement("button", { key: v, className: view === v ? "active" : "", onClick: () => setView(v) }, v === "structure" ? t("structure") : t("preview"))),
873
876
  ),
874
- React.createElement("strong", null, (view === "structure" ? t("structure") : t("preview")) + " · " + sel.db + " / " + sel.table.name),
877
+ React.createElement("strong", null, (view === "structure" ? t("structure") : t("preview")) + " · " + sel.db + (sel.schemaName ? "." + sel.schemaName : "") + " / " + sel.table.name),
875
878
  view === "structure"
876
879
  ? resultTable(
877
880
  [t("column"), t("dataType"), t("nullable"), t("keyCol"), t("defaultVal"), t("comment")],
@@ -28,25 +28,64 @@ function poolConfig(conn) {
28
28
  export async function createPgLikeAdapter(kind, Driver, conn, opts) {
29
29
  const pool = new Driver.Pool(poolConfig(conn));
30
30
  const readOnly = opts?.mode === 'ro';
31
- if (readOnly) {
32
- // 服务器级 ro 强制(官方手段):每个新连接会话设为只读事务。
33
- // fail-closed:SET 失败时 release(err) 让驱动销毁该连接、等待的 acquire 收到错误——
34
- // 会话级只读是 ro 的最后防线,不允许静默降级成可写连接。
35
- pool.on('connect', (c) => {
31
+ // 服务器级 ro 强制(官方手段):每个新连接会话设为只读事务。
32
+ // fail-closed:SET 失败时 release(err) 让驱动销毁该连接、等待的 acquire 收到错误——
33
+ // 会话级只读是 ro 的最后防线,不允许静默降级成可写连接。
34
+ const setupReadOnly = (p) => {
35
+ p.on('connect', (c) => {
36
36
  void c.query('SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY').catch((err) => {
37
37
  c.release(err instanceof Error ? err : new Error(String(err)));
38
38
  });
39
39
  });
40
40
  // pg 约定:release(err) 若无等待者会 emit pool 'error',不监听会崩进程
41
- pool.on('error', () => { });
42
- }
41
+ p.on('error', () => { });
42
+ };
43
+ if (readOnly)
44
+ setupReadOnly(pool);
43
45
  // 事务激活期间所有 query/execute 路由到同一 client
44
46
  let txClient = null;
45
47
  const run = (sql, params) => txClient ? txClient.query(sql, params) : pool.query(sql, params);
48
+ // 跨库浏览(Navicat 行为):树列出服务器上所有库,展开非连接库时用同一凭据开指向该库的池。
49
+ // 池按库缓存复用;ro 模式下新池同样套会话只读。连接自身的库(fields.database 或 url 库名)直接复用主池。
50
+ const dbPools = new Map();
51
+ const mainDb = () => {
52
+ if (conn.fields)
53
+ return conn.fields.database ? String(conn.fields.database) : null;
54
+ if (!conn.url)
55
+ return null;
56
+ try {
57
+ return decodeURIComponent(new URL(conn.url).pathname.replace(/^\//, '')) || null;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ };
63
+ const poolFor = (db) => {
64
+ if (!db || db === mainDb())
65
+ return pool;
66
+ const cached = dbPools.get(db);
67
+ if (cached)
68
+ return cached;
69
+ const created = new Driver.Pool({ ...poolConfig(conn), database: db });
70
+ if (readOnly)
71
+ setupReadOnly(created);
72
+ dbPools.set(db, created);
73
+ return created;
74
+ };
46
75
  const defaultSchema = () => {
47
76
  const f = conn.fields;
48
77
  return f && typeof f.schema === 'string' && f.schema ? f.schema : 'public';
49
78
  };
79
+ /** 浏览目标解析:"db.schema"(跨库)| "schema"(当前库)| 空(当前库默认 schema)。
80
+ * PG 标识符不允许裸点,按第一个点切分安全;多余点由 assertIdent 拒绝。 */
81
+ const parseBrowseTarget = (ref, what) => {
82
+ if (!ref)
83
+ return { db: null, schema: defaultSchema() };
84
+ const i = ref.indexOf('.');
85
+ if (i < 0)
86
+ return { db: null, schema: assertIdent(ref, what) };
87
+ return { db: assertIdent(ref.slice(0, i), '数据库'), schema: assertIdent(ref.slice(i + 1), what) };
88
+ };
50
89
  function toQueryResult(res) {
51
90
  let columns = (res.fields ?? []).map((f) => f.name);
52
91
  if (columns.length === 0 && res.rows.length > 0) {
@@ -117,37 +156,40 @@ export async function createPgLikeAdapter(kind, Driver, conn, opts) {
117
156
  return res.rows.map((r) => String(r.datname));
118
157
  }),
119
158
  // 库内 schema 清单(Navicat 官方层级:数据库 → 模式 → 表)。
120
- // PG 连接固定单库,database 参数无法跨库,忽略;pg_* 前缀覆盖 pg_catalog/pg_toast/pg_temp 系。
121
- listSchemas: () => humanize(`${kind} 列出模式`, async () => {
122
- const res = await pool.query(`SELECT nspname FROM pg_catalog.pg_namespace
159
+ // database 参数 = 库名(UI 树第一层);pg_* 前缀覆盖 pg_catalog/pg_toast/pg_temp 系。
160
+ listSchemas: (database) => humanize(`${kind} 列出模式`, async () => {
161
+ const p = poolFor(database ? assertIdent(database, '数据库') : null);
162
+ const res = await p.query(`SELECT nspname FROM pg_catalog.pg_namespace
123
163
  WHERE nspname NOT LIKE 'pg\\_%' AND nspname <> 'information_schema'
124
164
  ORDER BY nspname`);
125
165
  return res.rows.map((r) => String(r.nspname));
126
166
  }),
167
+ // database 参数 = "库名.schema"(跨库浏览)或 "schema"(当前库)
127
168
  listTables: (database) => humanize(`${kind} 列出表`, async () => {
128
- const schema = database ? assertIdent(database, 'schema') : defaultSchema();
129
- const res = await pool.query(`SELECT table_name, table_type FROM information_schema.tables
169
+ const { db, schema } = parseBrowseTarget(database, 'schema');
170
+ const res = await poolFor(db).query(`SELECT table_name, table_type FROM information_schema.tables
130
171
  WHERE table_schema = $1 ORDER BY table_name`, [schema]);
131
172
  return res.rows.map((r) => {
132
173
  const rawType = String(r.table_type);
133
174
  return {
134
175
  name: String(r.table_name),
135
176
  type: rawType === 'BASE TABLE' ? 'TABLE' : rawType,
136
- database: schema,
177
+ database: db ? `${db}.${schema}` : schema,
137
178
  };
138
179
  });
139
180
  }),
140
181
  describeTable: (table, database) => humanize(`${kind} 查看表结构`, async () => {
141
- const schema = database ? assertIdent(database, 'schema') : defaultSchema();
182
+ const { db, schema } = parseBrowseTarget(database, 'schema');
142
183
  const tbl = assertIdent(table, '表名');
143
- const res = await pool.query(`SELECT c.column_name, c.data_type, c.is_nullable, c.column_default, c.character_maximum_length,
184
+ const p = poolFor(db);
185
+ const res = await p.query(`SELECT c.column_name, c.data_type, c.is_nullable, c.column_default, c.character_maximum_length,
144
186
  col_description((quote_ident($1) || '.' || quote_ident($2))::regclass, c.ordinal_position) AS col_comment
145
187
  FROM information_schema.columns c
146
188
  WHERE c.table_schema = $1 AND c.table_name = $2
147
189
  ORDER BY c.ordinal_position`, [schema, tbl]);
148
190
  if (res.rows.length === 0)
149
- throw new Error(`表不存在: ${schema}.${tbl}`);
150
- const pks = await pool.query(`SELECT kcu.column_name FROM information_schema.table_constraints tc
191
+ throw new Error(`表不存在: ${db ? db + '.' : ''}${schema}.${tbl}`);
192
+ const pks = await p.query(`SELECT kcu.column_name FROM information_schema.table_constraints tc
151
193
  JOIN information_schema.key_column_usage kcu
152
194
  ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
153
195
  WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = $1 AND tc.table_name = $2`, [schema, tbl]);
@@ -166,13 +208,17 @@ export async function createPgLikeAdapter(kind, Driver, conn, opts) {
166
208
  });
167
209
  }),
168
210
  previewRows: (table, limit, database, offset) => humanize(`${kind} 预览行`, async () => {
169
- const schema = database ? assertIdent(database, 'schema') : defaultSchema();
211
+ const { db, schema } = parseBrowseTarget(database, 'schema');
170
212
  const tbl = assertIdent(table, '表名');
171
213
  const lim = clampLimit(limit);
172
214
  const off = clampOffset(offset);
173
- const res = await run(`SELECT * FROM ${quoteIdent(schema)}.${quoteIdent(tbl)} LIMIT $1 OFFSET $2`, [lim, off]);
215
+ const sql = `SELECT * FROM ${quoteIdent(schema)}.${quoteIdent(tbl)} LIMIT $1 OFFSET $2`;
216
+ // 当前库保持事务路由(run);跨库必须用该库自己的池
217
+ const res = db ? await poolFor(db).query(sql, [lim, off]) : await run(sql, [lim, off]);
174
218
  return toQueryResult(res);
175
219
  }),
176
- close: () => humanize(`${kind} 关闭连接`, () => pool.end()),
220
+ close: () => humanize(`${kind} 关闭连接`, async () => {
221
+ await Promise.all([pool.end(), ...[...dbPools.values()].map((p) => p.end())]);
222
+ }),
177
223
  };
178
224
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-db-tool",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "DSH community plugin: chat-operated multi-database admin tool with sidebar management, project-scoped grants, ro/rw modes and dangerous-operation confirmation",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -57,7 +57,7 @@ DatabaseManager({ action: "schema", conn_id: "c1", database: "shop", table: "use
57
57
  ```
58
58
 
59
59
  - 表列表返回 `TableInfo[]`:`{name, type?, comment?, database?}`(Redis 的 type 为 hash|list|set|zset|string|stream)。
60
- - PostgreSQL/GaussDB 为「数据库 → 模式(schema) → 表」三层语义:`database` 参数此时应传 **schema 名**(如 `public`);不确定时先执行 `SELECT nspname FROM pg_catalog.pg_namespace WHERE nspname NOT LIKE 'pg\_%' AND nspname <> 'information_schema'` 列出可用 schema。
60
+ - PostgreSQL/GaussDB 为「数据库 → 模式(schema) → 表」三层语义,支持跨库浏览(Navicat 行为):`database` 参数传 `"库名.schema"` 即可浏览连接库之外的库(如 `gycwd.public`,服务端自动用同一凭据开指向该库的连接);只传 schema 名(如 `public`)则查连接自身库。`listSchemas(database)` 的参数是**纯库名**。不确定 schema 时先执行 `SELECT nspname FROM pg_catalog.pg_namespace WHERE nspname NOT LIKE 'pg\_%' AND nspname <> 'information_schema'`。
61
61
  - 列结构返回 `ColumnInfo[]`:`{name, dataType, nullable, key?, default?, comment?}`。
62
62
 
63
63
  ### 5. preview — 预览表数据