@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 +83 -0
- package/dist/engine.d.ts +71 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +494 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +8 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -610
- package/dist/index.js.map +1 -1
- package/dist/internal/pg-exec.d.ts +113 -0
- package/dist/internal/pg-exec.d.ts.map +1 -0
- package/dist/internal/pg-exec.js +286 -0
- package/dist/internal/pg-exec.js.map +1 -0
- package/dist/internal/pg-lexer.d.ts +52 -0
- package/dist/internal/pg-lexer.d.ts.map +1 -0
- package/dist/internal/pg-lexer.js +273 -0
- package/dist/internal/pg-lexer.js.map +1 -0
- package/dist/internal/shared.d.ts +161 -0
- package/dist/internal/shared.d.ts.map +1 -0
- package/dist/internal/shared.js +538 -0
- package/dist/internal/shared.js.map +1 -0
- package/dist/internal/sqlite-session.d.ts +21 -0
- package/dist/internal/sqlite-session.d.ts.map +1 -0
- package/dist/internal/sqlite-session.js +254 -0
- package/dist/internal/sqlite-session.js.map +1 -0
- package/dist/postgres.d.ts +148 -0
- package/dist/postgres.d.ts.map +1 -0
- package/dist/postgres.js +580 -0
- package/dist/postgres.js.map +1 -0
- package/package.json +15 -2
- package/src/engine.ts +664 -0
- package/src/index.ts +43 -640
- package/src/internal/pg-exec.ts +386 -0
- package/src/internal/pg-lexer.ts +279 -0
- package/src/internal/shared.ts +705 -0
- package/src/internal/sqlite-session.ts +273 -0
- package/src/postgres.ts +777 -0
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),
|
package/dist/engine.d.ts
ADDED
|
@@ -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
|