@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.
- 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 +62 -2
- package/lib/dialects/sqlite.js +351 -14
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/reserved-tables.d.ts +10 -1
- package/lib/reserved-tables.js +44 -5
- package/lib/types.d.ts +16 -1
- package/package.json +2 -2
package/lib/dialects/sqlite.js
CHANGED
|
@@ -2,7 +2,46 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
4
|
import { Dialect } from '../base/index.js';
|
|
5
|
+
import { capRows, markTruncated } from '../base/statement-rows.js';
|
|
5
6
|
import { RelatedDatabase } from '../type/related/database.js';
|
|
7
|
+
/** A statement attached another database file to a connection with {@link SQLiteDialectConfig.denyAttach}. */
|
|
8
|
+
export class SqliteAttachRefusedError extends Error {
|
|
9
|
+
schemas;
|
|
10
|
+
code = 'attach_refused';
|
|
11
|
+
constructor(schemas) {
|
|
12
|
+
super(`ATTACH DATABASE is not allowed on this connection (attached: ${schemas.join(', ')}); the statement was undone`);
|
|
13
|
+
this.schemas = schemas;
|
|
14
|
+
this.name = 'SqliteAttachRefusedError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Schemas of `PRAGMA database_list` other than `main` / `temp`. */
|
|
18
|
+
function attachedSchemas(db) {
|
|
19
|
+
const rows = db.prepare('PRAGMA database_list').all();
|
|
20
|
+
return rows.map(row => String(row.name ?? '')).filter(name => name !== 'main' && name !== 'temp');
|
|
21
|
+
}
|
|
22
|
+
/** `SQLITE_ATTACH` / `SQLITE_DENY` / `SQLITE_OK` (sqlite3.h), for an authorizer where Node has one. */
|
|
23
|
+
const SQLITE_ATTACH_ACTION = 24;
|
|
24
|
+
const SQLITE_DENY_RESULT = 1;
|
|
25
|
+
const SQLITE_OK_RESULT = 0;
|
|
26
|
+
/** `SQLITE_AUTH`: the statement was refused by the authorizer (which only ever denies ATTACH here). */
|
|
27
|
+
const SQLITE_AUTH_ERRCODE = 23;
|
|
28
|
+
function isAuthorizerDenial(error) {
|
|
29
|
+
const record = error;
|
|
30
|
+
return record?.errcode === SQLITE_AUTH_ERRCODE || /\bnot authorized\b/iu.test(String(record?.message ?? ''));
|
|
31
|
+
}
|
|
32
|
+
/** Installs an authorizer that denies ATTACH when `node:sqlite` supports `setAuthorizer` (feature-detected). */
|
|
33
|
+
function installAttachAuthorizer(db) {
|
|
34
|
+
const target = db;
|
|
35
|
+
if (typeof target.setAuthorizer !== 'function')
|
|
36
|
+
return false;
|
|
37
|
+
try {
|
|
38
|
+
target.setAuthorizer((action) => (action === SQLITE_ATTACH_ACTION ? SQLITE_DENY_RESULT : SQLITE_OK_RESULT));
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
6
45
|
/** Default budget of {@link SQLiteDialectConfig.busyRetryMs}. */
|
|
7
46
|
export const SQLITE_BUSY_RETRY_MS = 5_000;
|
|
8
47
|
const BUSY_RETRY_FIRST_DELAY_MS = 2;
|
|
@@ -14,19 +53,88 @@ export function isSqliteBusyError(error) {
|
|
|
14
53
|
const errcode = error.errcode;
|
|
15
54
|
return typeof errcode === 'number' && (errcode & 0xff) === 5 && errcode !== 517;
|
|
16
55
|
}
|
|
56
|
+
/** Thrown when an isolated read exceeded its time budget (its process is killed). */
|
|
57
|
+
export class SqliteQueryTimeoutError extends Error {
|
|
58
|
+
code = 'timeout';
|
|
59
|
+
constructor(timeoutMs) {
|
|
60
|
+
super(`Query exceeded the ${timeoutMs} ms time limit and was stopped`);
|
|
61
|
+
this.name = 'SqliteQueryTimeoutError';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Child-process body of {@link SQLiteDialect.queryReadOnlyIsolated} (CommonJS, `node -e`): its own
|
|
66
|
+
* read-only connection with `query_only`, rows iterated up to the cap and the wall-clock budget.
|
|
67
|
+
* A separate process, because only a process can be stopped (SIGKILL) inside a native SQLite step.
|
|
68
|
+
*/
|
|
69
|
+
const ISOLATED_READ_CHILD = `
|
|
70
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
71
|
+
const attached = (db) => db.prepare('PRAGMA database_list').all().map(row => String(row.name)).filter(name => name !== 'main' && name !== 'temp');
|
|
72
|
+
process.once('message', (job) => {
|
|
73
|
+
const { filename, sql, maxRows, deadline, json, denyAttach } = job;
|
|
74
|
+
let db;
|
|
75
|
+
let reply;
|
|
76
|
+
try {
|
|
77
|
+
db = new DatabaseSync(filename, denyAttach ? { readOnly: true, limits: { attach: 0 } } : { readOnly: true });
|
|
78
|
+
if (denyAttach && typeof db.setAuthorizer === 'function') {
|
|
79
|
+
try { db.setAuthorizer((action) => (action === 24 ? 1 : 0)); } catch {}
|
|
80
|
+
}
|
|
81
|
+
db.exec('PRAGMA query_only = ON');
|
|
82
|
+
const statement = db.prepare(sql);
|
|
83
|
+
const meta = typeof statement.columns === 'function' ? statement.columns() : [];
|
|
84
|
+
const decode = new Set(meta.filter(c => c.database === 'main' && c.table && c.column && (json[c.table] || []).includes(c.column)).map(c => c.name));
|
|
85
|
+
const rows = [];
|
|
86
|
+
let truncated = false;
|
|
87
|
+
let timeout = false;
|
|
88
|
+
for (const row of statement.iterate()) {
|
|
89
|
+
if (rows.length >= maxRows) { truncated = true; break; }
|
|
90
|
+
const out = {};
|
|
91
|
+
for (const [key, value] of Object.entries(row)) {
|
|
92
|
+
out[key] = value;
|
|
93
|
+
if (decode.has(key) && typeof value === 'string') { try { out[key] = JSON.parse(value); } catch {} }
|
|
94
|
+
}
|
|
95
|
+
rows.push(out);
|
|
96
|
+
if (Date.now() > deadline) { timeout = true; break; }
|
|
97
|
+
}
|
|
98
|
+
const extra = denyAttach ? attached(db) : [];
|
|
99
|
+
reply = extra.length
|
|
100
|
+
? { ok: false, attach: extra }
|
|
101
|
+
: timeout ? { ok: false, timeout: true } : { ok: true, rows, truncated, columns: meta.map(c => c.name) };
|
|
102
|
+
} catch (error) {
|
|
103
|
+
reply = { ok: false, message: String(error && error.message || error), code: error && error.code, errcode: error && error.errcode };
|
|
104
|
+
} finally {
|
|
105
|
+
try { db && db.close(); } catch {}
|
|
106
|
+
}
|
|
107
|
+
process.send(reply, () => process.exit(0));
|
|
108
|
+
});
|
|
109
|
+
`;
|
|
110
|
+
/** How long a new isolated read waits for a stopped one to be gone. */
|
|
111
|
+
const ISOLATED_READER_EXIT_WAIT_MS = 2_000;
|
|
17
112
|
function isInMemoryFilename(filename) {
|
|
18
113
|
return filename === ':memory:' || filename === '' || filename.startsWith('file::memory:');
|
|
19
114
|
}
|
|
20
115
|
export class SQLiteDialect extends Dialect {
|
|
21
116
|
/** Node 内置 SQLite 连接(node:sqlite),在 connect() 中动态加载 */
|
|
22
|
-
|
|
117
|
+
/** The `node:sqlite` connection: a true private field, so no handle on the dialect object reaches it. */
|
|
118
|
+
#db = null;
|
|
23
119
|
/** Own transaction flag; `DatabaseSync#isTransaction` is preferred where Node provides it. */
|
|
24
120
|
transactionOpen = false;
|
|
121
|
+
/** The transaction in progress; `poisoned` once a statement in it attached a database (denyAttach). */
|
|
122
|
+
currentTransaction = null;
|
|
123
|
+
/** Worker of the isolated read in flight (also while a terminated one is still unwinding). */
|
|
124
|
+
isolatedReader = null;
|
|
125
|
+
/**
|
|
126
|
+
* Captured once at construction: later changes to `config` (a plugin holding the config object)
|
|
127
|
+
* cannot turn the ATTACH check off.
|
|
128
|
+
*/
|
|
129
|
+
#denyAttach;
|
|
130
|
+
/** An ATTACH-denying authorizer is installed (Node with `setAuthorizer`). */
|
|
131
|
+
#authorizer = false;
|
|
25
132
|
constructor(config) {
|
|
26
133
|
super('sqlite', config);
|
|
134
|
+
this.#denyAttach = config.denyAttach === true;
|
|
27
135
|
}
|
|
28
136
|
isConnected() {
|
|
29
|
-
return this
|
|
137
|
+
return this.#db !== null;
|
|
30
138
|
}
|
|
31
139
|
async connect() {
|
|
32
140
|
try {
|
|
@@ -37,8 +145,12 @@ export class SQLiteDialect extends Dialect {
|
|
|
37
145
|
if (!fs.existsSync(dirname)) {
|
|
38
146
|
fs.mkdirSync(dirname, { recursive: true });
|
|
39
147
|
}
|
|
40
|
-
const
|
|
148
|
+
const deny = this.#denyAttach;
|
|
149
|
+
// `limits.attach = 0` where Node honours it (ignored by Node 22); the database_list check is the defense.
|
|
150
|
+
const db = deny ? new DatabaseSync(this.config.filename, { limits: { attach: 0 } }) : new DatabaseSync(this.config.filename);
|
|
41
151
|
try {
|
|
152
|
+
if (deny)
|
|
153
|
+
this.#authorizer = installAttachAuthorizer(db);
|
|
42
154
|
// Two processes share the file (gateway: console_* tables, instance: the rest):
|
|
43
155
|
// WAL lets readers run beside one writer and never blocks readers on a writer.
|
|
44
156
|
const busy = this.config.busyTimeoutMs;
|
|
@@ -51,19 +163,144 @@ export class SQLiteDialect extends Dialect {
|
|
|
51
163
|
db.close();
|
|
52
164
|
throw error;
|
|
53
165
|
}
|
|
54
|
-
this
|
|
166
|
+
this.#db = db;
|
|
55
167
|
}
|
|
56
168
|
catch (error) {
|
|
57
169
|
throw new Error(`SQLite 连接失败(需要 Node.js 22.5+,推荐 24+): ${error}`);
|
|
58
170
|
}
|
|
59
171
|
}
|
|
172
|
+
/** True when {@link queryReadOnlyIsolated} can open its own connection (a file database). */
|
|
173
|
+
supportsIsolatedReads() {
|
|
174
|
+
return this.#db !== null && !isInMemoryFilename(this.config.filename);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Runs one read statement on its own read-only connection in a child process, so a slow query
|
|
178
|
+
* never blocks this event loop: rows are iterated up to `maxRows`, and the process is killed
|
|
179
|
+
* (SIGKILL — SQLite cannot be interrupted inside a step otherwise) when `timeoutMs` passes. One
|
|
180
|
+
* isolated read runs at a time per connection; the child never keeps this process alive.
|
|
181
|
+
*/
|
|
182
|
+
async queryReadOnlyIsolated(sql, options) {
|
|
183
|
+
if (!this.supportsIsolatedReads())
|
|
184
|
+
throw new Error('Isolated reads need a file database');
|
|
185
|
+
const previous = this.isolatedReader;
|
|
186
|
+
if (previous) {
|
|
187
|
+
// A killed reader is gone within milliseconds; a running one is not waited for.
|
|
188
|
+
if (previous.killed)
|
|
189
|
+
await Promise.race([previous.exited, delay(ISOLATED_READER_EXIT_WAIT_MS, undefined, { ref: false })]);
|
|
190
|
+
if (this.isolatedReader)
|
|
191
|
+
throw new Error('A previous query is still running; try again shortly');
|
|
192
|
+
}
|
|
193
|
+
const { spawn } = await import('node:child_process');
|
|
194
|
+
const json = Object.fromEntries([...this.jsonColumns].map(([table, columns]) => [table, [...columns]]));
|
|
195
|
+
const { NODE_OPTIONS: _options, ...env } = process.env;
|
|
196
|
+
const child = spawn(process.execPath, ['--disable-warning=ExperimentalWarning', '-e', ISOLATED_READ_CHILD], {
|
|
197
|
+
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
|
198
|
+
serialization: 'advanced',
|
|
199
|
+
env,
|
|
200
|
+
windowsHide: true,
|
|
201
|
+
});
|
|
202
|
+
let markExited;
|
|
203
|
+
const reader = { child, exited: new Promise(resolve => { markExited = resolve; }), killed: false };
|
|
204
|
+
this.isolatedReader = reader;
|
|
205
|
+
// Neither the child nor its channel keeps this process alive (the pending timer does, while needed).
|
|
206
|
+
child.unref();
|
|
207
|
+
child.channel?.unref?.();
|
|
208
|
+
const kill = () => {
|
|
209
|
+
reader.killed = true;
|
|
210
|
+
try {
|
|
211
|
+
child.kill('SIGKILL');
|
|
212
|
+
}
|
|
213
|
+
catch { /* already gone */ }
|
|
214
|
+
};
|
|
215
|
+
return await new Promise((resolve, reject) => {
|
|
216
|
+
let settled = false;
|
|
217
|
+
const finish = (error, value) => {
|
|
218
|
+
if (settled)
|
|
219
|
+
return;
|
|
220
|
+
settled = true;
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
if (error)
|
|
223
|
+
reject(error);
|
|
224
|
+
else
|
|
225
|
+
resolve(value);
|
|
226
|
+
};
|
|
227
|
+
const timer = setTimeout(() => {
|
|
228
|
+
kill();
|
|
229
|
+
finish(new SqliteQueryTimeoutError(options.timeoutMs));
|
|
230
|
+
}, options.timeoutMs);
|
|
231
|
+
child.once('exit', code => {
|
|
232
|
+
if (this.isolatedReader === reader)
|
|
233
|
+
this.isolatedReader = null;
|
|
234
|
+
markExited();
|
|
235
|
+
finish(new Error(`SQLite query process exited (${code ?? 'killed'})`));
|
|
236
|
+
});
|
|
237
|
+
child.once('error', error => {
|
|
238
|
+
if (this.isolatedReader === reader)
|
|
239
|
+
this.isolatedReader = null;
|
|
240
|
+
markExited();
|
|
241
|
+
finish(error);
|
|
242
|
+
});
|
|
243
|
+
child.once('message', (message) => {
|
|
244
|
+
// A child that answered has closed SQLite and is exiting: the next read need not wait.
|
|
245
|
+
if (this.isolatedReader === reader)
|
|
246
|
+
this.isolatedReader = null;
|
|
247
|
+
if (message.attach?.length) {
|
|
248
|
+
finish(new SqliteAttachRefusedError(message.attach));
|
|
249
|
+
}
|
|
250
|
+
else if (!message.ok && this.#denyAttach && isAuthorizerDenial(message)) {
|
|
251
|
+
finish(new SqliteAttachRefusedError(['(refused by the SQLite authorizer)']));
|
|
252
|
+
}
|
|
253
|
+
else if (message.ok) {
|
|
254
|
+
finish(null, { columns: message.columns ?? [], rows: message.rows ?? [], truncated: message.truncated === true });
|
|
255
|
+
}
|
|
256
|
+
else if (message.timeout) {
|
|
257
|
+
finish(new SqliteQueryTimeoutError(options.timeoutMs));
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
finish(Object.assign(new Error(message.message ?? 'SQLite query failed'), { code: message.code, errcode: message.errcode }));
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
child.send({ filename: this.config.filename, sql, maxRows: options.maxRows, deadline: Date.now() + options.timeoutMs, json, denyAttach: this.#denyAttach }, error => {
|
|
264
|
+
if (error) {
|
|
265
|
+
kill();
|
|
266
|
+
finish(error);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Copies the database to `destination` (a consistent snapshot, also while WAL frames are not
|
|
273
|
+
* checkpointed): `node:sqlite` `backup()` where Node provides it, otherwise `VACUUM INTO`. The
|
|
274
|
+
* copy is created owner-only (0600). The destination must not exist.
|
|
275
|
+
*/
|
|
276
|
+
async backupTo(destination) {
|
|
277
|
+
if (!this.#db)
|
|
278
|
+
throw new Error('SQLite 未连接');
|
|
279
|
+
if (fs.existsSync(destination))
|
|
280
|
+
throw new Error(`Backup destination already exists: ${destination}`);
|
|
281
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
|
|
282
|
+
const { createRequire } = await import('node:module');
|
|
283
|
+
const sqlite = createRequire(import.meta.url)('node:sqlite');
|
|
284
|
+
if (typeof sqlite.backup === 'function')
|
|
285
|
+
await sqlite.backup(this.#db, destination);
|
|
286
|
+
else
|
|
287
|
+
this.#db.prepare('VACUUM INTO ?').run(destination);
|
|
288
|
+
fs.chmodSync(destination, 0o600);
|
|
289
|
+
}
|
|
60
290
|
async disconnect() {
|
|
61
|
-
if (this.
|
|
291
|
+
if (this.isolatedReader) {
|
|
292
|
+
this.isolatedReader.killed = true;
|
|
62
293
|
try {
|
|
63
|
-
this.
|
|
294
|
+
this.isolatedReader.child.kill('SIGKILL');
|
|
295
|
+
}
|
|
296
|
+
catch { /* already gone */ }
|
|
297
|
+
}
|
|
298
|
+
if (this.#db) {
|
|
299
|
+
try {
|
|
300
|
+
this.#db.close();
|
|
64
301
|
}
|
|
65
302
|
finally {
|
|
66
|
-
this
|
|
303
|
+
this.#db = null;
|
|
67
304
|
}
|
|
68
305
|
}
|
|
69
306
|
}
|
|
@@ -121,11 +358,70 @@ export class SQLiteDialect extends Dialect {
|
|
|
121
358
|
return Number.isFinite(budget) && budget > 0 ? budget : 0;
|
|
122
359
|
}
|
|
123
360
|
inTransaction() {
|
|
124
|
-
const native = this
|
|
361
|
+
const native = this.#db?.isTransaction;
|
|
125
362
|
return typeof native === 'boolean' ? native : this.transactionOpen;
|
|
126
363
|
}
|
|
364
|
+
/**
|
|
365
|
+
* denyAttach: fails when `db` has a schema besides main / temp. Inside a transaction the
|
|
366
|
+
* transaction is rolled back first and marked poisoned (DETACH is refused while it holds
|
|
367
|
+
* locks); then every extra schema is detached.
|
|
368
|
+
*/
|
|
369
|
+
assertNotAttached() {
|
|
370
|
+
if (!this.#denyAttach || !this.#db)
|
|
371
|
+
return;
|
|
372
|
+
const extra = attachedSchemas(this.#db);
|
|
373
|
+
if (!extra.length)
|
|
374
|
+
return;
|
|
375
|
+
this.refuseAttach(extra);
|
|
376
|
+
}
|
|
377
|
+
/** Rolls back and poisons an open transaction, detaches `extra` and throws (naming `reported`). */
|
|
378
|
+
refuseAttach(extra, reported = extra) {
|
|
379
|
+
if (!this.#db)
|
|
380
|
+
throw new SqliteAttachRefusedError(reported);
|
|
381
|
+
if (this.currentTransaction) {
|
|
382
|
+
this.currentTransaction.poisoned = true;
|
|
383
|
+
try {
|
|
384
|
+
this.#db.exec('ROLLBACK');
|
|
385
|
+
}
|
|
386
|
+
catch { /* not in a transaction any more */ }
|
|
387
|
+
this.transactionOpen = false;
|
|
388
|
+
}
|
|
389
|
+
for (const schema of extra) {
|
|
390
|
+
try {
|
|
391
|
+
this.#db.exec(`DETACH DATABASE ${this.quoteIdentifier(schema)}`);
|
|
392
|
+
}
|
|
393
|
+
catch { /* already gone */ }
|
|
394
|
+
}
|
|
395
|
+
throw new SqliteAttachRefusedError(reported);
|
|
396
|
+
}
|
|
397
|
+
/** Statements of a poisoned transaction are refused (it was rolled back when ATTACH was found). */
|
|
398
|
+
assertTransactionUsable() {
|
|
399
|
+
if (this.currentTransaction?.poisoned) {
|
|
400
|
+
throw new SqliteAttachRefusedError(['(transaction rolled back after ATTACH)']);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
127
403
|
async execute(sql, params) {
|
|
128
|
-
if (!this
|
|
404
|
+
if (!this.#db)
|
|
405
|
+
throw new Error('SQLite 未连接');
|
|
406
|
+
this.assertTransactionUsable();
|
|
407
|
+
this.assertNotAttached();
|
|
408
|
+
try {
|
|
409
|
+
return await this.executeUnchecked(sql, params);
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
// Where Node has `setAuthorizer` (24+), SQLite refuses the ATTACH itself ("not authorized").
|
|
413
|
+
// Same outcome as the database_list defense on Node 22: the transaction is poisoned and the
|
|
414
|
+
// caller sees SqliteAttachRefusedError, whichever Node runs the instance.
|
|
415
|
+
if (this.#authorizer && isAuthorizerDenial(error))
|
|
416
|
+
this.refuseAttach([], ['(refused by the SQLite authorizer)']);
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
419
|
+
finally {
|
|
420
|
+
this.assertNotAttached();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
async executeUnchecked(sql, params) {
|
|
424
|
+
if (!this.#db)
|
|
129
425
|
throw new Error('SQLite 未连接');
|
|
130
426
|
const trimmedSql = sql.trim().toLowerCase();
|
|
131
427
|
const isInsert = trimmedSql.startsWith('insert');
|
|
@@ -134,7 +430,7 @@ export class SQLiteDialect extends Dialect {
|
|
|
134
430
|
const sqlWithoutLiterals = trimmedSql.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, '""');
|
|
135
431
|
const hasReturning = /\breturning\b/.test(sqlWithoutLiterals);
|
|
136
432
|
const args = this.prepareBindParams(params ?? []);
|
|
137
|
-
const stmt = this
|
|
433
|
+
const stmt = this.#db.prepare(sql);
|
|
138
434
|
if (isInsert && !hasReturning) {
|
|
139
435
|
const result = stmt.run(...args);
|
|
140
436
|
return {
|
|
@@ -188,12 +484,12 @@ export class SQLiteDialect extends Dialect {
|
|
|
188
484
|
return result;
|
|
189
485
|
}
|
|
190
486
|
async dispose() {
|
|
191
|
-
if (this
|
|
487
|
+
if (this.#db) {
|
|
192
488
|
try {
|
|
193
|
-
this
|
|
489
|
+
this.#db.close();
|
|
194
490
|
}
|
|
195
491
|
finally {
|
|
196
|
-
this
|
|
492
|
+
this.#db = null;
|
|
197
493
|
}
|
|
198
494
|
}
|
|
199
495
|
}
|
|
@@ -211,7 +507,7 @@ export class SQLiteDialect extends Dialect {
|
|
|
211
507
|
return typeMap[type.toLowerCase()] || 'TEXT';
|
|
212
508
|
}
|
|
213
509
|
quoteIdentifier(identifier) {
|
|
214
|
-
return `"${identifier}"`;
|
|
510
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
215
511
|
}
|
|
216
512
|
getParameterPlaceholder(index) {
|
|
217
513
|
return '?';
|
|
@@ -310,6 +606,8 @@ export class SQLiteDialect extends Dialect {
|
|
|
310
606
|
// Callers that cannot declare intent (the plugin-runtime Database Host) stay IMMEDIATE.
|
|
311
607
|
await this.query(readOnly ? 'BEGIN DEFERRED' : 'BEGIN IMMEDIATE');
|
|
312
608
|
this.transactionOpen = true;
|
|
609
|
+
const state = { poisoned: false };
|
|
610
|
+
this.currentTransaction = state;
|
|
313
611
|
if (readOnly) {
|
|
314
612
|
try {
|
|
315
613
|
await this.query('PRAGMA query_only = ON');
|
|
@@ -322,10 +620,19 @@ export class SQLiteDialect extends Dialect {
|
|
|
322
620
|
}
|
|
323
621
|
const close = async () => {
|
|
324
622
|
dialect.transactionOpen = false;
|
|
623
|
+
if (dialect.currentTransaction === state)
|
|
624
|
+
dialect.currentTransaction = null;
|
|
325
625
|
if (readOnly)
|
|
326
626
|
await dialect.query('PRAGMA query_only = OFF').catch(() => undefined);
|
|
327
627
|
};
|
|
328
628
|
const finish = async (sql) => {
|
|
629
|
+
if (state.poisoned) {
|
|
630
|
+
// Already rolled back when the ATTACH was found: nothing to commit, nothing to roll back.
|
|
631
|
+
await close();
|
|
632
|
+
if (sql === 'COMMIT')
|
|
633
|
+
throw new SqliteAttachRefusedError(['(transaction rolled back after ATTACH)']);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
329
636
|
try {
|
|
330
637
|
await dialect.query(sql);
|
|
331
638
|
}
|
|
@@ -342,6 +649,36 @@ export class SQLiteDialect extends Dialect {
|
|
|
342
649
|
rollback: () => finish('ROLLBACK'),
|
|
343
650
|
async query(sql, params) {
|
|
344
651
|
return dialect.query(sql, params);
|
|
652
|
+
},
|
|
653
|
+
/** `prepare()` compiles only the first statement; with `maxRows` rows are iterated, not all read. */
|
|
654
|
+
async queryStatement(sql, options) {
|
|
655
|
+
const maxRows = options?.maxRows;
|
|
656
|
+
if (maxRows === undefined || !dialect.#db)
|
|
657
|
+
return capRows(await dialect.query(sql), maxRows);
|
|
658
|
+
dialect.assertTransactionUsable();
|
|
659
|
+
dialect.assertNotAttached();
|
|
660
|
+
const rows = [];
|
|
661
|
+
let truncated = false;
|
|
662
|
+
let statement;
|
|
663
|
+
try {
|
|
664
|
+
statement = dialect.#db.prepare(sql);
|
|
665
|
+
for (const row of statement.iterate()) {
|
|
666
|
+
if (rows.length >= maxRows) {
|
|
667
|
+
truncated = true;
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
rows.push(row);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
catch (error) {
|
|
674
|
+
if (dialect.#authorizer && isAuthorizerDenial(error))
|
|
675
|
+
dialect.refuseAttach([], ['(refused by the SQLite authorizer)']);
|
|
676
|
+
throw error;
|
|
677
|
+
}
|
|
678
|
+
finally {
|
|
679
|
+
dialect.assertNotAttached();
|
|
680
|
+
}
|
|
681
|
+
return markTruncated(dialect.processQueryResults(rows, statement.columns()), truncated);
|
|
345
682
|
}
|
|
346
683
|
};
|
|
347
684
|
}
|
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
|
@@ -15,5 +15,6 @@ export * from './migration.js';
|
|
|
15
15
|
export * from './structured-schema.js';
|
|
16
16
|
export * from './probe.js';
|
|
17
17
|
export * from './reserved-tables.js';
|
|
18
|
+
export * from './console-credential.js';
|
|
18
19
|
import { registerBuiltinDatabases } from './builtin-runtime.js';
|
|
19
20
|
registerBuiltinDatabases();
|
package/lib/reserved-tables.d.ts
CHANGED
|
@@ -5,11 +5,20 @@
|
|
|
5
5
|
* them. Deny by prefix so future gateway tables are covered without another list to update.
|
|
6
6
|
*/
|
|
7
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";
|
|
8
13
|
/** `console_`-prefixed tables that the instance itself owns (historical names). */
|
|
9
14
|
export declare const INSTANCE_CONSOLE_TABLES: readonly string[];
|
|
10
15
|
/** True when `name` is a gateway-owned table (case-insensitive, quotes and schema ignored). */
|
|
11
16
|
export declare function isGatewayOwnedTable(name: string): boolean;
|
|
12
|
-
/**
|
|
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
|
+
*/
|
|
13
22
|
export declare function referencesGatewayOwnedTable(sql: string): boolean;
|
|
14
23
|
export declare class GatewayOwnedTableError extends Error {
|
|
15
24
|
constructor(table?: string);
|
package/lib/reserved-tables.js
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* them. Deny by prefix so future gateway tables are covered without another list to update.
|
|
6
6
|
*/
|
|
7
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';
|
|
8
13
|
/** `console_`-prefixed tables that the instance itself owns (historical names). */
|
|
9
14
|
export const INSTANCE_CONSOLE_TABLES = Object.freeze(['console_conversations']);
|
|
10
15
|
const instanceConsoleTables = new Set(INSTANCE_CONSOLE_TABLES);
|
|
@@ -13,14 +18,48 @@ export function isGatewayOwnedTable(name) {
|
|
|
13
18
|
const bare = String(name).trim().replace(/^[`"[]|[`"\]]$/g, '');
|
|
14
19
|
const table = (bare.includes('.') ? bare.slice(bare.lastIndexOf('.') + 1) : bare)
|
|
15
20
|
.replace(/^[`"[]|[`"\]]$/g, '').toLowerCase();
|
|
21
|
+
if (table === GATEWAY_STORE_MARKER_TABLE)
|
|
22
|
+
return true;
|
|
16
23
|
return table.startsWith(GATEWAY_TABLE_PREFIX) && !instanceConsoleTables.has(table);
|
|
17
24
|
}
|
|
18
|
-
const IDENTIFIER = /(?<![A-Za-z0-9_$])console_[A-Za-z0-9_$]
|
|
19
|
-
/**
|
|
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
|
+
*/
|
|
20
54
|
export function referencesGatewayOwnedTable(sql) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
+
}
|
|
24
63
|
}
|
|
25
64
|
return false;
|
|
26
65
|
}
|
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
|
* 键值查询结果类型
|
|
@@ -288,13 +288,28 @@ export interface TransactionOptions {
|
|
|
288
288
|
/**
|
|
289
289
|
* The transaction only reads. SQLite then opens it with `BEGIN DEFERRED` (no write lock, so it
|
|
290
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`.
|
|
291
292
|
*/
|
|
292
293
|
readOnly?: boolean;
|
|
293
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;
|
|
301
|
+
}
|
|
294
302
|
export interface Transaction {
|
|
295
303
|
commit(): Promise<void>;
|
|
296
304
|
rollback(): Promise<void>;
|
|
297
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>;
|
|
298
313
|
}
|
|
299
314
|
/**
|
|
300
315
|
* 增强的事务接口,支持链式调用
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/database",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
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",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
},
|
|
46
46
|
"license": "MIT",
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@zhin.js/logger": "1.
|
|
48
|
+
"@zhin.js/logger": "1.2.0"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"mongodb": ">=3.1.13",
|