@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.
@@ -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
- db = null;
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.db !== null;
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
- this.db = new DatabaseSync(this.config.filename);
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.db) {
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.db.close();
300
+ this.#db.close();
33
301
  }
34
302
  finally {
35
- this.db = null;
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
- if (!this.db)
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.db.prepare(sql);
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.db) {
487
+ if (this.#db) {
125
488
  try {
126
- this.db.close();
489
+ this.#db.close();
127
490
  }
128
491
  finally {
129
- this.db = null;
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
- await this.query('BEGIN TRANSACTION');
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
- async commit() {
242
- await dialect.query('COMMIT');
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 {};