@zhin.js/database 1.1.7 → 1.2.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/lib/probe.js ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * One-shot connection probe for setup wizards: connect, run the dialect health query, disconnect.
3
+ * Error text is stripped of credentials before it is returned.
4
+ */
5
+ /** Driver package that must be importable for each dialect (`null`: built into Node). */
6
+ export const DATABASE_DRIVER_PACKAGES = Object.freeze({
7
+ sqlite: null,
8
+ mysql: 'mysql2',
9
+ pg: 'pg',
10
+ mongodb: 'mongodb',
11
+ });
12
+ /** Whether the optional driver package for `dialect` can be loaded. */
13
+ export async function isDatabaseDriverAvailable(dialect, importer = specifier => import(specifier)) {
14
+ const pkg = DATABASE_DRIVER_PACKAGES[dialect];
15
+ if (pkg === null)
16
+ return true;
17
+ try {
18
+ await importer(pkg);
19
+ return true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ async function createProbeTarget(dialect, config) {
26
+ switch (dialect) {
27
+ case 'sqlite': {
28
+ const { SQLiteDialect } = await import('./dialects/sqlite.js');
29
+ return new SQLiteDialect(config);
30
+ }
31
+ case 'mysql': {
32
+ const { MySQLDialect } = await import('./dialects/mysql.js');
33
+ return new MySQLDialect(config);
34
+ }
35
+ case 'pg': {
36
+ const { PostgreSQLDialect } = await import('./dialects/pg.js');
37
+ return new PostgreSQLDialect(config);
38
+ }
39
+ case 'mongodb': {
40
+ const { MongoDBDialect } = await import('./dialects/mongodb.js');
41
+ return new MongoDBDialect(config);
42
+ }
43
+ default:
44
+ throw new Error(`Unsupported dialect: ${String(dialect)}`);
45
+ }
46
+ }
47
+ /**
48
+ * Opens a temporary connection with `config` and closes it again.
49
+ * `factory` is a test seam; it defaults to the built-in dialects.
50
+ */
51
+ export async function probeDatabaseConnection(dialect, config, options = {}) {
52
+ const secrets = collectSecrets(config);
53
+ const startedAt = Date.now();
54
+ let target;
55
+ let timer;
56
+ const timeout = new Promise((_resolve, reject) => {
57
+ timer = setTimeout(() => reject(new Error('connection timed out')), options.timeoutMs ?? 10_000);
58
+ });
59
+ try {
60
+ target = await (options.factory ?? createProbeTarget)(dialect, config);
61
+ await Promise.race([target.connect(), timeout]);
62
+ const healthy = await Promise.race([target.healthCheck(), timeout]);
63
+ if (!healthy)
64
+ return Object.freeze({ ok: false, error: 'probe query failed' });
65
+ return Object.freeze({ ok: true, latencyMs: Date.now() - startedAt });
66
+ }
67
+ catch (error) {
68
+ return Object.freeze({ ok: false, error: redactProbeError(error, secrets) });
69
+ }
70
+ finally {
71
+ clearTimeout(timer);
72
+ await target?.disconnect().catch(() => undefined);
73
+ }
74
+ }
75
+ function collectSecrets(config) {
76
+ const values = [];
77
+ for (const [key, value] of Object.entries(config)) {
78
+ if (typeof value !== 'string' || !value)
79
+ continue;
80
+ if (/pass|secret|token|key/iu.test(key))
81
+ values.push(value);
82
+ if (/url|uri|connectionString/iu.test(key)) {
83
+ const credentials = /\/\/([^@/]*)@/u.exec(value)?.[1];
84
+ if (credentials)
85
+ values.push(credentials, ...credentials.split(':').filter(Boolean));
86
+ }
87
+ }
88
+ return values.filter(value => value.length >= 3).sort((a, b) => b.length - a.length);
89
+ }
90
+ /** Removes credentials (configured secret values, URL userinfo) from a driver error. */
91
+ export function redactProbeError(error, secrets = []) {
92
+ let message = error instanceof Error ? error.message : String(error);
93
+ for (const secret of secrets)
94
+ message = message.split(secret).join('***');
95
+ message = message.replace(/\/\/[^@\s/]*@/gu, '//***@');
96
+ message = message.replace(/(password|pwd)\s*[=:]\s*\S+/giu, '$1=***');
97
+ return message.slice(0, 300);
98
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Tables owned by the management gateway (ADR 0049 / 0054 §7). The gateway and the instance share
3
+ * one project database; the gateway's tables hold password hashes, session / token hashes and the
4
+ * audit log, so every instance-side path (plugin models, Console database page, raw SQL) must deny
5
+ * them. Deny by prefix so future gateway tables are covered without another list to update.
6
+ */
7
+ export declare const GATEWAY_TABLE_PREFIX = "console_";
8
+ /**
9
+ * The gateway's marker in the project database (ADR 0059 §3 修订): which `.zhin/gateway.db` holds
10
+ * the Console accounts. Gateway-owned like `console_*`: no instance path may read or drop it.
11
+ */
12
+ export declare const GATEWAY_STORE_MARKER_TABLE = "zhin_gateway_store";
13
+ /** `console_`-prefixed tables that the instance itself owns (historical names). */
14
+ export declare const INSTANCE_CONSOLE_TABLES: readonly string[];
15
+ /** True when `name` is a gateway-owned table (case-insensitive, quotes and schema ignored). */
16
+ export declare function isGatewayOwnedTable(name: string): boolean;
17
+ /**
18
+ * True when SQL text mentions a gateway-owned table anywhere (identifiers, quoted or literal),
19
+ * also when the name is spelled with escapes (`U&"\\0063onsole_users"`, `E'\\x63onsole_users'`).
20
+ * A custom `UESCAPE` character cannot be decoded reliably, so it counts as a reference.
21
+ */
22
+ export declare function referencesGatewayOwnedTable(sql: string): boolean;
23
+ export declare class GatewayOwnedTableError extends Error {
24
+ constructor(table?: string);
25
+ }
26
+ /** Throws {@link GatewayOwnedTableError} for a gateway-owned table name. */
27
+ export declare function assertInstanceTable(name: string): void;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Tables owned by the management gateway (ADR 0049 / 0054 §7). The gateway and the instance share
3
+ * one project database; the gateway's tables hold password hashes, session / token hashes and the
4
+ * audit log, so every instance-side path (plugin models, Console database page, raw SQL) must deny
5
+ * them. Deny by prefix so future gateway tables are covered without another list to update.
6
+ */
7
+ export const GATEWAY_TABLE_PREFIX = 'console_';
8
+ /**
9
+ * The gateway's marker in the project database (ADR 0059 §3 修订): which `.zhin/gateway.db` holds
10
+ * the Console accounts. Gateway-owned like `console_*`: no instance path may read or drop it.
11
+ */
12
+ export const GATEWAY_STORE_MARKER_TABLE = 'zhin_gateway_store';
13
+ /** `console_`-prefixed tables that the instance itself owns (historical names). */
14
+ export const INSTANCE_CONSOLE_TABLES = Object.freeze(['console_conversations']);
15
+ const instanceConsoleTables = new Set(INSTANCE_CONSOLE_TABLES);
16
+ /** True when `name` is a gateway-owned table (case-insensitive, quotes and schema ignored). */
17
+ export function isGatewayOwnedTable(name) {
18
+ const bare = String(name).trim().replace(/^[`"[]|[`"\]]$/g, '');
19
+ const table = (bare.includes('.') ? bare.slice(bare.lastIndexOf('.') + 1) : bare)
20
+ .replace(/^[`"[]|[`"\]]$/g, '').toLowerCase();
21
+ if (table === GATEWAY_STORE_MARKER_TABLE)
22
+ return true;
23
+ return table.startsWith(GATEWAY_TABLE_PREFIX) && !instanceConsoleTables.has(table);
24
+ }
25
+ const IDENTIFIER = /(?<![A-Za-z0-9_$])(?:console_[A-Za-z0-9_$]*|zhin_gateway_store)(?![A-Za-z0-9_$])/gi;
26
+ /**
27
+ * Spellings of the SQL text with escape sequences decoded: PostgreSQL `U&"\\0063…"` /
28
+ * `U&'\\+000063…'` and `E'\\x63…'` / octal / `\\u`, MySQL backslash escapes inside strings
29
+ * (`'con\\sole_users'`) and doubled quotes inside quoted names (`"console_""x"`).
30
+ */
31
+ function decodedSpellings(sql) {
32
+ const hex = (code) => {
33
+ const value = Number.parseInt(code, 16);
34
+ return Number.isFinite(value) && value <= 0x10ffff ? String.fromCodePoint(value) : '';
35
+ };
36
+ // U& names and strings: \XXXX and \+XXXXXX.
37
+ const unicode = sql
38
+ .replace(/\\\+([0-9a-f]{6})/gi, (_, code) => hex(code))
39
+ .replace(/\\([0-9a-f]{4})/gi, (_, code) => hex(code));
40
+ // E'' strings: \xHH, \ooo, \uXXXX, \UXXXXXXXX.
41
+ const escaped = sql
42
+ .replace(/\\U([0-9a-f]{8})/g, (_, code) => hex(code))
43
+ .replace(/\\u([0-9a-f]{4})/g, (_, code) => hex(code))
44
+ .replace(/\\x([0-9a-f]{1,2})/gi, (_, code) => hex(code))
45
+ .replace(/\\([0-7]{1,3})/g, (_, code) => String.fromCharCode(Number.parseInt(code, 8) & 0xff));
46
+ const spellings = [sql, unicode, escaped].flatMap(text => [text, text.replace(/\\(.)/g, '$1')]);
47
+ return spellings.flatMap(text => [text, text.replace(/""|``/g, '')]);
48
+ }
49
+ /**
50
+ * True when SQL text mentions a gateway-owned table anywhere (identifiers, quoted or literal),
51
+ * also when the name is spelled with escapes (`U&"\\0063onsole_users"`, `E'\\x63onsole_users'`).
52
+ * A custom `UESCAPE` character cannot be decoded reliably, so it counts as a reference.
53
+ */
54
+ export function referencesGatewayOwnedTable(sql) {
55
+ const text = String(sql);
56
+ if (/\bu&\s*['"]/i.test(text) && /\buescape\b/i.test(text))
57
+ return true;
58
+ for (const spelling of decodedSpellings(text)) {
59
+ for (const match of spelling.matchAll(IDENTIFIER)) {
60
+ if (isGatewayOwnedTable(match[0]))
61
+ return true;
62
+ }
63
+ }
64
+ return false;
65
+ }
66
+ export class GatewayOwnedTableError extends Error {
67
+ constructor(table) {
68
+ super(table
69
+ ? `Table '${table}' is owned by the management gateway and is not accessible from the instance`
70
+ : 'Statement references a table owned by the management gateway');
71
+ this.name = 'GatewayOwnedTableError';
72
+ }
73
+ }
74
+ /** Throws {@link GatewayOwnedTableError} for a gateway-owned table name. */
75
+ export function assertInstanceTable(name) {
76
+ if (isGatewayOwnedTable(name))
77
+ throw new GatewayOwnedTableError(name);
78
+ }
package/lib/types.d.ts CHANGED
@@ -209,7 +209,7 @@ export interface DocumentQueryResult {
209
209
  limit?: number;
210
210
  skip?: number;
211
211
  projection?: Record<string, 1 | 0>;
212
- operation?: 'find' | 'insertOne' | 'insertMany' | 'updateOne' | 'updateMany' | 'deleteOne' | 'deleteMany' | 'createCollection' | 'createIndex' | 'dropIndex' | 'dropCollection';
212
+ operation?: 'find' | 'insertOne' | 'insertMany' | 'updateOne' | 'updateMany' | 'deleteOne' | 'deleteMany' | 'createCollection' | 'createIndex' | 'dropIndex' | 'dropCollection' | 'listCollections';
213
213
  }
214
214
  /**
215
215
  * 键值查询结果类型
@@ -285,11 +285,31 @@ export type IsolationLevel = 'READ_UNCOMMITTED' | 'READ_COMMITTED' | 'REPEATABLE
285
285
  export interface TransactionOptions {
286
286
  isolationLevel?: IsolationLevel;
287
287
  timeout?: number;
288
+ /**
289
+ * The transaction only reads. SQLite then opens it with `BEGIN DEFERRED` (no write lock, so it
290
+ * never waits for another connection's writer) and refuses writes inside it (`query_only`).
291
+ * PostgreSQL opens it `READ ONLY` and MySQL with `START TRANSACTION READ ONLY`.
292
+ */
293
+ readOnly?: boolean;
294
+ }
295
+ export interface StatementQueryOptions {
296
+ /**
297
+ * Keep at most this many rows. Drivers stop reading past it where they can (SQLite iterates,
298
+ * MySQL streams and discards); the returned array then carries a non-enumerable `truncated`.
299
+ */
300
+ readonly maxRows?: number;
288
301
  }
289
302
  export interface Transaction {
290
303
  commit(): Promise<void>;
291
304
  rollback(): Promise<void>;
292
305
  query<T = any>(sql: string, params?: any[]): Promise<T>;
306
+ /**
307
+ * Runs exactly one statement through a protocol that refuses a second one: PostgreSQL's
308
+ * extended protocol (`cannot insert multiple commands into a prepared statement`), MySQL
309
+ * prepared statements (never `multipleStatements`, whatever the connection config says) and
310
+ * SQLite `prepare()` (which compiles only the first statement). For SQL typed by a person.
311
+ */
312
+ queryStatement?<T = any>(sql: string, options?: StatementQueryOptions): Promise<T>;
293
313
  }
294
314
  /**
295
315
  * 增强的事务接口,支持链式调用
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/database",
3
- "version": "1.1.7",
3
+ "version": "1.2.0",
4
4
  "description": "Universal database abstraction layer for Zhin.js - supports SQLite, MySQL, PostgreSQL, MongoDB, Redis",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -43,14 +43,15 @@
43
43
  "engines": {
44
44
  "node": ">=22.19.0"
45
45
  },
46
+ "license": "MIT",
46
47
  "dependencies": {
47
- "@zhin.js/logger": "1.1.2"
48
+ "@zhin.js/logger": "1.1.3"
48
49
  },
49
50
  "peerDependencies": {
50
51
  "mongodb": ">=3.1.13",
51
52
  "mysql2": ">=3.9.8",
52
53
  "pg": ">=2.11.2",
53
- "redis": "latest"
54
+ "redis": ">=4.0.0"
54
55
  },
55
56
  "peerDependenciesMeta": {
56
57
  "mysql2": {