@nifrajs/mcp-db 3.5.0 → 4.0.2

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 CHANGED
@@ -12,6 +12,8 @@ import { serveDatabaseAsMcp } from "@nifrajs/mcp-db"
12
12
 
13
13
  const mcp = serveDatabaseAsMcp(new Database("app.db"), {
14
14
  tables: ["habits", "entries"],
15
+ // Refuse any other Host, so a DNS-rebound browser page cannot read the schema tools.
16
+ allowedHosts: ["localhost", "127.0.0.1", "[::1]"],
15
17
  })
16
18
  ```
17
19
 
@@ -20,6 +22,87 @@ allowlist. `run_query` requires an authorization hook and is guarded by SQLite q
20
22
  single-statement/read-only checks, query-plan verification, and bounded output. D1 is not supported
21
23
  because it cannot provide the same engine-level read-only guarantee.
22
24
 
25
+ ## The engine: `@nifrajs/mcp-db/engine`
26
+
27
+ The read-only SQLite engine behind the MCP server, without the MCP layer. `nifra db` uses it.
28
+
29
+ ```ts
30
+ import { openReadOnlySqlite, querySqlite, readSqliteSchema } from "@nifrajs/mcp-db/engine"
31
+
32
+ const db = await openReadOnlySqlite("./data/app.db")
33
+ const result = querySqlite(db, "SELECT status, count(*) FROM orders GROUP BY 1", {
34
+ exclude: ["sessions"],
35
+ maxRows: 100,
36
+ maxResultBytes: 100 * 1024,
37
+ })
38
+ if ("code" in result) console.error(result.code, result.fix)
39
+ ```
40
+
41
+ - The file opens with the `readonly` flag and `PRAGMA query_only = ON`, so SQLite itself rejects every
42
+ write. A WAL database with no writer attached (no `-wal` file yet) is reopened read-write with
43
+ `query_only` on, which still rejects writes at the engine.
44
+ - A query must be one SELECT (or WITH ... SELECT). Every table its compiled bytecode opens must be
45
+ exposed (all tables and views minus `exclude`): views read as their base tables, an alias cannot hide
46
+ a table, and `sqlite_master`, virtual tables and table-valued functions are never exposed.
47
+ - With `redaction`, a query that reads a column `redaction.column` matches is refused
48
+ (`NIFRA_DB_COLUMN_REFUSED`) wherever the compiled statement reads it: selected under another name,
49
+ inside an expression, in a filter or an index seek, or through a view. Strings go through
50
+ `redaction.text`.
51
+ - Results are capped by rows and bytes, bigints beyond 2^53 come back as strings, blobs as
52
+ `<n bytes>`.
53
+ - Every refusal is a `DbRefusal`: a stable `NIFRA_DB_*` code, a message, a fix and a docs anchor.
54
+
55
+ ## Postgres: `@nifrajs/mcp-db/postgres`
56
+
57
+ The same result shape and refusal codes for a development Postgres, on `Bun.SQL`.
58
+
59
+ ```ts
60
+ import { isDbRefusal } from "@nifrajs/mcp-db/engine"
61
+ import { connectPostgres, parsePostgresUrl, queryPostgres } from "@nifrajs/mcp-db/postgres"
62
+
63
+ const target = parsePostgresUrl("postgres://nifra_dev_reader:secret@localhost/app")
64
+ if (isDbRefusal(target)) throw new Error(target.message)
65
+ const client = connectPostgres(target, { timeoutMs: 5000 })
66
+ if (isDbRefusal(client)) throw new Error(client.message)
67
+ const result = await queryPostgres(client, "SELECT id, status FROM orders LIMIT 10", {
68
+ timeoutMs: 5000,
69
+ maxRows: 100,
70
+ maxResultBytes: 100 * 1024,
71
+ exclude: ["audit_log"],
72
+ })
73
+ await client.close()
74
+ ```
75
+
76
+ Each layer refuses on its own, in this order:
77
+
78
+ 1. **Host.** Only loopback, `*.localhost` and unix sockets connect unless `allowHosts` names the host.
79
+ URL parameters other than host, port, user, password, database and `sslmode` are ignored.
80
+ 2. **Tokenizer.** One SELECT, WITH, VALUES or TABLE statement; row-locking clauses, unicode-escaped
81
+ identifiers and functions that reach outside a query (server files, `dblink`, `set_config`,
82
+ advisory locks, `pg_notify`, `nextval`, `query_to_xml`, backend signals) are refused.
83
+ 3. **Read-only transaction.** `BEGIN READ ONLY` with `statement_timeout`, `lock_timeout` and
84
+ `idle_in_transaction_session_timeout` set to `timeoutMs`, always rolled back. Sessions also start
85
+ with `default_transaction_read_only = on`.
86
+ 4. **Role and extensions.** A superuser, a member of a superuser or server-file/program role, or a
87
+ role that may execute server-file functions is refused (`NIFRA_DB_SUPERUSER`). A role that can use
88
+ dblink, postgres_fdw, file_fdw or an untrusted language is refused (`NIFRA_DB_EXTENSION`) unless
89
+ `allowExtensions` names it.
90
+ 5. **Cursor.** The statement runs as one extended-protocol `DECLARE ... NO SCROLL CURSOR`, which the
91
+ server accepts only for SELECT and VALUES and refuses for a data-modifying WITH, then one `FETCH`
92
+ of `maxRows + 1` rows.
93
+ 6. **Plan scope.** Every relation in the `EXPLAIN (VERBOSE)` plan, every table it inherits from, and
94
+ every `pg_catalog` function scanned in FROM must sit in `schemas` (default `public`) and outside
95
+ `exclude`. With `redaction`, a column `redaction.column` matches that any plan expression uses
96
+ (output, filter, join, sort or index condition), or a whole row holding one, is refused
97
+ (`NIFRA_DB_COLUMN_REFUSED`).
98
+
99
+ `explainPostgres` returns the plan (with `analyze`, executed inside the same transaction),
100
+ `readPostgresSchema` reads tables, columns, keys and indexes from `pg_catalog`, and `postgresRoleSql`
101
+ writes the SQL for a read-only role (`pg_read_all_data` on 14+, or `GRANT SELECT` per schema with a
102
+ `REVOKE` for each excluded table and its partitions) without running it.
103
+
104
+ The live tests run against a throwaway server: set `NIFRA_TEST_POSTGRES_URL` to its superuser URL.
105
+
23
106
  ## For AI agents
24
107
 
25
108
  Start with [`LLM.md`](./LLM.md) - this package's contract card (the exports you call + its footguns),
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `@nifrajs/mcp-db/engine` - the read-only SQLite engine behind `@nifrajs/mcp-db`, without the MCP
3
+ * layer, plus the result shape and refusal codes both database engines share.
4
+ *
5
+ * Read-only is enforced by SQLite, not promised by a parser: {@link openReadOnlySqlite} opens the file
6
+ * with the `readonly` flag and sets `PRAGMA query_only = ON`, so the engine rejects every write. The
7
+ * statement gates ({@link gateSqliteStatement}: one statement, SELECT/WITH only, every named relation
8
+ * exposed) and a table check on the compiled statement (every table its bytecode opens must be
9
+ * exposed, so views and aliases cannot hide one) sit in front of it, and the caps
10
+ * ({@link shapeRows}, {@link fitToBytes}) bound what comes back.
11
+ *
12
+ * const db = await openReadOnlySqlite("./data/app.db")
13
+ * const result = querySqlite(db, "SELECT status, count(*) FROM orders GROUP BY 1", {
14
+ * exclude: ["sessions"],
15
+ * maxRows: 100,
16
+ * maxResultBytes: 100 * 1024,
17
+ * })
18
+ * if ("code" in result) console.error(result.code, result.fix)
19
+ */
20
+ import type { Database } from "bun:sqlite";
21
+ import { type DbPlan, type DbRedaction, type DbRefusal, type DbRows, type DbSchemaReport, type ShapeRowsOptions } from "./internal/shared.js";
22
+ export { boundedSqliteQuery, countSqliteQuery, type DbPlan, type DbRedaction, type DbRefusal, type DbRefusalCode, type DbRows, type DbSchemaColumn, type DbSchemaForeignKey, type DbSchemaIndex, type DbSchemaReport, type DbSchemaTable, dbRefusal, fitToBytes, gateSqliteStatement, isDbRefusal, REDACTED_CELL, type ShapeRowsOptions, type SqliteDatabaseLike, type SqliteGate, shapeRows, toJsonCell, unexposedPlanRelation, } from "./internal/shared.js";
23
+ /**
24
+ * Resolve a database file against the project root. The file must exist and its real path (symlinks
25
+ * followed) must sit inside the root's real path, unless `allowFiles` names it.
26
+ */
27
+ export declare function resolveSqliteFile(root: string, file: string, allowFiles?: readonly string[]): string | DbRefusal;
28
+ /**
29
+ * Open a SQLite file read-only: the `readonly` flag plus `PRAGMA query_only = ON`.
30
+ *
31
+ * Trap: a read-only connection cannot create a WAL database's `-wal` file, so while no writer has the
32
+ * database open (no `-wal` on disk) the first read fails with SQLITE_CANTOPEN. Only in that case the
33
+ * file is reopened read-write with `query_only` on, which creates the WAL files the way any client
34
+ * would and still rejects every write at the engine. With a live writer the `-wal` exists and the
35
+ * read-only open works.
36
+ */
37
+ export declare function openReadOnlySqlite(file: string): Promise<Database>;
38
+ /** The tables and views of a SQLite database, lowercased: all of them, and those not excluded. */
39
+ export declare function sqliteRelations(db: Pick<Database, "prepare">, exclude?: readonly string[]): {
40
+ readonly all: ReadonlySet<string>;
41
+ readonly exposed: ReadonlySet<string>;
42
+ };
43
+ /** Options for {@link querySqlite} and {@link explainSqlite}. */
44
+ export interface SqliteQueryOptions extends ShapeRowsOptions {
45
+ /** Tables and views the query may not read (case-insensitive). */
46
+ readonly exclude?: readonly string[];
47
+ }
48
+ /**
49
+ * Run one read-only query: the statement gates, the check that every table its bytecode opens is
50
+ * exposed (every table and view minus `exclude`), the refusal of any column `redaction.column`
51
+ * masks that the bytecode reads in any form, then at most `maxRows + 1` rows through
52
+ * {@link shapeRows}. Synchronous: run it where the caller can stop the process (the CLI runs each
53
+ * call in its own subprocess, killed at the deadline).
54
+ */
55
+ export declare function querySqlite(db: Pick<Database, "prepare">, sql: string, options: SqliteQueryOptions): DbRows | DbRefusal;
56
+ /** `EXPLAIN QUERY PLAN` for one statement, after the same gates as {@link querySqlite}. */
57
+ export declare function explainSqlite(db: Pick<Database, "prepare">, sql: string, options: Pick<SqliteQueryOptions, "exclude" | "maxResultBytes">): DbPlan | DbRefusal;
58
+ /** Options for {@link readSqliteSchema}. */
59
+ export interface SqliteSchemaOptions {
60
+ readonly exclude?: readonly string[];
61
+ /** Describe only this table or view. */
62
+ readonly table?: string | undefined;
63
+ readonly redaction?: DbRedaction;
64
+ }
65
+ /**
66
+ * Describe the exposed tables and views from SQLite's own catalog (`sqlite_master` and the
67
+ * `pragma_*` table functions, each bound by parameter): columns, primary key, foreign keys, indexes,
68
+ * and a row count per table.
69
+ */
70
+ export declare function readSqliteSchema(db: Pick<Database, "prepare">, options?: SqliteSchemaOptions): DbSchemaReport | DbRefusal;
71
+ //# sourceMappingURL=engine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;AAG1C,OAAO,EAEL,KAAK,MAAM,EACX,KAAK,WAAW,EAChB,KAAK,SAAS,EACd,KAAK,MAAM,EACX,KAAK,cAAc,EAKnB,KAAK,gBAAgB,EAGtB,MAAM,sBAAsB,CAAA;AAE7B,OAAO,EACL,kBAAkB,EAClB,gBAAgB,EAChB,KAAK,MAAM,EACX,KAAK,WAAW,EAChB,KAAK,SAAS,EACd,KAAK,aAAa,EAClB,KAAK,MAAM,EACX,KAAK,cAAc,EACnB,KAAK,kBAAkB,EACvB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,SAAS,EACT,UAAU,EACV,mBAAmB,EACnB,WAAW,EACX,aAAa,EACb,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,EACvB,KAAK,UAAU,EACf,SAAS,EACT,UAAU,EACV,qBAAqB,GACtB,MAAM,sBAAsB,CAAA;AAW7B;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,UAAU,GAAE,SAAS,MAAM,EAAO,GACjC,MAAM,GAAG,SAAS,CAuBpB;AAwCD;;;;;;;;GAQG;AACH,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC,CA8BxE;AAED,kGAAkG;AAClG,wBAAgB,eAAe,CAC7B,EAAE,EAAE,IAAI,CAAC,QAAQ,EAAE,SAAS,CAAC,EAC7B,OAAO,GAAE,SAAS,MAAM,EAAO,GAC9B;IAAE,QAAQ,CAAC,GAAG,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;CAAE,CAS9E;AAkCD,iEAAiE;AACjE,MAAM,WAAW,kBAAmB,SAAQ,gBAAgB;IAC1D,kEAAkE;IAClE,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CACrC;AAuPD;;;;;;GAMG;AACH,wBAAgB,WAAW,CACzB,EAAE,EAAE,IAAI,CAAC,QAAQ,EAAE,SAAS,CAAC,EAC7B,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,kBAAkB,GAC1B,MAAM,GAAG,SAAS,CAYpB;AAED,2FAA2F;AAC3F,wBAAgB,aAAa,CAC3B,EAAE,EAAE,IAAI,CAAC,QAAQ,EAAE,SAAS,CAAC,EAC7B,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,IAAI,CAAC,kBAAkB,EAAE,SAAS,GAAG,gBAAgB,CAAC,GAC9D,MAAM,GAAG,SAAS,CAuBpB;AAmBD,4CAA4C;AAC5C,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACpC,wCAAwC;IACxC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACnC,QAAQ,CAAC,SAAS,CAAC,EAAE,WAAW,CAAA;CACjC;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,EAAE,EAAE,IAAI,CAAC,QAAQ,EAAE,SAAS,CAAC,EAC7B,OAAO,GAAE,mBAAwB,GAChC,cAAc,GAAG,SAAS,CA2F5B"}
package/dist/engine.js ADDED
@@ -0,0 +1,494 @@
1
+ /**
2
+ * `@nifrajs/mcp-db/engine` - the read-only SQLite engine behind `@nifrajs/mcp-db`, without the MCP
3
+ * layer, plus the result shape and refusal codes both database engines share.
4
+ *
5
+ * Read-only is enforced by SQLite, not promised by a parser: {@link openReadOnlySqlite} opens the file
6
+ * with the `readonly` flag and sets `PRAGMA query_only = ON`, so the engine rejects every write. The
7
+ * statement gates ({@link gateSqliteStatement}: one statement, SELECT/WITH only, every named relation
8
+ * exposed) and a table check on the compiled statement (every table its bytecode opens must be
9
+ * exposed, so views and aliases cannot hide one) sit in front of it, and the caps
10
+ * ({@link shapeRows}, {@link fitToBytes}) bound what comes back.
11
+ *
12
+ * const db = await openReadOnlySqlite("./data/app.db")
13
+ * const result = querySqlite(db, "SELECT status, count(*) FROM orders GROUP BY 1", {
14
+ * exclude: ["sessions"],
15
+ * maxRows: 100,
16
+ * maxResultBytes: 100 * 1024,
17
+ * })
18
+ * if ("code" in result) console.error(result.code, result.fix)
19
+ */
20
+ import { closeSync, existsSync, openSync, readSync, realpathSync } from "node:fs";
21
+ import { isAbsolute, relative, resolve, sep } from "node:path";
22
+ import { boundedSqliteQuery, dbRefusal, fitToBytes, gateSqliteStatement, shapeRows, sqliteCteNames, } from "./internal/shared.js";
23
+ export { boundedSqliteQuery, countSqliteQuery, dbRefusal, fitToBytes, gateSqliteStatement, isDbRefusal, REDACTED_CELL, shapeRows, toJsonCell, unexposedPlanRelation, } from "./internal/shared.js";
24
+ // ---------------------------------------------------------------------------------------------------
25
+ // SQLite: open, scope, query, explain, schema
26
+ // ---------------------------------------------------------------------------------------------------
27
+ const isInside = (root, target) => {
28
+ const rel = relative(root, target);
29
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
30
+ };
31
+ /**
32
+ * Resolve a database file against the project root. The file must exist and its real path (symlinks
33
+ * followed) must sit inside the root's real path, unless `allowFiles` names it.
34
+ */
35
+ export function resolveSqliteFile(root, file, allowFiles = []) {
36
+ const candidate = resolve(root, file);
37
+ if (!existsSync(candidate))
38
+ return dbRefusal("NIFRA_DB_DRIVER", `no database file at ${file}`);
39
+ let real;
40
+ let realRoot;
41
+ try {
42
+ real = realpathSync(candidate);
43
+ realRoot = realpathSync(root);
44
+ }
45
+ catch (error) {
46
+ return dbRefusal("NIFRA_DB_DRIVER", `could not resolve ${file}: ${String(error)}`);
47
+ }
48
+ if (isInside(realRoot, real))
49
+ return real;
50
+ for (const allowed of allowFiles) {
51
+ try {
52
+ if (realpathSync(resolve(root, allowed)) === real)
53
+ return real;
54
+ }
55
+ catch {
56
+ // An allowFiles entry that does not exist allows nothing.
57
+ }
58
+ }
59
+ return dbRefusal("NIFRA_DB_OUTSIDE_ROOT", `${file} resolves to ${real}, outside the project root ${realRoot}`);
60
+ }
61
+ /** True when the file's header says WAL mode (format versions 2/2 at bytes 18-19). */
62
+ function isWalDatabase(file) {
63
+ const header = new Uint8Array(20);
64
+ let fd;
65
+ try {
66
+ fd = openSync(file, "r");
67
+ return readSync(fd, header, 0, 20, 0) === 20 && header[18] === 2 && header[19] === 2;
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ finally {
73
+ if (fd !== undefined)
74
+ closeSync(fd);
75
+ }
76
+ }
77
+ const SQLITE_BUSY_TIMEOUT_MS = 2_000;
78
+ /**
79
+ * Make `close()` finalize every statement prepared on `db`. Otherwise bun:sqlite keeps a connection with
80
+ * live statements open until they are garbage collected, and the process holds the file until then.
81
+ */
82
+ function finalizeOnClose(db) {
83
+ const statements = [];
84
+ const prepare = db.prepare.bind(db);
85
+ const close = db.close.bind(db);
86
+ Object.assign(db, {
87
+ prepare: (...args) => {
88
+ const statement = prepare(...args);
89
+ statements.push(statement);
90
+ return statement;
91
+ },
92
+ close: (throwOnError) => {
93
+ for (const statement of statements.splice(0))
94
+ statement.finalize();
95
+ close(throwOnError);
96
+ },
97
+ });
98
+ return db;
99
+ }
100
+ /**
101
+ * Open a SQLite file read-only: the `readonly` flag plus `PRAGMA query_only = ON`.
102
+ *
103
+ * Trap: a read-only connection cannot create a WAL database's `-wal` file, so while no writer has the
104
+ * database open (no `-wal` on disk) the first read fails with SQLITE_CANTOPEN. Only in that case the
105
+ * file is reopened read-write with `query_only` on, which creates the WAL files the way any client
106
+ * would and still rejects every write at the engine. With a live writer the `-wal` exists and the
107
+ * read-only open works.
108
+ */
109
+ export async function openReadOnlySqlite(file) {
110
+ const { Database } = await import("bun:sqlite");
111
+ const probe = (db) => {
112
+ db.run("PRAGMA query_only = ON");
113
+ // A writer that is mid-recovery or holds the journal lock answers SQLITE_BUSY: wait, not fail.
114
+ db.run(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
115
+ db.prepare("SELECT count(*) FROM sqlite_master").all();
116
+ return db;
117
+ };
118
+ // safeIntegers: an INTEGER beyond 2^53 comes back exact (as a bigint) instead of rounded.
119
+ const readonly = finalizeOnClose(new Database(file, { readonly: true, safeIntegers: true }));
120
+ try {
121
+ return probe(readonly);
122
+ }
123
+ catch (error) {
124
+ readonly.close();
125
+ if (sqliteCode(error) !== "SQLITE_CANTOPEN" ||
126
+ !isWalDatabase(file) ||
127
+ existsSync(`${file}-wal`)) {
128
+ throw error;
129
+ }
130
+ }
131
+ const fallback = finalizeOnClose(new Database(file, { readwrite: true, safeIntegers: true }));
132
+ try {
133
+ return probe(fallback);
134
+ }
135
+ catch (error) {
136
+ fallback.close();
137
+ throw error;
138
+ }
139
+ }
140
+ /** The tables and views of a SQLite database, lowercased: all of them, and those not excluded. */
141
+ export function sqliteRelations(db, exclude = []) {
142
+ const excluded = new Set(exclude.map((name) => name.toLowerCase()));
143
+ const rows = db
144
+ .prepare("SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\'")
145
+ .all();
146
+ const all = new Set(rows.map((row) => row.name.toLowerCase()));
147
+ return { all, exposed: new Set([...all].filter((name) => !excluded.has(name))) };
148
+ }
149
+ const SQLITE_WRITE_SYNTAX = /near "(insert|update|delete|replace|create|drop|alter|attach|detach|pragma|vacuum|reindex|analyze|begin|commit|rollback|savepoint|release)": syntax error/i;
150
+ /** The `SQLITE_*` code bun:sqlite puts on an error, if any. */
151
+ const sqliteCode = (error) => typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
152
+ /** Map a SQLite error to a refusal: a write the engine rejected is a refusal, not a failure. */
153
+ function sqliteFailure(error, context) {
154
+ const code = sqliteCode(error);
155
+ const message = `${context}: ${error instanceof Error ? error.message : String(error)}`;
156
+ if (typeof code === "string") {
157
+ if (code.startsWith("SQLITE_READONLY") || code === "SQLITE_AUTH") {
158
+ return dbRefusal("NIFRA_DB_WRITE_REFUSED", message);
159
+ }
160
+ if (code === "SQLITE_INTERRUPT")
161
+ return dbRefusal("NIFRA_DB_TIMEOUT", message);
162
+ // SQLite has no data-modifying CTE: `WITH d AS (DELETE ...)` fails to parse at the write.
163
+ if (code === "SQLITE_ERROR" && SQLITE_WRITE_SYNTAX.test(message)) {
164
+ return dbRefusal("NIFRA_DB_WRITE_REFUSED", message);
165
+ }
166
+ if (code.startsWith("SQLITE_CANTOPEN") ||
167
+ code.startsWith("SQLITE_CORRUPT") ||
168
+ code.startsWith("SQLITE_IOERR") ||
169
+ code === "SQLITE_NOTADB") {
170
+ return dbRefusal("NIFRA_DB_DRIVER", message);
171
+ }
172
+ }
173
+ return dbRefusal("NIFRA_DB_QUERY_FAILED", message);
174
+ }
175
+ const GATE_MESSAGES = {
176
+ empty: "empty query",
177
+ multiple: "only a single statement is allowed",
178
+ "not-read": "only a read-only query (SELECT, or WITH ... SELECT) is allowed",
179
+ };
180
+ function verifySqlite(db, sql, exclude, masked) {
181
+ const { all, exposed } = sqliteRelations(db, exclude);
182
+ const unexposed = (relation) => dbRefusal("NIFRA_DB_TABLE_EXCLUDED", all.has(relation.toLowerCase())
183
+ ? `the query reads ${JSON.stringify(relation)}, which devDatabase.exclude leaves out`
184
+ : `the query reads ${JSON.stringify(relation)}, which is not a table or view of this database`);
185
+ const ctes = new Set(sqliteCteNames(sql));
186
+ const gate = gateSqliteStatement(sql, (relation) => exposed.has(relation) || ctes.has(relation));
187
+ if (!gate.ok) {
188
+ if (gate.reason === "unexposed")
189
+ return unexposed(gate.relation ?? "");
190
+ return dbRefusal("NIFRA_DB_WRITE_REFUSED", GATE_MESSAGES[gate.reason]);
191
+ }
192
+ let reads;
193
+ try {
194
+ reads = bytecodeReads(db, gate.query, masked);
195
+ }
196
+ catch (error) {
197
+ return sqliteFailure(error, "query failed to plan");
198
+ }
199
+ const relation = reads.tables.find((name) => !exposed.has(name.toLowerCase()));
200
+ if (relation !== undefined)
201
+ return unexposed(relation);
202
+ if (reads.masked.length > 0) {
203
+ return dbRefusal("NIFRA_DB_COLUMN_REFUSED", `the query reads ${reads.masked.join(", ")}, masked as ${reads.masked.length === 1 ? "a credential" : "credentials"}`);
204
+ }
205
+ return { ok: true, query: gate.query };
206
+ }
207
+ const READ_OPCODES = new Set(["OpenRead", "ReopenIdx", "OpenWrite", "VOpen"]);
208
+ // Each compares a cursor's leading key fields (P4 of them, all when P4 is not a count) with values.
209
+ const KEY_COMPARE_OPCODES = new Set([
210
+ "SeekGE",
211
+ "SeekGT",
212
+ "SeekLE",
213
+ "SeekLT",
214
+ "IdxGE",
215
+ "IdxGT",
216
+ "IdxLE",
217
+ "IdxLT",
218
+ "Found",
219
+ "NotFound",
220
+ "NoConflict",
221
+ "IfNoHope",
222
+ ]);
223
+ const ROWID_OPCODES = new Set(["Rowid", "SeekRowid", "NotExists", "IdxRowid"]);
224
+ /**
225
+ * The record layout behind a cursor on `entry`, from SQLite's own catalog. A rowid table stores its
226
+ * non-virtual columns in order, then its virtual ones; a WITHOUT ROWID table and an index store the
227
+ * fields `pragma_index_xinfo` lists. An expression field reads every column its index SQL names.
228
+ */
229
+ function cursorLayout(db, entry) {
230
+ const columns = db
231
+ .prepare("SELECT name, type, pk, hidden FROM pragma_table_xinfo(?) ORDER BY cid")
232
+ .all(entry.table);
233
+ let withoutRowid;
234
+ try {
235
+ withoutRowid =
236
+ Number(db
237
+ .prepare("SELECT wr FROM pragma_table_list WHERE schema = 'main' AND name = ?")
238
+ .get(entry.table)?.wr ?? 0) === 1;
239
+ }
240
+ catch {
241
+ // pragma_table_list is SQLite 3.37+; Bun on macOS links the system library, which can be older.
242
+ const created = db
243
+ .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
244
+ .get(entry.table)?.sql;
245
+ withoutRowid = /\)\s*(?:STRICT\s*,\s*)?WITHOUT\s+ROWID\b/i.test(created ?? "");
246
+ }
247
+ const keys = columns.filter((column) => Number(column.pk) > 0);
248
+ const rowid = !withoutRowid && keys.length === 1 && keys[0]?.type.toUpperCase() === "INTEGER"
249
+ ? keys[0].name
250
+ : undefined;
251
+ let index = entry.type === "index" ? entry.name : undefined;
252
+ if (entry.type === "table" && withoutRowid) {
253
+ index = db
254
+ .prepare("SELECT name FROM pragma_index_list(?) WHERE origin = 'pk'")
255
+ .get(entry.table)?.name;
256
+ }
257
+ if (index === undefined) {
258
+ const stored = [
259
+ ...columns.filter((column) => Number(column.hidden) !== 2),
260
+ ...columns.filter((column) => Number(column.hidden) === 2),
261
+ ];
262
+ return { table: entry.table, fields: stored.map((column) => [column.name]), keyCount: 0, rowid };
263
+ }
264
+ // A substring match can only over-count: an expression field then reads one column too many.
265
+ const source = (entry.sql ?? "").toLowerCase();
266
+ const named = columns
267
+ .map((column) => column.name)
268
+ .filter((name) => source.includes(name.toLowerCase()));
269
+ const xinfo = db
270
+ .prepare("SELECT cid, name, key FROM pragma_index_xinfo(?) ORDER BY seqno")
271
+ .all(index);
272
+ return {
273
+ table: entry.table,
274
+ fields: xinfo.map((field) => field.name !== null
275
+ ? [field.name]
276
+ : Number(field.cid) === -1
277
+ ? rowid === undefined
278
+ ? []
279
+ : [rowid]
280
+ : named),
281
+ keyCount: xinfo.filter((field) => Number(field.key) === 1).length,
282
+ rowid,
283
+ };
284
+ }
285
+ /**
286
+ * The tables a statement's bytecode opens, mapped from each cursor's root page through
287
+ * `sqlite_master`: a view reads as its base tables, an alias cannot rename a table (the query plan
288
+ * would print `SCAN o` for `orders AS o`), and an index read counts as a read of its table. Root
289
+ * page 1 is `sqlite_master` itself; a cursor on another schema (temp, attached) or a virtual table
290
+ * (`pragma_*`, `dbstat`, `json_each`) is never exposed.
291
+ *
292
+ * With `masked`, also every masked column a cursor on a table or index yields (`Column`), compares
293
+ * (a seek or index range check, so a filter counts) or reads as the rowid. Values the statement
294
+ * derives from those reads live in other cursors and registers, so an alias or an expression
295
+ * cannot hide the column it started from.
296
+ */
297
+ function bytecodeReads(db, query, masked) {
298
+ const pages = new Map([
299
+ [1, { type: "table", name: "sqlite_master", table: "sqlite_master", sql: null }],
300
+ ]);
301
+ const catalog = db
302
+ .prepare('SELECT type, name, tbl_name AS "table", rootpage, sql FROM sqlite_master WHERE rootpage > 0')
303
+ .all();
304
+ for (const entry of catalog)
305
+ pages.set(Number(entry.rootpage), entry);
306
+ const program = db.prepare(`EXPLAIN ${query}`).all();
307
+ const reads = new Set();
308
+ const cursors = new Map();
309
+ for (const step of program) {
310
+ if (!READ_OPCODES.has(step.opcode))
311
+ continue;
312
+ const page = Number(step.p2);
313
+ const entry = Number(step.p3) === 0 ? pages.get(page) : undefined;
314
+ if (entry !== undefined && step.opcode !== "VOpen")
315
+ cursors.set(Number(step.p1), entry);
316
+ reads.add(step.opcode === "VOpen"
317
+ ? "<virtual table>"
318
+ : Number(step.p3) === 0
319
+ ? (entry?.table ?? `<root page ${page}>`)
320
+ : `<schema ${step.p3}>`);
321
+ }
322
+ if (masked === undefined)
323
+ return { tables: [...reads], masked: [] };
324
+ const layouts = new Map();
325
+ const layoutOf = (cursor) => {
326
+ const entry = cursors.get(cursor);
327
+ if (entry === undefined || entry.name === "sqlite_master")
328
+ return undefined;
329
+ let layout = layouts.get(cursor);
330
+ if (layout === undefined) {
331
+ layout = cursorLayout(db, entry);
332
+ layouts.set(cursor, layout);
333
+ }
334
+ return layout;
335
+ };
336
+ const hits = new Set();
337
+ const read = (layout, names) => {
338
+ for (const name of names)
339
+ if (masked(name))
340
+ hits.add(`${layout.table}.${name}`);
341
+ };
342
+ for (const step of program) {
343
+ const layout = layoutOf(Number(step.p1));
344
+ if (layout === undefined)
345
+ continue;
346
+ if (step.opcode === "Column")
347
+ read(layout, layout.fields[Number(step.p2)] ?? []);
348
+ else if (KEY_COMPARE_OPCODES.has(step.opcode)) {
349
+ const count = Number(step.p4);
350
+ const compared = Number.isInteger(count) && count > 0 ? count : layout.keyCount;
351
+ for (const field of layout.fields.slice(0, compared))
352
+ read(layout, field);
353
+ }
354
+ else if (ROWID_OPCODES.has(step.opcode) && layout.rowid !== undefined) {
355
+ read(layout, [layout.rowid]);
356
+ }
357
+ }
358
+ return { tables: [...reads], masked: [...hits] };
359
+ }
360
+ /**
361
+ * Run one read-only query: the statement gates, the check that every table its bytecode opens is
362
+ * exposed (every table and view minus `exclude`), the refusal of any column `redaction.column`
363
+ * masks that the bytecode reads in any form, then at most `maxRows + 1` rows through
364
+ * {@link shapeRows}. Synchronous: run it where the caller can stop the process (the CLI runs each
365
+ * call in its own subprocess, killed at the deadline).
366
+ */
367
+ export function querySqlite(db, sql, options) {
368
+ const verified = verifySqlite(db, sql, options.exclude, options.redaction?.column);
369
+ if (!("ok" in verified))
370
+ return verified;
371
+ try {
372
+ const statement = db.prepare(boundedSqliteQuery(verified.query, options.maxRows));
373
+ const rows = statement.values();
374
+ const columns = [...statement.columnNames];
375
+ statement.finalize();
376
+ return shapeRows(columns, rows, options);
377
+ }
378
+ catch (error) {
379
+ return sqliteFailure(error, "query failed");
380
+ }
381
+ }
382
+ /** `EXPLAIN QUERY PLAN` for one statement, after the same gates as {@link querySqlite}. */
383
+ export function explainSqlite(db, sql, options) {
384
+ const verified = verifySqlite(db, sql, options.exclude);
385
+ if (!("ok" in verified))
386
+ return verified;
387
+ try {
388
+ const plan = db
389
+ .prepare(`EXPLAIN QUERY PLAN ${verified.query}`)
390
+ .all();
391
+ const steps = plan.map(({ id, parent, detail }) => ({
392
+ id: Number(id),
393
+ parent: Number(parent),
394
+ detail,
395
+ }));
396
+ const fit = fitToBytes(steps.length, (shown) => steps.slice(0, shown), options.maxResultBytes);
397
+ return {
398
+ plan: fit?.value ?? [],
399
+ analyzed: false,
400
+ truncated: (fit?.shown ?? 0) < steps.length,
401
+ };
402
+ }
403
+ catch (error) {
404
+ return sqliteFailure(error, "query failed to plan");
405
+ }
406
+ }
407
+ const quoteSqliteIdentifier = (name) => `"${name.replaceAll('"', '""')}"`;
408
+ /**
409
+ * Describe the exposed tables and views from SQLite's own catalog (`sqlite_master` and the
410
+ * `pragma_*` table functions, each bound by parameter): columns, primary key, foreign keys, indexes,
411
+ * and a row count per table.
412
+ */
413
+ export function readSqliteSchema(db, options = {}) {
414
+ const excluded = new Set((options.exclude ?? []).map((name) => name.toLowerCase()));
415
+ const wanted = options.table?.toLowerCase();
416
+ let entries;
417
+ try {
418
+ entries = db
419
+ .prepare("SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name")
420
+ .all();
421
+ }
422
+ catch (error) {
423
+ return sqliteFailure(error, "could not read the schema");
424
+ }
425
+ const exposed = entries.filter((entry) => !excluded.has(entry.name.toLowerCase()));
426
+ if (wanted !== undefined && !exposed.some((entry) => entry.name.toLowerCase() === wanted)) {
427
+ return dbRefusal("NIFRA_DB_TABLE_EXCLUDED", `${JSON.stringify(options.table)} is not an exposed table or view of this database`);
428
+ }
429
+ const text = (value) => options.redaction?.text?.(value) ?? value;
430
+ const tables = [];
431
+ try {
432
+ for (const entry of exposed) {
433
+ if (wanted !== undefined && entry.name.toLowerCase() !== wanted)
434
+ continue;
435
+ const columns = db
436
+ .prepare('SELECT name, type, "notnull" AS required, dflt_value AS dflt, pk FROM pragma_table_info(?) ORDER BY cid')
437
+ .all(entry.name);
438
+ const keys = db
439
+ .prepare('SELECT id, "table" AS ref, "from" AS col, "to" AS refcol FROM pragma_foreign_key_list(?) ORDER BY id, seq')
440
+ .all(entry.name);
441
+ const foreignKeys = new Map();
442
+ for (const key of keys) {
443
+ const id = Number(key.id);
444
+ const fk = foreignKeys.get(id) ?? { columns: [], table: key.ref, refs: [] };
445
+ fk.columns.push(key.col);
446
+ if (key.refcol !== null)
447
+ fk.refs.push(key.refcol);
448
+ foreignKeys.set(id, fk);
449
+ }
450
+ const indexes = db
451
+ .prepare('SELECT name, "unique" AS uniq FROM pragma_index_list(?) ORDER BY seq')
452
+ .all(entry.name)
453
+ .map((index) => ({
454
+ name: index.name,
455
+ unique: Number(index.uniq) === 1,
456
+ columns: db
457
+ .prepare("SELECT name FROM pragma_index_info(?) ORDER BY seqno")
458
+ .all(index.name)
459
+ .map((column) => column.name ?? "<expression>"),
460
+ }));
461
+ let rowEstimate = null;
462
+ if (entry.type === "table") {
463
+ // nifra-expect sql-dynamic: the table name comes from sqlite_master, quoted as an identifier
464
+ const counted = db
465
+ .prepare(`SELECT count(*) AS n FROM ${quoteSqliteIdentifier(entry.name)}`)
466
+ .all();
467
+ rowEstimate = counted[0] === undefined ? null : Number(counted[0].n);
468
+ }
469
+ tables.push({
470
+ name: entry.name,
471
+ kind: entry.type,
472
+ rowEstimate,
473
+ columns: columns.map((column) => ({
474
+ name: column.name,
475
+ type: column.type,
476
+ nullable: Number(column.required) === 0 && Number(column.pk) === 0,
477
+ default: column.dflt === null ? null : text(column.dflt),
478
+ primaryKey: Number(column.pk) > 0,
479
+ redacted: options.redaction?.column?.(column.name) === true,
480
+ })),
481
+ foreignKeys: [...foreignKeys.values()].map((fk) => ({
482
+ columns: fk.columns,
483
+ references: { table: fk.table, columns: fk.refs },
484
+ })),
485
+ indexes,
486
+ });
487
+ }
488
+ }
489
+ catch (error) {
490
+ return sqliteFailure(error, "could not read the schema");
491
+ }
492
+ return { tables, excludedCount: entries.length - exposed.length };
493
+ }
494
+ //# sourceMappingURL=engine.js.map