@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/README.md +16 -10
- package/lib/base/database.js +7 -0
- package/lib/base/statement-rows.d.ts +5 -0
- package/lib/base/statement-rows.js +16 -0
- package/lib/base/transaction.d.ts +3 -1
- package/lib/base/transaction.js +6 -0
- package/lib/console-credential.d.ts +181 -0
- package/lib/console-credential.js +633 -0
- package/lib/dialects/mongodb.js +7 -2
- package/lib/dialects/mysql.d.ts +34 -0
- package/lib/dialects/mysql.js +135 -13
- package/lib/dialects/pg.d.ts +19 -0
- package/lib/dialects/pg.js +81 -14
- package/lib/dialects/sqlite.d.ts +86 -2
- package/lib/dialects/sqlite.js +453 -20
- package/lib/index.d.ts +3 -0
- package/lib/index.js +3 -0
- package/lib/probe.d.ts +30 -0
- package/lib/probe.js +98 -0
- package/lib/reserved-tables.d.ts +27 -0
- package/lib/reserved-tables.js +78 -0
- package/lib/types.d.ts +21 -1
- package/package.json +4 -3
package/lib/dialects/mysql.d.ts
CHANGED
|
@@ -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;
|
package/lib/dialects/mysql.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/dialects/pg.d.ts
CHANGED
|
@@ -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> {
|
package/lib/dialects/pg.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/dialects/sqlite.d.ts
CHANGED
|
@@ -4,18 +4,102 @@ import { RelatedDatabase } from '../type/related/database.js';
|
|
|
4
4
|
export interface SQLiteDialectConfig {
|
|
5
5
|
filename: string;
|
|
6
6
|
mode?: string;
|
|
7
|
+
/**
|
|
8
|
+
* `PRAGMA busy_timeout` in ms (default 0: no synchronous wait; see `busyRetryMs`). The wait
|
|
9
|
+
* is synchronous (`node:sqlite`), so only a connection whose own writes are short statements
|
|
10
|
+
* should set it — the gateway does for its `console_*` connection (ADR 0054 §7). Two
|
|
11
|
+
* connections of one process with async transactions would block each other's event loop.
|
|
12
|
+
*/
|
|
13
|
+
busyTimeoutMs?: number;
|
|
14
|
+
/**
|
|
15
|
+
* Without `busyTimeoutMs`, a statement outside a transaction (and `BEGIN IMMEDIATE`) that hits
|
|
16
|
+
* `SQLITE_BUSY` is retried with an async backoff for at most this long (default 5000 ms; 0
|
|
17
|
+
* disables). The event loop keeps running while another process (the gateway) holds the
|
|
18
|
+
* write lock, so two connections in one process cannot deadlock each other.
|
|
19
|
+
*/
|
|
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[]);
|
|
37
|
+
}
|
|
38
|
+
/** Default budget of {@link SQLiteDialectConfig.busyRetryMs}. */
|
|
39
|
+
export declare const SQLITE_BUSY_RETRY_MS = 5000;
|
|
40
|
+
/** `SQLITE_BUSY` and its extended codes except `SQLITE_BUSY_SNAPSHOT` (a stale read snapshot never clears by waiting). */
|
|
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);
|
|
7
56
|
}
|
|
8
57
|
export declare class SQLiteDialect<S extends Record<string, object> = Record<string, object>> extends Dialect<SQLiteDialectConfig, S, string> {
|
|
9
|
-
|
|
10
|
-
|
|
58
|
+
#private;
|
|
59
|
+
/** Own transaction flag; `DatabaseSync#isTransaction` is preferred where Node provides it. */
|
|
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;
|
|
11
65
|
constructor(config: SQLiteDialectConfig);
|
|
12
66
|
isConnected(): boolean;
|
|
13
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>;
|
|
14
83
|
disconnect(): Promise<void>;
|
|
15
84
|
healthCheck(): Promise<boolean>;
|
|
16
85
|
/** node:sqlite 仅支持 number/string/bigint/buffer/null;JSON 列在库内为 TEXT,对象需序列化 */
|
|
17
86
|
private prepareBindParams;
|
|
18
87
|
query<U = any>(sql: string, params?: any[]): Promise<U>;
|
|
88
|
+
/** Async retry budget: only for connections that do not wait synchronously (`busyTimeoutMs`). */
|
|
89
|
+
private busyRetryBudget;
|
|
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;
|
|
101
|
+
private execute;
|
|
102
|
+
private executeUnchecked;
|
|
19
103
|
/** Column declarations are scoped to the table in this database, never the result alias. */
|
|
20
104
|
private readonly jsonColumns;
|
|
21
105
|
registerTableSchema(table: string, definition: Record<string, {
|