@zhin.js/database 1.1.8 → 1.2.1

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.
@@ -8,6 +8,13 @@ export interface MySQLDialectConfig extends ConnectionOptions {
8
8
  * 如果提供此选项,将使用连接池而不是单连接
9
9
  */
10
10
  pool?: PoolConfig;
11
+ /**
12
+ * Pool mode: `COM_RESET_CONNECTION` (`connection.reset()`, MySQL 5.7.3+) before a connection
13
+ * goes back to the pool, so session variables, user variables, temporary tables and prepared
14
+ * statements never outlive one use; a connection that cannot be reset is destroyed. The
15
+ * restricted Console account uses it (ADR 0059 §4).
16
+ */
17
+ resetSession?: boolean;
11
18
  }
12
19
  export declare class MySQLDialect<S extends Record<string, object> = Record<string, object>> extends Dialect<MySQLDialectConfig, S, string> {
13
20
  private connection;
@@ -15,12 +22,38 @@ export declare class MySQLDialect<S extends Record<string, object> = Record<stri
15
22
  private usePool;
16
23
  private readonly booleanColumns;
17
24
  private readonly bigintColumns;
25
+ /** `SELECT VERSION()` of the server, read once at connect ('' when it could not be read). */
26
+ private serverVersion;
18
27
  constructor(config: MySQLDialectConfig);
19
28
  isConnected(): boolean;
20
29
  connect(): Promise<void>;
30
+ /** Whether the server is MariaDB (from `VERSION()`; false when unknown). */
31
+ isMariaDb(): boolean;
32
+ /**
33
+ * Binary, NO PAD collation for indexed text keys: `utf8mb4_0900_bin` (MySQL 8.0+),
34
+ * `utf8mb4_nopad_bin` (MariaDB 10.2+, which has no 0900 collations before 11.4), and
35
+ * `utf8mb4_bin` on MySQL 5.7 (binary, but PAD SPACE: the only binary utf8mb4 collation there).
36
+ */
37
+ indexedTextCollation(): string;
21
38
  disconnect(): Promise<void>;
22
39
  healthCheck(): Promise<boolean>;
40
+ /**
41
+ * What the driver will actually send at connect time (after merging `uri` and `flags`):
42
+ * whether `LOAD DATA LOCAL` and multiple statements are possible on this connection.
43
+ */
44
+ connectionSafety(): {
45
+ readonly localFiles: boolean;
46
+ readonly multipleStatements: boolean;
47
+ } | undefined;
48
+ /** Gives a pool connection back, reset first with {@link MySQLDialectConfig.resetSession}. */
49
+ private releaseConnection;
23
50
  query<U = any>(sql: string, params?: any[]): Promise<U>;
51
+ /**
52
+ * One statement as a prepared statement (binary protocol): MySQL never runs a second statement
53
+ * from it, even when the connection was configured with `multipleStatements`. With `maxRows`
54
+ * the rows are streamed and those past the cap discarded, so memory stays bounded.
55
+ */
56
+ private queryStatementOn;
24
57
  registerTableSchema(table: string, definition: Record<string, {
25
58
  type: string;
26
59
  }>): void;
@@ -41,6 +74,7 @@ export declare class MySQLDialect<S extends Record<string, object> = Record<stri
41
74
  formatBoolean(value: boolean): string;
42
75
  formatDate(value: Date): string;
43
76
  formatJson(value: any): string;
77
+ /** MySQL string literals treat `\\` as an escape (unless `NO_BACKSLASH_ESCAPES`): double both. */
44
78
  escapeString(value: string): string;
45
79
  formatDefaultValue(value: any): string;
46
80
  formatLimit(limit: number): string;
@@ -1,13 +1,19 @@
1
1
  import { Dialect } from '../base/index.js';
2
2
  import { RelatedDatabase } from "../type/related/database.js";
3
3
  import { getLogger } from '@zhin.js/logger';
4
+ import { markTruncated } from '../base/statement-rows.js';
4
5
  const logger = getLogger('database');
6
+ /** `ClientConstants.LOCAL_FILES` / `MULTI_STATEMENTS` of the MySQL protocol. */
7
+ const CLIENT_LOCAL_FILES = 0x80;
8
+ const CLIENT_MULTI_STATEMENTS = 0x10000;
5
9
  export class MySQLDialect extends Dialect {
6
10
  connection = null;
7
11
  pool = null;
8
12
  usePool = false;
9
13
  booleanColumns = new Set();
10
14
  bigintColumns = new Set();
15
+ /** `SELECT VERSION()` of the server, read once at connect ('' when it could not be read). */
16
+ serverVersion = '';
11
17
  constructor(config) {
12
18
  super('mysql', config);
13
19
  this.usePool = !!config.pool;
@@ -20,7 +26,7 @@ export class MySQLDialect extends Dialect {
20
26
  try {
21
27
  if (this.usePool) {
22
28
  const { createPool } = await import('mysql2/promise');
23
- const { pool, ...connectionOptions } = this.config;
29
+ const { pool, resetSession: _reset, ...connectionOptions } = this.config;
24
30
  const poolConfig = {
25
31
  ...connectionOptions,
26
32
  waitForConnections: true,
@@ -33,13 +39,38 @@ export class MySQLDialect extends Dialect {
33
39
  }
34
40
  else {
35
41
  const { createConnection } = await import('mysql2/promise');
36
- this.connection = await createConnection(this.config);
42
+ const { resetSession: _reset, ...connectionOptions } = this.config;
43
+ this.connection = await createConnection(connectionOptions);
37
44
  }
38
45
  }
39
46
  catch (error) {
40
47
  logger.error('forgot install mysql2 ?');
41
48
  throw new Error(`MySQL 连接失败: ${error}`);
42
49
  }
50
+ try {
51
+ const rows = await this.query('SELECT VERSION() AS v');
52
+ this.serverVersion = String(rows?.[0]?.v ?? '');
53
+ }
54
+ catch (error) {
55
+ this.serverVersion = '';
56
+ // Unknown server: indexed text keys fall back to utf8mb4_0900_bin, which MariaDB < 11.4 lacks.
57
+ logger.warn(`MySQL: could not read VERSION() (${error instanceof Error ? error.message : String(error)}); assuming MySQL 8 collations`);
58
+ }
59
+ }
60
+ /** Whether the server is MariaDB (from `VERSION()`; false when unknown). */
61
+ isMariaDb() {
62
+ return /mariadb/iu.test(this.serverVersion);
63
+ }
64
+ /**
65
+ * Binary, NO PAD collation for indexed text keys: `utf8mb4_0900_bin` (MySQL 8.0+),
66
+ * `utf8mb4_nopad_bin` (MariaDB 10.2+, which has no 0900 collations before 11.4), and
67
+ * `utf8mb4_bin` on MySQL 5.7 (binary, but PAD SPACE: the only binary utf8mb4 collation there).
68
+ */
69
+ indexedTextCollation() {
70
+ if (this.isMariaDb())
71
+ return 'utf8mb4_nopad_bin';
72
+ const major = Number(/^(\d+)\./u.exec(this.serverVersion)?.[1]);
73
+ return Number.isFinite(major) && major > 0 && major < 8 ? 'utf8mb4_bin' : 'utf8mb4_0900_bin';
43
74
  }
44
75
  async disconnect() {
45
76
  if (this.usePool && this.pool) {
@@ -63,7 +94,46 @@ export class MySQLDialect extends Dialect {
63
94
  return false;
64
95
  }
65
96
  }
97
+ /**
98
+ * What the driver will actually send at connect time (after merging `uri` and `flags`):
99
+ * whether `LOAD DATA LOCAL` and multiple statements are possible on this connection.
100
+ */
101
+ connectionSafety() {
102
+ const config = this.usePool ? this.pool?.pool?.config?.connectionConfig : this.connection?.connection?.config;
103
+ if (!config || typeof config.clientFlags !== 'number')
104
+ return undefined;
105
+ return Object.freeze({
106
+ localFiles: (config.clientFlags & CLIENT_LOCAL_FILES) !== 0 || typeof config.infileStreamFactory === 'function',
107
+ multipleStatements: config.multipleStatements === true || (config.clientFlags & CLIENT_MULTI_STATEMENTS) !== 0,
108
+ });
109
+ }
110
+ /** Gives a pool connection back, reset first with {@link MySQLDialectConfig.resetSession}. */
111
+ async releaseConnection(connection) {
112
+ if (!this.config.resetSession) {
113
+ connection.release();
114
+ return;
115
+ }
116
+ try {
117
+ if (typeof connection.reset !== 'function')
118
+ throw new Error('connection.reset() is not available');
119
+ await connection.reset();
120
+ connection.release();
121
+ }
122
+ catch {
123
+ connection.destroy();
124
+ }
125
+ }
66
126
  async query(sql, params) {
127
+ if (this.usePool && this.config.resetSession) {
128
+ const connection = await this.pool.getConnection();
129
+ try {
130
+ const [rows, fields] = await connection.execute(sql, params);
131
+ return this.processQueryResults(rows, fields);
132
+ }
133
+ finally {
134
+ await this.releaseConnection(connection);
135
+ }
136
+ }
67
137
  if (this.usePool) {
68
138
  const [rows, fields] = await this.pool.execute(sql, params);
69
139
  return this.processQueryResults(rows, fields);
@@ -73,6 +143,45 @@ export class MySQLDialect extends Dialect {
73
143
  return this.processQueryResults(rows, fields);
74
144
  }
75
145
  }
146
+ /**
147
+ * One statement as a prepared statement (binary protocol): MySQL never runs a second statement
148
+ * from it, even when the connection was configured with `multipleStatements`. With `maxRows`
149
+ * the rows are streamed and those past the cap discarded, so memory stays bounded.
150
+ */
151
+ async queryStatementOn(connection, sql, options) {
152
+ const maxRows = options?.maxRows;
153
+ const core = connection?.connection;
154
+ if (maxRows === undefined || typeof core?.execute !== 'function') {
155
+ const [rows, fields] = await connection.execute(sql, []);
156
+ if (maxRows === undefined || !Array.isArray(rows))
157
+ return this.processQueryResults(rows, fields);
158
+ const extra = rows.length > maxRows;
159
+ return markTruncated(this.processQueryResults(rows.slice(0, maxRows), fields), extra);
160
+ }
161
+ const { rows, fields, truncated, header } = await new Promise((resolve, reject) => {
162
+ const rows = [];
163
+ let fields;
164
+ let header;
165
+ let truncated = false;
166
+ const command = core.execute(sql, []);
167
+ command.on('error', reject);
168
+ command.on('fields', (value) => { fields = value; });
169
+ command.on('result', (row) => {
170
+ if (fields === undefined) {
171
+ header = row;
172
+ return;
173
+ }
174
+ if (rows.length < maxRows)
175
+ rows.push(row);
176
+ else
177
+ truncated = true;
178
+ });
179
+ command.on('end', () => resolve({ rows, fields, truncated, header }));
180
+ });
181
+ if (fields === undefined && header !== undefined)
182
+ return header;
183
+ return markTruncated(this.processQueryResults(rows, fields), truncated);
184
+ }
76
185
  registerTableSchema(table, definition) {
77
186
  for (const [columnName, column] of Object.entries(definition)) {
78
187
  if (column.type === 'boolean')
@@ -136,7 +245,7 @@ export class MySQLDialect extends Dialect {
136
245
  return typeMap[type.toLowerCase()] || 'TEXT';
137
246
  }
138
247
  quoteIdentifier(identifier) {
139
- return `\`${identifier}\``;
248
+ return `\`${identifier.replace(/`/g, '``')}\``;
140
249
  }
141
250
  getParameterPlaceholder(index) {
142
251
  return '?';
@@ -151,10 +260,11 @@ export class MySQLDialect extends Dialect {
151
260
  return `'${value.toISOString().slice(0, 19).replace('T', ' ')}'`;
152
261
  }
153
262
  formatJson(value) {
154
- return `'${JSON.stringify(value).replace(/'/g, "''")}'`;
263
+ return `'${this.escapeString(JSON.stringify(value))}'`;
155
264
  }
265
+ /** MySQL string literals treat `\\` as an escape (unless `NO_BACKSLASH_ESCAPES`): double both. */
156
266
  escapeString(value) {
157
- return value.replace(/'/g, "''");
267
+ return value.replace(/\\/g, '\\\\').replace(/'/g, "''");
158
268
  }
159
269
  formatDefaultValue(value) {
160
270
  if (typeof value === 'string') {
@@ -201,7 +311,7 @@ export class MySQLDialect extends Dialect {
201
311
  if (indexedText && (column.length ?? 768) > 768) {
202
312
  throw new RangeError(`MySQL indexed text column ${field} exceeds the utf8mb4 index limit of 768 characters`);
203
313
  }
204
- const collation = indexedText ? ' CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin' : '';
314
+ const collation = indexedText ? ` CHARACTER SET utf8mb4 COLLATE ${this.indexedTextCollation()}` : '';
205
315
  const type = boundedText ? 'VARCHAR' : this.mapColumnType(column.type);
206
316
  const length = boundedText ? `(${column.length ?? 768})` : column.length ? `(${column.length})` : '';
207
317
  const nullable = column.nullable === false ? ' NOT NULL' : '';
@@ -284,11 +394,14 @@ export class MySQLDialect extends Dialect {
284
394
  if (options?.isolationLevel) {
285
395
  await connection.query(`SET TRANSACTION ISOLATION LEVEL ${this.formatIsolationLevel(options.isolationLevel)}`);
286
396
  }
287
- // 开始事务
288
- await connection.beginTransaction();
397
+ // 开始事务;声明只读时数据库拒绝写入
398
+ if (options?.readOnly)
399
+ await connection.query('START TRANSACTION READ ONLY');
400
+ else
401
+ await connection.beginTransaction();
289
402
  }
290
403
  catch (error) {
291
- connection.release();
404
+ await dialect.releaseConnection(connection);
292
405
  throw error;
293
406
  }
294
407
  return {
@@ -297,7 +410,7 @@ export class MySQLDialect extends Dialect {
297
410
  await connection.commit();
298
411
  }
299
412
  finally {
300
- connection.release(); // 归还连接到池
413
+ await dialect.releaseConnection(connection); // 归还连接到池
301
414
  }
302
415
  },
303
416
  async rollback() {
@@ -305,12 +418,15 @@ export class MySQLDialect extends Dialect {
305
418
  await connection.rollback();
306
419
  }
307
420
  finally {
308
- connection.release(); // 归还连接到池
421
+ await dialect.releaseConnection(connection); // 归还连接到池
309
422
  }
310
423
  },
311
424
  async query(sql, params) {
312
425
  const [rows, fields] = await connection.execute(sql, params);
313
426
  return dialect.processQueryResults(rows, fields);
427
+ },
428
+ async queryStatement(sql, options) {
429
+ return await dialect.queryStatementOn(connection, sql, options);
314
430
  }
315
431
  };
316
432
  }
@@ -319,8 +435,11 @@ export class MySQLDialect extends Dialect {
319
435
  if (options?.isolationLevel) {
320
436
  await this.connection.query(`SET TRANSACTION ISOLATION LEVEL ${this.formatIsolationLevel(options.isolationLevel)}`);
321
437
  }
322
- // 开始事务
323
- await this.connection.beginTransaction();
438
+ // 开始事务;声明只读时数据库拒绝写入
439
+ if (options?.readOnly)
440
+ await this.connection.query('START TRANSACTION READ ONLY');
441
+ else
442
+ await this.connection.beginTransaction();
324
443
  const connection = this.connection;
325
444
  return {
326
445
  async commit() {
@@ -332,6 +451,9 @@ export class MySQLDialect extends Dialect {
332
451
  async query(sql, params) {
333
452
  const [rows, fields] = await connection.execute(sql, params);
334
453
  return dialect.processQueryResults(rows, fields);
454
+ },
455
+ async queryStatement(sql, options) {
456
+ return await dialect.queryStatementOn(connection, sql, options);
335
457
  }
336
458
  };
337
459
  }
@@ -8,18 +8,35 @@ export interface PostgreSQLDialectConfig extends ClientConfig {
8
8
  * 如果提供此选项,将使用连接池而不是单连接
9
9
  */
10
10
  pool?: PoolConfig;
11
+ /**
12
+ * Pool mode: `DISCARD ALL` before a connection goes back to the pool (session settings, roles,
13
+ * temp objects and prepared statements never outlive one use). A connection that cannot be
14
+ * reset is destroyed. The restricted Console account uses it (ADR 0059 §4).
15
+ */
16
+ resetSession?: boolean;
11
17
  }
12
18
  export declare class PostgreSQLDialect<S extends Record<string, object> = Record<string, object>> extends Dialect<PostgreSQLDialectConfig, S, string> {
13
19
  private connection;
14
20
  private pool;
15
21
  private usePool;
16
22
  constructor(config: PostgreSQLDialectConfig);
23
+ /** Gives a pool client back, after `DISCARD ALL` with {@link PostgreSQLDialectConfig.resetSession}. */
24
+ private releaseClient;
17
25
  isConnected(): boolean;
18
26
  connect(): Promise<void>;
19
27
  disconnect(): Promise<void>;
20
28
  healthCheck(): Promise<boolean>;
21
29
  query<U = any>(sql: string, params?: any[]): Promise<U>;
30
+ /**
31
+ * One statement through the extended protocol: the server refuses a second command in it
32
+ * (`cannot insert multiple commands into a prepared statement`), whatever the text hides.
33
+ */
34
+ private queryStatementOn;
35
+ private extendedProtocol;
36
+ /** `queryMode: 'extended'` arrived in pg 8.11; an older driver would silently use the simple protocol. */
37
+ private assertExtendedProtocol;
22
38
  private normalizeResult;
39
+ private normalizeRows;
23
40
  dispose(): Promise<void>;
24
41
  /**
25
42
  * 获取连接池统计信息(仅在使用连接池时有效)
@@ -55,6 +72,8 @@ export declare class PostgreSQLDialect<S extends Record<string, object> = Record
55
72
  * 开始事务
56
73
  * 在连接池模式下,会获取一个专用连接用于事务
57
74
  */
75
+ /** `BEGIN [ISOLATION LEVEL …] [READ ONLY]`: a declared read-only transaction refuses writes. */
76
+ private formatBegin;
58
77
  beginTransaction(options?: TransactionOptions): Promise<Transaction>;
59
78
  }
60
79
  export declare class PG<S extends Record<string, object> = Record<string, object>> extends RelatedDatabase<PostgreSQLDialectConfig, S> {
@@ -1,7 +1,21 @@
1
1
  import { Dialect } from '../base/index.js';
2
2
  import { RelatedDatabase } from "../type/related/database.js";
3
3
  import { getLogger } from '@zhin.js/logger';
4
+ import { capRows } from '../base/statement-rows.js';
4
5
  const logger = getLogger('database');
6
+ /**
7
+ * Keeps the driver's `command` / `rowCount` on the row array as non-enumerable properties, so
8
+ * callers that need the affected row count of DML (Console SQL editor) can read it.
9
+ */
10
+ function withCommandInfo(rows, result) {
11
+ if (!Array.isArray(rows) || Object.isFrozen(rows))
12
+ return rows;
13
+ if (typeof result.command === 'string')
14
+ Object.defineProperty(rows, 'command', { value: result.command, enumerable: false, configurable: true });
15
+ if (typeof result.rowCount === 'number')
16
+ Object.defineProperty(rows, 'rowCount', { value: result.rowCount, enumerable: false, configurable: true });
17
+ return rows;
18
+ }
5
19
  export class PostgreSQLDialect extends Dialect {
6
20
  connection = null;
7
21
  pool = null;
@@ -10,6 +24,20 @@ export class PostgreSQLDialect extends Dialect {
10
24
  super('pg', config);
11
25
  this.usePool = !!config.pool;
12
26
  }
27
+ /** Gives a pool client back, after `DISCARD ALL` with {@link PostgreSQLDialectConfig.resetSession}. */
28
+ async releaseClient(client) {
29
+ if (!this.config.resetSession) {
30
+ client.release();
31
+ return;
32
+ }
33
+ try {
34
+ await client.query('DISCARD ALL');
35
+ client.release();
36
+ }
37
+ catch (error) {
38
+ client.release(error instanceof Error ? error : new Error(String(error)));
39
+ }
40
+ }
13
41
  // Connection management
14
42
  isConnected() {
15
43
  return this.usePool ? this.pool !== null : this.connection !== null;
@@ -18,7 +46,7 @@ export class PostgreSQLDialect extends Dialect {
18
46
  try {
19
47
  if (this.usePool) {
20
48
  const { Pool } = await import('pg');
21
- const { pool, ...connectionOptions } = this.config;
49
+ const { pool, resetSession: _reset, ...connectionOptions } = this.config;
22
50
  const poolConfig = {
23
51
  ...connectionOptions,
24
52
  max: pool?.max ?? 10,
@@ -31,7 +59,8 @@ export class PostgreSQLDialect extends Dialect {
31
59
  }
32
60
  else {
33
61
  const { Client } = await import('pg');
34
- this.connection = new Client(this.config);
62
+ const { resetSession: _reset, ...clientOptions } = this.config;
63
+ this.connection = new Client(clientOptions);
35
64
  await this.connection.connect();
36
65
  }
37
66
  }
@@ -64,6 +93,15 @@ export class PostgreSQLDialect extends Dialect {
64
93
  }
65
94
  async query(sql, params) {
66
95
  if (this.usePool) {
96
+ if (this.config.resetSession) {
97
+ const client = await this.pool.connect();
98
+ try {
99
+ return this.normalizeResult(await client.query(sql, params));
100
+ }
101
+ finally {
102
+ await this.releaseClient(client);
103
+ }
104
+ }
67
105
  const result = await this.pool.query(sql, params);
68
106
  return this.normalizeResult(result);
69
107
  }
@@ -72,7 +110,31 @@ export class PostgreSQLDialect extends Dialect {
72
110
  return this.normalizeResult(result);
73
111
  }
74
112
  }
113
+ /**
114
+ * One statement through the extended protocol: the server refuses a second command in it
115
+ * (`cannot insert multiple commands into a prepared statement`), whatever the text hides.
116
+ */
117
+ async queryStatementOn(client, sql, options) {
118
+ await this.assertExtendedProtocol();
119
+ const result = await client.query({ text: sql, values: [], queryMode: 'extended' });
120
+ return capRows(this.normalizeResult(result), options?.maxRows);
121
+ }
122
+ extendedProtocol;
123
+ /** `queryMode: 'extended'` arrived in pg 8.11; an older driver would silently use the simple protocol. */
124
+ assertExtendedProtocol() {
125
+ return this.extendedProtocol ??= (async () => {
126
+ const pg = await import('pg');
127
+ const Query = pg.Query ?? pg.default?.Query;
128
+ const probe = Query ? new Query({ text: 'SELECT 1', values: [], queryMode: 'extended' }) : undefined;
129
+ if (probe?.requiresPreparation?.() !== true) {
130
+ throw new Error('Single-statement queries need the pg driver 8.11 or later (queryMode: extended)');
131
+ }
132
+ })();
133
+ }
75
134
  normalizeResult(result) {
135
+ return withCommandInfo(this.normalizeRows(result), result);
136
+ }
137
+ normalizeRows(result) {
76
138
  const bigintFields = new Set((result.fields ?? [])
77
139
  .filter((field) => field.dataTypeID === 20)
78
140
  .map((field) => field.name));
@@ -120,7 +182,7 @@ export class PostgreSQLDialect extends Dialect {
120
182
  return typeMap[type.toLowerCase()] || 'TEXT';
121
183
  }
122
184
  quoteIdentifier(identifier) {
123
- return `"${identifier}"`;
185
+ return `"${identifier.replace(/"/g, '""')}"`;
124
186
  }
125
187
  getParameterPlaceholder(index) {
126
188
  return `$${index + 1}`;
@@ -244,21 +306,23 @@ export class PostgreSQLDialect extends Dialect {
244
306
  * 开始事务
245
307
  * 在连接池模式下,会获取一个专用连接用于事务
246
308
  */
309
+ /** `BEGIN [ISOLATION LEVEL …] [READ ONLY]`: a declared read-only transaction refuses writes. */
310
+ formatBegin(options) {
311
+ const isolation = options?.isolationLevel ? ` ISOLATION LEVEL ${this.formatIsolationLevel(options.isolationLevel)}` : '';
312
+ return `BEGIN${isolation}${options?.readOnly ? ' READ ONLY' : ''}`;
313
+ }
247
314
  async beginTransaction(options) {
248
315
  const dialect = this;
249
316
  if (this.usePool) {
250
317
  // 从连接池获取一个连接用于事务
251
318
  const client = await this.pool.connect();
252
319
  // 开始事务(可以带隔离级别)
253
- let beginSql = 'BEGIN';
254
- if (options?.isolationLevel) {
255
- beginSql = `BEGIN ISOLATION LEVEL ${this.formatIsolationLevel(options.isolationLevel)}`;
256
- }
320
+ const beginSql = this.formatBegin(options);
257
321
  try {
258
322
  await client.query(beginSql);
259
323
  }
260
324
  catch (error) {
261
- client.release();
325
+ await dialect.releaseClient(client);
262
326
  throw error;
263
327
  }
264
328
  return {
@@ -267,7 +331,7 @@ export class PostgreSQLDialect extends Dialect {
267
331
  await client.query('COMMIT');
268
332
  }
269
333
  finally {
270
- client.release(); // 归还连接到池
334
+ await dialect.releaseClient(client); // 归还连接到池
271
335
  }
272
336
  },
273
337
  async rollback() {
@@ -275,12 +339,15 @@ export class PostgreSQLDialect extends Dialect {
275
339
  await client.query('ROLLBACK');
276
340
  }
277
341
  finally {
278
- client.release(); // 归还连接到池
342
+ await dialect.releaseClient(client); // 归还连接到池
279
343
  }
280
344
  },
281
345
  async query(sql, params) {
282
346
  const result = await client.query(sql, params);
283
347
  return dialect.normalizeResult(result);
348
+ },
349
+ async queryStatement(sql, options) {
350
+ return await dialect.queryStatementOn(client, sql, options);
284
351
  }
285
352
  };
286
353
  }
@@ -288,10 +355,7 @@ export class PostgreSQLDialect extends Dialect {
288
355
  // 单连接模式
289
356
  const dialect = this;
290
357
  // 开始事务(可以带隔离级别)
291
- let beginSql = 'BEGIN';
292
- if (options?.isolationLevel) {
293
- beginSql = `BEGIN ISOLATION LEVEL ${this.formatIsolationLevel(options.isolationLevel)}`;
294
- }
358
+ const beginSql = this.formatBegin(options);
295
359
  await this.query(beginSql);
296
360
  return {
297
361
  async commit() {
@@ -302,6 +366,9 @@ export class PostgreSQLDialect extends Dialect {
302
366
  },
303
367
  async query(sql, params) {
304
368
  return dialect.query(sql, params);
369
+ },
370
+ async queryStatement(sql, options) {
371
+ return await dialect.queryStatementOn(dialect.connection, sql, options);
305
372
  }
306
373
  };
307
374
  }
@@ -18,19 +18,68 @@ export interface SQLiteDialectConfig {
18
18
  * write lock, so two connections in one process cannot deadlock each other.
19
19
  */
20
20
  busyRetryMs?: number;
21
+ /**
22
+ * Refuse `ATTACH DATABASE` on this connection (ADR 0059 §2). Every raw statement — `query`,
23
+ * single statements of a transaction, the isolated read process — reads `PRAGMA database_list`
24
+ * before and after it runs; any schema besides `main` / `temp` fails the statement. Inside a
25
+ * transaction the transaction is rolled back and poisoned (later statements, `COMMIT` included,
26
+ * are refused), then the extra schema is detached. Where Node provides `setAuthorizer`, an
27
+ * authorizer denying `SQLITE_ATTACH` is installed as well. Off by default: the instance turns it
28
+ * on, so its raw SQL cannot open the gateway's `.zhin/gateway.db` beside the project database.
29
+ */
30
+ denyAttach?: boolean;
31
+ }
32
+ /** A statement attached another database file to a connection with {@link SQLiteDialectConfig.denyAttach}. */
33
+ export declare class SqliteAttachRefusedError extends Error {
34
+ readonly schemas: readonly string[];
35
+ readonly code = "attach_refused";
36
+ constructor(schemas: readonly string[]);
21
37
  }
22
38
  /** Default budget of {@link SQLiteDialectConfig.busyRetryMs}. */
23
39
  export declare const SQLITE_BUSY_RETRY_MS = 5000;
24
40
  /** `SQLITE_BUSY` and its extended codes except `SQLITE_BUSY_SNAPSHOT` (a stale read snapshot never clears by waiting). */
25
41
  export declare function isSqliteBusyError(error: unknown): boolean;
42
+ /** Options of {@link SQLiteDialect.queryReadOnlyIsolated}. */
43
+ export interface SqliteIsolatedQueryOptions {
44
+ readonly maxRows: number;
45
+ readonly timeoutMs: number;
46
+ }
47
+ export interface SqliteIsolatedQueryResult {
48
+ readonly columns: readonly string[];
49
+ readonly rows: Record<string, unknown>[];
50
+ readonly truncated: boolean;
51
+ }
52
+ /** Thrown when an isolated read exceeded its time budget (its process is killed). */
53
+ export declare class SqliteQueryTimeoutError extends Error {
54
+ readonly code = "timeout";
55
+ constructor(timeoutMs: number);
56
+ }
26
57
  export declare class SQLiteDialect<S extends Record<string, object> = Record<string, object>> extends Dialect<SQLiteDialectConfig, S, string> {
27
- /** Node 内置 SQLite 连接(node:sqlite),在 connect() 中动态加载 */
28
- private db;
58
+ #private;
29
59
  /** Own transaction flag; `DatabaseSync#isTransaction` is preferred where Node provides it. */
30
60
  private transactionOpen;
61
+ /** The transaction in progress; `poisoned` once a statement in it attached a database (denyAttach). */
62
+ private currentTransaction;
63
+ /** Worker of the isolated read in flight (also while a terminated one is still unwinding). */
64
+ private isolatedReader;
31
65
  constructor(config: SQLiteDialectConfig);
32
66
  isConnected(): boolean;
33
67
  connect(): Promise<void>;
68
+ /** True when {@link queryReadOnlyIsolated} can open its own connection (a file database). */
69
+ supportsIsolatedReads(): boolean;
70
+ /**
71
+ * Runs one read statement on its own read-only connection in a child process, so a slow query
72
+ * never blocks this event loop: rows are iterated up to `maxRows`, and the process is killed
73
+ * (SIGKILL — SQLite cannot be interrupted inside a step otherwise) when `timeoutMs` passes. One
74
+ * isolated read runs at a time per connection; the child never keeps this process alive.
75
+ */
76
+ queryReadOnlyIsolated(sql: string, options: SqliteIsolatedQueryOptions): Promise<SqliteIsolatedQueryResult>;
77
+ /**
78
+ * Copies the database to `destination` (a consistent snapshot, also while WAL frames are not
79
+ * checkpointed): `node:sqlite` `backup()` where Node provides it, otherwise `VACUUM INTO`. The
80
+ * copy is created owner-only (0600). The destination must not exist.
81
+ */
82
+ backupTo(destination: string): Promise<void>;
34
83
  disconnect(): Promise<void>;
35
84
  healthCheck(): Promise<boolean>;
36
85
  /** node:sqlite 仅支持 number/string/bigint/buffer/null;JSON 列在库内为 TEXT,对象需序列化 */
@@ -39,7 +88,18 @@ export declare class SQLiteDialect<S extends Record<string, object> = Record<str
39
88
  /** Async retry budget: only for connections that do not wait synchronously (`busyTimeoutMs`). */
40
89
  private busyRetryBudget;
41
90
  private inTransaction;
91
+ /**
92
+ * denyAttach: fails when `db` has a schema besides main / temp. Inside a transaction the
93
+ * transaction is rolled back first and marked poisoned (DETACH is refused while it holds
94
+ * locks); then every extra schema is detached.
95
+ */
96
+ private assertNotAttached;
97
+ /** Rolls back and poisons an open transaction, detaches `extra` and throws (naming `reported`). */
98
+ private refuseAttach;
99
+ /** Statements of a poisoned transaction are refused (it was rolled back when ATTACH was found). */
100
+ private assertTransactionUsable;
42
101
  private execute;
102
+ private executeUnchecked;
43
103
  /** Column declarations are scoped to the table in this database, never the result alias. */
44
104
  private readonly jsonColumns;
45
105
  registerTableSchema(table: string, definition: Record<string, {