diff --git a/src/sqlite-driver.ts b/src/sqlite-driver.ts index 38c1496c..a20133bc 100644 --- a/src/sqlite-driver.ts +++ b/src/sqlite-driver.ts @@ -1,5 +1,6 @@ -import { constants, DatabaseSync, type StatementColumnMetadata, type StatementResultingChanges } from "node:sqlite"; +import { createRequire } from "node:module"; import type { PathLike } from "node:fs"; +import type { DatabaseSync, StatementColumnMetadata, StatementResultingChanges, StatementSync } from "node:sqlite"; export type SqliteValue = null | number | bigint | string | NodeJS.ArrayBufferView; export type SqliteRow = Record; @@ -7,17 +8,46 @@ export type SqliteRawRow = unknown[]; export type SqliteRunResult = StatementResultingChanges; export type SqliteColumn = StatementColumnMetadata; +type SqliteConstants = { + SQLITE_DENY: number; + SQLITE_FUNCTION: number; + SQLITE_OK: number; + SQLITE_PRAGMA: number; + SQLITE_READ: number; + SQLITE_SELECT: number; + SQLITE_TRANSACTION: number; +}; +type SqliteDatabaseConstructor = new ( + filePath: PathLike, + options?: { readOnly?: boolean | undefined; timeout?: number }, +) => DatabaseSync; +type NodeSqliteModule = { + DatabaseSync: SqliteDatabaseConstructor; + constants: SqliteConstants; +}; type SqliteParameterInput = SqliteValue | readonly SqliteValue[]; -const isSqliteValueArray = (value: SqliteParameterInput): value is readonly SqliteValue[] => Array.isArray(value); +const requireNodeModule = createRequire(import.meta.url); +let sqliteModule: NodeSqliteModule | undefined; + +function loadNodeSqlite(): NodeSqliteModule { + if (sqliteModule) return sqliteModule; + const loaded = requireNodeModule("node:sqlite") as NodeSqliteModule; + sqliteModule = loaded; + return loaded; +} -const readOnlyAllowedActions = new Set([ - constants.SQLITE_FUNCTION, - constants.SQLITE_PRAGMA, - constants.SQLITE_READ, - constants.SQLITE_SELECT, - constants.SQLITE_TRANSACTION, -]); +function isReadOnlyAllowedAction(actionCode: number, constants: SqliteConstants): boolean { + return ( + actionCode === constants.SQLITE_FUNCTION || + actionCode === constants.SQLITE_PRAGMA || + actionCode === constants.SQLITE_READ || + actionCode === constants.SQLITE_SELECT || + actionCode === constants.SQLITE_TRANSACTION + ); +} + +const isSqliteValueArray = (value: SqliteParameterInput): value is readonly SqliteValue[] => Array.isArray(value); const normalizeParams = (params: readonly SqliteParameterInput[]): SqliteValue[] => { if (params.length === 1) { @@ -36,7 +66,7 @@ const normalizeParams = (params: readonly SqliteParameterInput[]): SqliteValue[] export class SqliteStatement { constructor( - private readonly statement: ReturnType, + private readonly statement: StatementSync, private readonly returnArrays = false, ) { this.statement.setReturnArrays(returnArrays); @@ -71,13 +101,15 @@ export class SqliteDatabase { private readonly db: DatabaseSync; constructor(filePath: PathLike, options?: { readonly?: boolean }) { - this.db = new DatabaseSync(filePath, { + const sqlite = loadNodeSqlite(); + this.db = new sqlite.DatabaseSync(filePath, { readOnly: options?.readonly, timeout: 5000, }); if (options?.readonly) { + const { constants } = sqlite; this.db.setAuthorizer((actionCode) => - readOnlyAllowedActions.has(actionCode) ? constants.SQLITE_OK : constants.SQLITE_DENY, + isReadOnlyAllowedAction(actionCode, constants) ? constants.SQLITE_OK : constants.SQLITE_DENY, ); } } diff --git a/tests/cli-regressions.test.ts b/tests/cli-regressions.test.ts index eff64906..e7a85d9e 100644 --- a/tests/cli-regressions.test.ts +++ b/tests/cli-regressions.test.ts @@ -128,13 +128,16 @@ describe("CLI regressions", () => { expect(stdout.trim()).toBe(packageJson.version); }); - it("does not statically load SQLite-backed command modules for version output", async () => { + it("does not statically load SQLite for version output", async () => { const source = await fsp.readFile(sourceCliPath, "utf8"); + const sqliteDriverSource = await fsp.readFile(path.resolve(process.cwd(), "src", "sqlite-driver.ts"), "utf8"); expect(source).not.toContain('from "./cli/artifact.js"'); expect(source).not.toContain('from "./cli/graph.js"'); expect(source).not.toContain('from "./cli/mcp.js"'); expect(source).not.toContain('from "./cli/sql.js"'); + expect(sqliteDriverSource).not.toMatch(/import\s*\{[^}]*\b(?:constants|DatabaseSync)\b[^}]*\}\s*from\s*[\"']node:sqlite[\"']/s); + expect(sqliteDriverSource).toMatch(/requireNodeModule\(\s*[\"']node:sqlite[\"']\s*\)/); }); it("importing cli.ts as a module does not execute the entrypoint", async () => {