/** * drivers/mongodb.ts — MongoDB driver via the official `mongodb` package. * * Design: * - `MongoClient` *is* the pool. The driver lazily creates one shared * client on the first `connect()` call and reuses it for subsequent * handles. `close()` is a no-op because we don't want one caller's * `getConnection()/releaseConnection()` cycle to destroy the pool for * concurrent callers. {@link shutdown} closes the client at teardown. * - Collections are the MongoDB equivalent of tables. `getSchemaAsync` * lists collections and infers columns by sampling up to 50 documents * and unioning their top-level keys. * - `executeReadAsync(conn, query)` treats `query` as a JSON envelope: * { "collection": "", "op": "find"|"aggregate"|"count", * "filter": {...}, "projection": {...}, "sort": {...}, * "pipeline": [ ... ] * } * This is the same shape the wiki_db policy layer emits for Mongo envs. * - READ-ONLY: the driver exposes only `find`, `aggregate`, and `count`. * Any other `op` is rejected at the driver layer with a `SQL_ERROR` * whose error message begins `"forbidden op"`. We never call any * mutating method (insertOne, updateOne, deleteOne, …). * - The `mongodb` package is loaded via dynamic `import()`; missing * install throws a clear `npm install mongodb` hint. */ import { performance } from "node:perf_hooks"; import { Column, DatabaseDriver, Table, type ExecuteReadResult, } from "./base.js"; import { OperationType } from "../policy.js"; /** * G-DB-1: MongoDB verb → OperationType mapping. * * READ: collection-reading verbs the driver actually supports here, plus * `count`/`distinct`/`estimatedDocumentCount` which the spec considers * read-only. DML: write verbs (insert/update/delete/replace/findAndModify * variants + bulk). DDL: collection/index lifecycle. Unknown verbs * default to DDL (most restrictive). */ const _MONGO_READ_OPS: ReadonlySet = new Set([ "find", "findOne", "aggregate", "count", "countDocuments", "estimatedDocumentCount", "distinct", ]); const _MONGO_DML_OPS: ReadonlySet = new Set([ "insertOne", "insertMany", "updateOne", "updateMany", "replaceOne", "deleteOne", "deleteMany", "bulkWrite", "findOneAndUpdate", "findOneAndReplace", "findOneAndDelete", ]); const _MONGO_DDL_OPS: ReadonlySet = new Set([ "createCollection", "drop", "dropCollection", "renameCollection", "createIndex", "createIndexes", "dropIndex", "dropIndexes", ]); // --------------------------------------------------------------------------- // Minimal ambient types // --------------------------------------------------------------------------- interface MongoCursor { toArray(): Promise; limit(n: number): MongoCursor; project(spec: Record): MongoCursor; sort(spec: Record): MongoCursor; } interface MongoCollection { find( filter?: Record, options?: Record, ): MongoCursor>; aggregate( pipeline: Record[], options?: Record, ): MongoCursor>; countDocuments(filter?: Record): Promise; } interface MongoDb { collection(name: string): MongoCollection; command(cmd: Record): Promise>; listCollections(): { toArray(): Promise<{ name: string; type?: string }[]>; }; } interface MongoClient { connect(): Promise; close(): Promise; db(name?: string): MongoDb; } interface MongoModule { MongoClient: new ( uri: string, options?: Record, ) => MongoClient; } // --------------------------------------------------------------------------- // Envelope // --------------------------------------------------------------------------- interface MongoQueryEnvelope { collection: string; op: "find" | "aggregate" | "count"; filter?: Record; projection?: Record; sort?: Record; pipeline?: Record[]; } const READ_ONLY_OPS = new Set(["find", "aggregate", "count"]); // --------------------------------------------------------------------------- // Driver // --------------------------------------------------------------------------- interface MongoHandle { db: MongoDb; database: string; } export class MongoDriver extends DatabaseDriver { private _mongoModule: MongoModule | null = null; private _client: MongoClient | null = null; private _clientPromise: Promise | null = null; private _database = ""; private async _loadMongo(): Promise { if (this._mongoModule !== null) return this._mongoModule; try { const mod = (await import("mongodb")) as unknown as | MongoModule | { default: MongoModule }; this._mongoModule = "MongoClient" in (mod as object) ? (mod as MongoModule) : (mod as { default: MongoModule }).default; return this._mongoModule; } catch (e) { throw new Error( `Driver 'mongodb' requires 'mongodb' — run: npm install mongodb (${ (e as Error).message })`, ); } } private _ensureClient( envConfig: Record, ): Promise { if (this._client !== null) return Promise.resolve(this._client); if (this._clientPromise !== null) return this._clientPromise; this._clientPromise = this._loadMongo().then(async (mongo) => { const uri = typeof envConfig["uri"] === "string" ? envConfig["uri"] : _buildUri(envConfig); this._database = typeof envConfig["database"] === "string" ? envConfig["database"] : ""; const opts: Record = { readPreference: "secondaryPreferred", maxPoolSize: typeof envConfig["pool_max"] === "number" ? envConfig["pool_max"] : 10, }; const client = new mongo.MongoClient(uri, opts); await client.connect(); this._client = client; return client; }); return this._clientPromise; } override connect(envConfig: Record): Promise { return this._ensureClient(envConfig).then((client) => { const db = client.db(this._database.length > 0 ? this._database : undefined); return { db, database: this._database } satisfies MongoHandle; }); } override executeRead( _conn: unknown, _query: string, _params: unknown[] | null = null, _maxRows: number = 1000, _timeoutMs: number = 30_000, ): ExecuteReadResult { return { status: "error", error_code: "SYNC_UNSUPPORTED", error: "MongoDriver.executeRead is async — call executeReadAsync() instead.", execution_time_ms: 0, }; } async executeReadAsync( conn: unknown, query: string, _params: unknown[] | null = null, maxRows: number = 1000, _timeoutMs: number = 30_000, ): Promise { const handle = (await (conn as Promise | MongoHandle)) as MongoHandle; const start = performance.now(); try { const env = _parseEnvelope(query); if (env === null) { return { status: "error", error_code: "SQL_ERROR", error: "MongoDriver: query must be a JSON envelope {collection, op, ...}", execution_time_ms: roundTo2(performance.now() - start), }; } if (!READ_ONLY_OPS.has(env.op)) { return { status: "error", error_code: "SQL_ERROR", error: `forbidden op: '${env.op}' — MongoDriver allows only [${[...READ_ONLY_OPS].join(", ")}]`, execution_time_ms: roundTo2(performance.now() - start), }; } const coll = handle.db.collection(env.collection); if (env.op === "count") { const total = await coll.countDocuments(env.filter ?? {}); return { status: "success", rows: [{ count: total }], row_count: 1, columns: ["count"], execution_time_ms: roundTo2(performance.now() - start), truncated: false, }; } let cursor: MongoCursor>; if (env.op === "find") { cursor = coll.find(env.filter ?? {}); if (env.projection) cursor = cursor.project(env.projection); if (env.sort) cursor = cursor.sort(env.sort); cursor = cursor.limit(maxRows + 1); } else { const pipeline = env.pipeline ?? []; cursor = coll.aggregate([ ...pipeline, { $limit: maxRows + 1 } as Record, ]); } const docs = await cursor.toArray(); let truncated = false; let rows = docs; if (rows.length > maxRows) { truncated = true; rows = rows.slice(0, maxRows); } const columns = _inferColumns(rows); return { status: "success", rows, row_count: rows.length, columns, execution_time_ms: roundTo2(performance.now() - start), truncated, }; } catch (e) { return { status: "error", error_code: "SQL_ERROR", error: (e as Error).message, execution_time_ms: roundTo2(performance.now() - start), }; } } override getSchema( _conn: unknown, _schemaName: string = "", _tableFilter: string | null = null, ): Table[] { return []; } async getSchemaAsync( conn: unknown, schemaName: string = "", tableFilter: string | null = null, ): Promise { const handle = (await (conn as Promise | MongoHandle)) as MongoHandle; try { const colls = await handle.db.listCollections().toArray(); const filtered = colls.filter((c) => { if (tableFilter === null || tableFilter === undefined) return true; const pattern = new RegExp( "^" + tableFilter.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/%/g, ".*") + "$", ); return pattern.test(c.name); }); // Sample all collections in parallel — each .toArray() is a round-trip, // so for an N-collection DB this trims latency from N×rtt to ~1×rtt. const samples = await Promise.all( filtered.map((c) => handle.db.collection(c.name).find({}, {}).limit(50).toArray(), ), ); const out: Table[] = []; for (let i = 0; i < filtered.length; i++) { const c = filtered[i]!; const sample = samples[i]!; const keyTypes = new Map(); for (const doc of sample) { for (const k of Object.keys(doc)) { if (!keyTypes.has(k) && doc[k] !== null && doc[k] !== undefined) { keyTypes.set(k, _mongoType(doc[k])); } else if (!keyTypes.has(k)) { keyTypes.set(k, "null"); } } } const columns: Column[] = [...keyTypes.entries()].map( ([name, type]) => new Column({ name, data_type: type, nullable: name !== "_id", is_primary_key: name === "_id", default: null, }), ); out.push( new Table({ name: c.name, schema: schemaName.length > 0 ? schemaName : handle.database, columns, }), ); } return out; } catch { return []; } } /** * No-op: the MongoClient is the pool and is shared across handles. * {@link shutdown} closes the client at process teardown. */ override close(_conn: unknown): void { /* pool is shared — nothing to release per-handle */ } async closeAsync(_conn: unknown): Promise { /* pool is shared — nothing to release per-handle */ } /** Per-driver health check via MongoDB's `ping` command. */ async healthCheck(conn: unknown): Promise { try { const handle = (await (conn as Promise | MongoHandle)) as MongoHandle; const res = await handle.db.command({ ping: 1 }); return Number(res["ok"]) === 1; } catch { return false; } } /** * G-DB-1: classify a Mongo query for the policy gate. * * Accepts either a JSON envelope `{collection, op, ...}` (the format * this driver's `executeReadAsync` consumes) or a free-form mongo * statement like `db.users.insertOne({...})`. The verb is extracted * and looked up in the read/dml/ddl tables. Anything not recognized * defaults to DDL — same default-deny behaviour as the SQL classifier. */ override classifyOperation(query: string): OperationType { const trimmed = query.trim(); if (trimmed.length === 0) { throw new Error("Empty MongoDB statement"); } let op: string | null = null; // Envelope form: {"collection": "...", "op": "..."} if (trimmed.startsWith("{")) { try { const parsed = JSON.parse(trimmed) as { op?: unknown }; if (typeof parsed.op === "string") op = parsed.op; } catch { /* fall through to dot-notation parse */ } } // Dot-notation form: db..(...) if (op === null) { const match = /(?:^|\.)([A-Za-z][A-Za-z0-9_]*)\s*\(/.exec(trimmed); if (match !== null) op = match[1] ?? null; } if (op === null) return OperationType.DDL; if (_MONGO_READ_OPS.has(op)) return OperationType.READ; if (_MONGO_DML_OPS.has(op)) return OperationType.DML; if (_MONGO_DDL_OPS.has(op)) return OperationType.DDL; return OperationType.DDL; } /** Close the shared MongoClient. */ async shutdown(): Promise { const client = this._client; this._client = null; this._clientPromise = null; if (client !== null) { try { await client.close(); } catch { /* best-effort */ } } } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function _buildUri(envConfig: Record): string { const host = typeof envConfig["host"] === "string" ? envConfig["host"] : "localhost"; const port = typeof envConfig["port"] === "number" ? envConfig["port"] : 27017; const user = typeof envConfig["user"] === "string" ? envConfig["user"] : ""; const password = typeof envConfig["password"] === "string" ? envConfig["password"] : ""; const authPart = user.length > 0 && password.length > 0 ? `${encodeURIComponent(user)}:${encodeURIComponent(password)}@` : ""; return `mongodb://${authPart}${host}:${port}`; } function _parseEnvelope(query: string): MongoQueryEnvelope | null { try { const parsed = JSON.parse(query) as unknown; if ( parsed === null || typeof parsed !== "object" || typeof (parsed as MongoQueryEnvelope).collection !== "string" || typeof (parsed as MongoQueryEnvelope).op !== "string" ) { return null; } return parsed as MongoQueryEnvelope; } catch { return null; } } function _mongoType(v: unknown): string { if (v === null) return "null"; if (Array.isArray(v)) return "array"; const t = typeof v; if (t === "object") { const ctor = (v as { constructor?: { name?: string } })?.constructor?.name; if (ctor === "ObjectId") return "objectId"; if (ctor === "Date") return "date"; return "object"; } return t; } function _inferColumns(rows: Record[]): string[] { const seen = new Set(); for (const r of rows) for (const k of Object.keys(r)) seen.add(k); return [...seen]; } function roundTo2(n: number): number { return Math.round(n * 100) / 100; }