@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/sqlite.js
CHANGED
|
@@ -1,15 +1,140 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
4
|
import { Dialect } from '../base/index.js';
|
|
5
|
+
import { capRows, markTruncated } from '../base/statement-rows.js';
|
|
4
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
|
+
}
|
|
45
|
+
/** Default budget of {@link SQLiteDialectConfig.busyRetryMs}. */
|
|
46
|
+
export const SQLITE_BUSY_RETRY_MS = 5_000;
|
|
47
|
+
const BUSY_RETRY_FIRST_DELAY_MS = 2;
|
|
48
|
+
const BUSY_RETRY_MAX_DELAY_MS = 100;
|
|
49
|
+
/** `SQLITE_BUSY` and its extended codes except `SQLITE_BUSY_SNAPSHOT` (a stale read snapshot never clears by waiting). */
|
|
50
|
+
export function isSqliteBusyError(error) {
|
|
51
|
+
if (!error || typeof error !== 'object')
|
|
52
|
+
return false;
|
|
53
|
+
const errcode = error.errcode;
|
|
54
|
+
return typeof errcode === 'number' && (errcode & 0xff) === 5 && errcode !== 517;
|
|
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;
|
|
112
|
+
function isInMemoryFilename(filename) {
|
|
113
|
+
return filename === ':memory:' || filename === '' || filename.startsWith('file::memory:');
|
|
114
|
+
}
|
|
5
115
|
export class SQLiteDialect extends Dialect {
|
|
6
116
|
/** Node 内置 SQLite 连接(node:sqlite),在 connect() 中动态加载 */
|
|
7
|
-
|
|
117
|
+
/** The `node:sqlite` connection: a true private field, so no handle on the dialect object reaches it. */
|
|
118
|
+
#db = null;
|
|
119
|
+
/** Own transaction flag; `DatabaseSync#isTransaction` is preferred where Node provides it. */
|
|
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;
|
|
8
132
|
constructor(config) {
|
|
9
133
|
super('sqlite', config);
|
|
134
|
+
this.#denyAttach = config.denyAttach === true;
|
|
10
135
|
}
|
|
11
136
|
isConnected() {
|
|
12
|
-
return this
|
|
137
|
+
return this.#db !== null;
|
|
13
138
|
}
|
|
14
139
|
async connect() {
|
|
15
140
|
try {
|
|
@@ -20,19 +145,162 @@ export class SQLiteDialect extends Dialect {
|
|
|
20
145
|
if (!fs.existsSync(dirname)) {
|
|
21
146
|
fs.mkdirSync(dirname, { recursive: true });
|
|
22
147
|
}
|
|
23
|
-
|
|
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);
|
|
151
|
+
try {
|
|
152
|
+
if (deny)
|
|
153
|
+
this.#authorizer = installAttachAuthorizer(db);
|
|
154
|
+
// Two processes share the file (gateway: console_* tables, instance: the rest):
|
|
155
|
+
// WAL lets readers run beside one writer and never blocks readers on a writer.
|
|
156
|
+
const busy = this.config.busyTimeoutMs;
|
|
157
|
+
if (busy !== undefined && Number.isSafeInteger(busy) && busy > 0)
|
|
158
|
+
db.exec(`PRAGMA busy_timeout = ${busy}`);
|
|
159
|
+
if (!isInMemoryFilename(this.config.filename))
|
|
160
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
db.close();
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
this.#db = db;
|
|
24
167
|
}
|
|
25
168
|
catch (error) {
|
|
26
169
|
throw new Error(`SQLite 连接失败(需要 Node.js 22.5+,推荐 24+): ${error}`);
|
|
27
170
|
}
|
|
28
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
|
+
}
|
|
29
290
|
async disconnect() {
|
|
30
|
-
if (this.
|
|
291
|
+
if (this.isolatedReader) {
|
|
292
|
+
this.isolatedReader.killed = true;
|
|
293
|
+
try {
|
|
294
|
+
this.isolatedReader.child.kill('SIGKILL');
|
|
295
|
+
}
|
|
296
|
+
catch { /* already gone */ }
|
|
297
|
+
}
|
|
298
|
+
if (this.#db) {
|
|
31
299
|
try {
|
|
32
|
-
this
|
|
300
|
+
this.#db.close();
|
|
33
301
|
}
|
|
34
302
|
finally {
|
|
35
|
-
this
|
|
303
|
+
this.#db = null;
|
|
36
304
|
}
|
|
37
305
|
}
|
|
38
306
|
}
|
|
@@ -58,7 +326,102 @@ export class SQLiteDialect extends Dialect {
|
|
|
58
326
|
});
|
|
59
327
|
}
|
|
60
328
|
async query(sql, params) {
|
|
61
|
-
|
|
329
|
+
const budget = this.busyRetryBudget();
|
|
330
|
+
if (budget <= 0)
|
|
331
|
+
return this.execute(sql, params);
|
|
332
|
+
const deadline = Date.now() + budget;
|
|
333
|
+
let wait = BUSY_RETRY_FIRST_DELAY_MS;
|
|
334
|
+
for (;;) {
|
|
335
|
+
// A statement inside an open transaction is never replayed here: the transaction's caller
|
|
336
|
+
// owns that retry (BEGIN IMMEDIATE takes the write lock up front, so this is rare).
|
|
337
|
+
// COMMIT is the exception: SQLite documents a busy COMMIT as safe to retry.
|
|
338
|
+
const replayable = !this.inTransaction() || /^\s*commit\b/i.test(sql);
|
|
339
|
+
try {
|
|
340
|
+
return await this.execute(sql, params);
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
if (!replayable || !isSqliteBusyError(error) || Date.now() + wait > deadline)
|
|
344
|
+
throw error;
|
|
345
|
+
}
|
|
346
|
+
await delay(wait);
|
|
347
|
+
wait = Math.min(wait * 2, BUSY_RETRY_MAX_DELAY_MS);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/** Async retry budget: only for connections that do not wait synchronously (`busyTimeoutMs`). */
|
|
351
|
+
busyRetryBudget() {
|
|
352
|
+
const busy = this.config.busyTimeoutMs;
|
|
353
|
+
if (busy !== undefined && busy > 0)
|
|
354
|
+
return 0;
|
|
355
|
+
if (isInMemoryFilename(this.config.filename))
|
|
356
|
+
return 0;
|
|
357
|
+
const budget = this.config.busyRetryMs ?? SQLITE_BUSY_RETRY_MS;
|
|
358
|
+
return Number.isFinite(budget) && budget > 0 ? budget : 0;
|
|
359
|
+
}
|
|
360
|
+
inTransaction() {
|
|
361
|
+
const native = this.#db?.isTransaction;
|
|
362
|
+
return typeof native === 'boolean' ? native : this.transactionOpen;
|
|
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
|
+
}
|
|
403
|
+
async execute(sql, params) {
|
|
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)
|
|
62
425
|
throw new Error('SQLite 未连接');
|
|
63
426
|
const trimmedSql = sql.trim().toLowerCase();
|
|
64
427
|
const isInsert = trimmedSql.startsWith('insert');
|
|
@@ -67,7 +430,7 @@ export class SQLiteDialect extends Dialect {
|
|
|
67
430
|
const sqlWithoutLiterals = trimmedSql.replace(/'[^']*'/g, "''").replace(/"[^"]*"/g, '""');
|
|
68
431
|
const hasReturning = /\breturning\b/.test(sqlWithoutLiterals);
|
|
69
432
|
const args = this.prepareBindParams(params ?? []);
|
|
70
|
-
const stmt = this
|
|
433
|
+
const stmt = this.#db.prepare(sql);
|
|
71
434
|
if (isInsert && !hasReturning) {
|
|
72
435
|
const result = stmt.run(...args);
|
|
73
436
|
return {
|
|
@@ -121,12 +484,12 @@ export class SQLiteDialect extends Dialect {
|
|
|
121
484
|
return result;
|
|
122
485
|
}
|
|
123
486
|
async dispose() {
|
|
124
|
-
if (this
|
|
487
|
+
if (this.#db) {
|
|
125
488
|
try {
|
|
126
|
-
this
|
|
489
|
+
this.#db.close();
|
|
127
490
|
}
|
|
128
491
|
finally {
|
|
129
|
-
this
|
|
492
|
+
this.#db = null;
|
|
130
493
|
}
|
|
131
494
|
}
|
|
132
495
|
}
|
|
@@ -144,7 +507,7 @@ export class SQLiteDialect extends Dialect {
|
|
|
144
507
|
return typeMap[type.toLowerCase()] || 'TEXT';
|
|
145
508
|
}
|
|
146
509
|
quoteIdentifier(identifier) {
|
|
147
|
-
return `"${identifier}"`;
|
|
510
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
148
511
|
}
|
|
149
512
|
getParameterPlaceholder(index) {
|
|
150
513
|
return '?';
|
|
@@ -235,17 +598,87 @@ export class SQLiteDialect extends Dialect {
|
|
|
235
598
|
*/
|
|
236
599
|
async beginTransaction(options) {
|
|
237
600
|
const dialect = this;
|
|
238
|
-
|
|
239
|
-
|
|
601
|
+
const readOnly = options?.readOnly === true;
|
|
602
|
+
// Write transactions: IMMEDIATE takes the write lock at BEGIN, so contention with the
|
|
603
|
+
// gateway's connection surfaces there (retried by query()), never as SQLITE_BUSY_SNAPSHOT
|
|
604
|
+
// halfway through. A declared read-only transaction takes no write lock (DEFERRED: WAL
|
|
605
|
+
// readers never wait for a writer) and query_only makes an accidental write fail loudly.
|
|
606
|
+
// Callers that cannot declare intent (the plugin-runtime Database Host) stay IMMEDIATE.
|
|
607
|
+
await this.query(readOnly ? 'BEGIN DEFERRED' : 'BEGIN IMMEDIATE');
|
|
608
|
+
this.transactionOpen = true;
|
|
609
|
+
const state = { poisoned: false };
|
|
610
|
+
this.currentTransaction = state;
|
|
611
|
+
if (readOnly) {
|
|
612
|
+
try {
|
|
613
|
+
await this.query('PRAGMA query_only = ON');
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
await this.query('ROLLBACK').catch(() => undefined);
|
|
617
|
+
this.transactionOpen = false;
|
|
618
|
+
throw error;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
const close = async () => {
|
|
622
|
+
dialect.transactionOpen = false;
|
|
623
|
+
if (dialect.currentTransaction === state)
|
|
624
|
+
dialect.currentTransaction = null;
|
|
625
|
+
if (readOnly)
|
|
626
|
+
await dialect.query('PRAGMA query_only = OFF').catch(() => undefined);
|
|
627
|
+
};
|
|
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
|
+
}
|
|
636
|
+
try {
|
|
637
|
+
await dialect.query(sql);
|
|
638
|
+
}
|
|
639
|
+
catch (error) {
|
|
640
|
+
// A failed COMMIT (e.g. busy past the budget) leaves the transaction to the caller's rollback.
|
|
641
|
+
if (sql === 'ROLLBACK')
|
|
642
|
+
await close();
|
|
643
|
+
throw error;
|
|
644
|
+
}
|
|
645
|
+
await close();
|
|
646
|
+
};
|
|
240
647
|
return {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
},
|
|
244
|
-
async rollback() {
|
|
245
|
-
await dialect.query('ROLLBACK');
|
|
246
|
-
},
|
|
648
|
+
commit: () => finish('COMMIT'),
|
|
649
|
+
rollback: () => finish('ROLLBACK'),
|
|
247
650
|
async query(sql, params) {
|
|
248
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);
|
|
249
682
|
}
|
|
250
683
|
};
|
|
251
684
|
}
|
package/lib/index.d.ts
CHANGED
|
@@ -13,3 +13,6 @@ export * from './dialects/memory.js';
|
|
|
13
13
|
export * from './dialects/sqlite.js';
|
|
14
14
|
export * from './migration.js';
|
|
15
15
|
export * from './structured-schema.js';
|
|
16
|
+
export * from './probe.js';
|
|
17
|
+
export * from './reserved-tables.js';
|
|
18
|
+
export * from './console-credential.js';
|
package/lib/index.js
CHANGED
|
@@ -13,5 +13,8 @@ export * from './dialects/memory.js';
|
|
|
13
13
|
export * from './dialects/sqlite.js';
|
|
14
14
|
export * from './migration.js';
|
|
15
15
|
export * from './structured-schema.js';
|
|
16
|
+
export * from './probe.js';
|
|
17
|
+
export * from './reserved-tables.js';
|
|
18
|
+
export * from './console-credential.js';
|
|
16
19
|
import { registerBuiltinDatabases } from './builtin-runtime.js';
|
|
17
20
|
registerBuiltinDatabases();
|
package/lib/probe.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-shot connection probe for setup wizards: connect, run the dialect health query, disconnect.
|
|
3
|
+
* Error text is stripped of credentials before it is returned.
|
|
4
|
+
*/
|
|
5
|
+
export type ProbeDialect = 'sqlite' | 'mysql' | 'pg' | 'mongodb';
|
|
6
|
+
export interface DatabaseProbeResult {
|
|
7
|
+
readonly ok: boolean;
|
|
8
|
+
readonly latencyMs?: number;
|
|
9
|
+
readonly error?: string;
|
|
10
|
+
}
|
|
11
|
+
interface ProbeTarget {
|
|
12
|
+
connect(): Promise<void>;
|
|
13
|
+
disconnect(): Promise<void>;
|
|
14
|
+
healthCheck(): Promise<boolean>;
|
|
15
|
+
}
|
|
16
|
+
/** Driver package that must be importable for each dialect (`null`: built into Node). */
|
|
17
|
+
export declare const DATABASE_DRIVER_PACKAGES: Readonly<Record<ProbeDialect, string | null>>;
|
|
18
|
+
/** Whether the optional driver package for `dialect` can be loaded. */
|
|
19
|
+
export declare function isDatabaseDriverAvailable(dialect: ProbeDialect, importer?: (specifier: string) => Promise<unknown>): Promise<boolean>;
|
|
20
|
+
/**
|
|
21
|
+
* Opens a temporary connection with `config` and closes it again.
|
|
22
|
+
* `factory` is a test seam; it defaults to the built-in dialects.
|
|
23
|
+
*/
|
|
24
|
+
export declare function probeDatabaseConnection(dialect: ProbeDialect, config: Record<string, unknown>, options?: Readonly<{
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
factory?: (dialect: ProbeDialect, config: Record<string, unknown>) => Promise<ProbeTarget>;
|
|
27
|
+
}>): Promise<DatabaseProbeResult>;
|
|
28
|
+
/** Removes credentials (configured secret values, URL userinfo) from a driver error. */
|
|
29
|
+
export declare function redactProbeError(error: unknown, secrets?: readonly string[]): string;
|
|
30
|
+
export {};
|