@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.
@@ -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
- db = null;
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.db !== null;
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 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);
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.db = db;
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.db) {
291
+ if (this.isolatedReader) {
292
+ this.isolatedReader.killed = true;
62
293
  try {
63
- this.db.close();
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.db = null;
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.db?.isTransaction;
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.db)
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.db.prepare(sql);
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.db) {
487
+ if (this.#db) {
192
488
  try {
193
- this.db.close();
489
+ this.#db.close();
194
490
  }
195
491
  finally {
196
- this.db = null;
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
@@ -15,3 +15,4 @@ 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';
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();
@@ -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
- /** True when SQL text mentions a gateway-owned table anywhere (identifiers, quoted or literal). */
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);
@@ -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_$]*/gi;
19
- /** True when SQL text mentions a gateway-owned table anywhere (identifiers, quoted or literal). */
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
- for (const match of String(sql).matchAll(IDENTIFIER)) {
22
- if (isGatewayOwnedTable(match[0]))
23
- return true;
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.8",
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.1.3"
48
+ "@zhin.js/logger": "1.2.0"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "mongodb": ">=3.1.13",