@zhin.js/database 1.1.4 → 1.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/lib/base/database.d.ts +4 -0
- package/lib/base/database.js +53 -9
- package/lib/base/transaction.js +10 -14
- package/lib/dialects/pg.js +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/structured-schema.d.ts +10 -0
- package/lib/structured-schema.js +32 -0
- package/package.json +1 -1
package/lib/base/database.d.ts
CHANGED
|
@@ -22,6 +22,10 @@ export declare abstract class Database<D = any, S extends Record<string, object>
|
|
|
22
22
|
readonly models: Database.Models<S, D, Q>;
|
|
23
23
|
private _logger?;
|
|
24
24
|
private _logging;
|
|
25
|
+
private readonly transactionContext;
|
|
26
|
+
private connectionTail;
|
|
27
|
+
/** 单连接事务独占连接;连接池仍允许不相关操作并行。 */
|
|
28
|
+
private acquireConnection;
|
|
25
29
|
constructor(dialect: Dialect<D, S, Q>, definitions?: Database.DefinitionObj<S>);
|
|
26
30
|
/**
|
|
27
31
|
* 若方言支持按列声明类型处理(如 SQLite 读路径的 json 反序列化),向其注册表结构
|
package/lib/base/database.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
1
2
|
import * as QueryClasses from './query-classes.js';
|
|
2
3
|
import { TransactionContextImpl } from './transaction.js';
|
|
3
4
|
import { getLogger } from '@zhin.js/logger';
|
|
@@ -21,6 +22,19 @@ export class Database {
|
|
|
21
22
|
models = new Database.Models();
|
|
22
23
|
_logger;
|
|
23
24
|
_logging = false;
|
|
25
|
+
transactionContext = new AsyncLocalStorage();
|
|
26
|
+
connectionTail = Promise.resolve();
|
|
27
|
+
/** 单连接事务独占连接;连接池仍允许不相关操作并行。 */
|
|
28
|
+
async acquireConnection() {
|
|
29
|
+
if (this.dialect.name !== 'sqlite'
|
|
30
|
+
&& (!['mysql', 'pg'].includes(this.dialect.name) || this.config?.pool))
|
|
31
|
+
return () => undefined;
|
|
32
|
+
const previous = this.connectionTail;
|
|
33
|
+
let release;
|
|
34
|
+
this.connectionTail = new Promise(resolve => { release = resolve; });
|
|
35
|
+
await previous;
|
|
36
|
+
return release;
|
|
37
|
+
}
|
|
24
38
|
constructor(dialect, definitions) {
|
|
25
39
|
this.dialect = dialect;
|
|
26
40
|
for (const key in definitions) {
|
|
@@ -108,9 +122,15 @@ export class Database {
|
|
|
108
122
|
if (!this.isStarted) {
|
|
109
123
|
throw new Error('Database not started');
|
|
110
124
|
}
|
|
125
|
+
const context = this.transactionContext.getStore();
|
|
126
|
+
if (context && !context.active)
|
|
127
|
+
throw new Error('Database transaction is already closed');
|
|
128
|
+
const release = context ? () => undefined : await this.acquireConnection();
|
|
111
129
|
const start = Date.now();
|
|
112
130
|
try {
|
|
113
|
-
const result =
|
|
131
|
+
const result = context
|
|
132
|
+
? await context.transaction.query(sql, params)
|
|
133
|
+
: await this.dialect.query(sql, params);
|
|
114
134
|
this.log(String(sql), params, Date.now() - start);
|
|
115
135
|
return result;
|
|
116
136
|
}
|
|
@@ -118,6 +138,9 @@ export class Database {
|
|
|
118
138
|
this.log(String(sql), params, Date.now() - start, error);
|
|
119
139
|
throw error;
|
|
120
140
|
}
|
|
141
|
+
finally {
|
|
142
|
+
release();
|
|
143
|
+
}
|
|
121
144
|
}
|
|
122
145
|
/**
|
|
123
146
|
* 获取数据库方言名称
|
|
@@ -198,16 +221,37 @@ export class Database {
|
|
|
198
221
|
if (!this.isStarted) {
|
|
199
222
|
throw new Error('Database not started');
|
|
200
223
|
}
|
|
201
|
-
|
|
202
|
-
|
|
224
|
+
if (this.transactionContext.getStore())
|
|
225
|
+
throw new Error('Nested database transactions are not supported');
|
|
226
|
+
const release = await this.acquireConnection();
|
|
203
227
|
try {
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
228
|
+
const trx = await this.dialect.beginTransaction(options);
|
|
229
|
+
const frame = { transaction: trx, active: true };
|
|
230
|
+
// 所有入口(含 model 查询)都绑定同一个事务,禁止异步逃逸后继续访问。
|
|
231
|
+
const guarded = {
|
|
232
|
+
query: (sql, params) => {
|
|
233
|
+
if (!frame.active)
|
|
234
|
+
return Promise.reject(new Error('Database transaction is already closed'));
|
|
235
|
+
return trx.query(sql, params);
|
|
236
|
+
},
|
|
237
|
+
commit: () => trx.commit(),
|
|
238
|
+
rollback: () => trx.rollback(),
|
|
239
|
+
};
|
|
240
|
+
const trxContext = new TransactionContextImpl(this, guarded);
|
|
241
|
+
try {
|
|
242
|
+
const result = await this.transactionContext.run(frame, () => callback(trxContext));
|
|
243
|
+
frame.active = false;
|
|
244
|
+
await trxContext.commit();
|
|
245
|
+
return result;
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
frame.active = false;
|
|
249
|
+
await trxContext.rollback();
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
207
252
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
throw error;
|
|
253
|
+
finally {
|
|
254
|
+
release();
|
|
211
255
|
}
|
|
212
256
|
}
|
|
213
257
|
/**
|
package/lib/base/transaction.js
CHANGED
|
@@ -32,7 +32,7 @@ class TrxSelection {
|
|
|
32
32
|
this.offsetCount = count;
|
|
33
33
|
return this;
|
|
34
34
|
}
|
|
35
|
-
then(onfulfilled) {
|
|
35
|
+
then(onfulfilled, onrejected) {
|
|
36
36
|
const { query, params } = this.db.buildQuery({
|
|
37
37
|
type: 'select',
|
|
38
38
|
tableName: this.tableName,
|
|
@@ -42,7 +42,7 @@ class TrxSelection {
|
|
|
42
42
|
limitCount: this.limitCount,
|
|
43
43
|
offsetCount: this.offsetCount
|
|
44
44
|
});
|
|
45
|
-
return this.trx.query(query, params).then(onfulfilled);
|
|
45
|
+
return this.trx.query(query, params).then(onfulfilled, onrejected);
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
/**
|
|
@@ -64,18 +64,15 @@ class TrxUpdation {
|
|
|
64
64
|
this.conditions = { ...this.conditions, ...condition };
|
|
65
65
|
return this;
|
|
66
66
|
}
|
|
67
|
-
then(onfulfilled) {
|
|
67
|
+
then(onfulfilled, onrejected) {
|
|
68
68
|
const { query, params } = this.db.buildQuery({
|
|
69
69
|
type: 'update',
|
|
70
70
|
tableName: this.tableName,
|
|
71
71
|
update: this.data,
|
|
72
72
|
conditions: this.conditions
|
|
73
73
|
});
|
|
74
|
-
return this.trx.query(query, params)
|
|
75
|
-
.then(
|
|
76
|
-
const affected = affectedRows(result);
|
|
77
|
-
return onfulfilled ? onfulfilled(affected) : affected;
|
|
78
|
-
});
|
|
74
|
+
return this.trx.query(this.db.dialectName === 'pg' ? `${query} RETURNING 1` : query, params)
|
|
75
|
+
.then(affectedRows).then(onfulfilled, onrejected);
|
|
79
76
|
}
|
|
80
77
|
}
|
|
81
78
|
/**
|
|
@@ -95,20 +92,19 @@ class TrxDeletion {
|
|
|
95
92
|
this.conditions = { ...this.conditions, ...condition };
|
|
96
93
|
return this;
|
|
97
94
|
}
|
|
98
|
-
then(onfulfilled) {
|
|
95
|
+
then(onfulfilled, onrejected) {
|
|
99
96
|
const { query, params } = this.db.buildQuery({
|
|
100
97
|
type: 'delete',
|
|
101
98
|
tableName: this.tableName,
|
|
102
99
|
conditions: this.conditions
|
|
103
100
|
});
|
|
104
|
-
return this.trx.query(query, params)
|
|
105
|
-
.then(
|
|
106
|
-
const affected = affectedRows(result);
|
|
107
|
-
return onfulfilled ? onfulfilled(affected) : affected;
|
|
108
|
-
});
|
|
101
|
+
return this.trx.query(this.db.dialectName === 'pg' ? `${query} RETURNING 1` : query, params)
|
|
102
|
+
.then(affectedRows).then(onfulfilled, onrejected);
|
|
109
103
|
}
|
|
110
104
|
}
|
|
111
105
|
function affectedRows(result) {
|
|
106
|
+
if (Array.isArray(result))
|
|
107
|
+
return result.length;
|
|
112
108
|
return typeof result === 'number'
|
|
113
109
|
? result
|
|
114
110
|
: result.affectedRows ?? result.changes ?? 0;
|
package/lib/dialects/pg.js
CHANGED
|
@@ -175,7 +175,7 @@ export class PostgreSQLDialect extends Dialect {
|
|
|
175
175
|
}
|
|
176
176
|
formatColumnDefinition(field, column) {
|
|
177
177
|
const name = this.quoteIdentifier(String(field));
|
|
178
|
-
const type = this.mapColumnType(column.type);
|
|
178
|
+
const type = column.type === 'text' && column.length ? 'VARCHAR' : this.mapColumnType(column.type);
|
|
179
179
|
const length = column.length ? `(${column.length})` : '';
|
|
180
180
|
const nullable = column.nullable === false ? ' NOT NULL' : '';
|
|
181
181
|
const primary = column.primary ? ' PRIMARY KEY' : '';
|
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Database } from './base/database.js';
|
|
2
|
+
export type StructuredDatabase = Database<unknown, Record<string, Record<string, unknown>>, string>;
|
|
3
|
+
import type { Column } from './types.js';
|
|
4
|
+
/** Explicit additive migrations for a declared table; never removes old columns/data. */
|
|
5
|
+
export declare function ensureDatabaseColumns(database: StructuredDatabase, table: string, columns: Record<string, Column>): Promise<void>;
|
|
6
|
+
/** Index migration with exact identifier quoting; MySQL TEXT needs a bounded prefix. */
|
|
7
|
+
export declare function ensureDatabaseIndex(database: StructuredDatabase, table: string, name: string, columns: readonly {
|
|
8
|
+
name: string;
|
|
9
|
+
text?: boolean;
|
|
10
|
+
}[]): Promise<void>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Explicit additive migrations for a declared table; never removes old columns/data. */
|
|
2
|
+
export async function ensureDatabaseColumns(database, table, columns) {
|
|
3
|
+
const dialect = database.dialectName;
|
|
4
|
+
if (!['sqlite', 'mysql', 'pg'].includes(dialect))
|
|
5
|
+
return;
|
|
6
|
+
const quote = (name) => dialect === 'mysql' ? `\`${name.replaceAll('`', '``')}\`` : `"${name.replaceAll('"', '""')}"`;
|
|
7
|
+
const rows = dialect === 'sqlite'
|
|
8
|
+
? await database.query(`PRAGMA table_info(${quote(table)})`)
|
|
9
|
+
: dialect === 'mysql' ? await database.query(`SHOW COLUMNS FROM ${quote(table)}`)
|
|
10
|
+
: await database.query('SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1', [table]);
|
|
11
|
+
if (!rows.length)
|
|
12
|
+
throw new Error(`Cannot migrate missing table '${table}'`);
|
|
13
|
+
const existing = new Set(rows.map(row => String(row.name ?? row.Field ?? row.column_name)));
|
|
14
|
+
for (const [name, definition] of Object.entries(columns)) {
|
|
15
|
+
if (!existing.has(name))
|
|
16
|
+
await database.alter(table, { [name]: { action: 'add', ...definition } });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Index migration with exact identifier quoting; MySQL TEXT needs a bounded prefix. */
|
|
20
|
+
export async function ensureDatabaseIndex(database, table, name, columns) {
|
|
21
|
+
const dialect = database.dialectName;
|
|
22
|
+
if (!['sqlite', 'mysql', 'pg'].includes(dialect))
|
|
23
|
+
return;
|
|
24
|
+
const quote = (value) => dialect === 'mysql' ? `\`${value.replaceAll('`', '``')}\`` : `"${value.replaceAll('"', '""')}"`;
|
|
25
|
+
if (dialect === 'mysql') {
|
|
26
|
+
const rows = await database.query(`SHOW INDEX FROM ${quote(table)} WHERE Key_name = ?`, [name]);
|
|
27
|
+
if (rows.length)
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const fields = columns.map(column => quote(column.name) + (dialect === 'mysql' && column.text ? '(128)' : '')).join(', ');
|
|
31
|
+
await database.query(`CREATE INDEX ${dialect === 'mysql' ? '' : 'IF NOT EXISTS '}${quote(name)} ON ${quote(table)} (${fields})`);
|
|
32
|
+
}
|
package/package.json
CHANGED