/**
* schema.ts — Schema introspection with TTL cache.
*
* Mirrors `schema.py`:
* - `SchemaManager(driver, ttl=300)` caches driver.getSchema results,
* keyed by `(env, schemaName, tableFilter)`.
* - On driver error, returns `[]` (never raises).
* - `clearCache()` forces re-query on next call.
*
* Phase E bridge:
* - Phase E drivers (pg, mysql, mssql, mongo, dynamo) expose
* `getSchemaAsync(conn, schema?, tableFilter?)` and return a useless
* stub from the sync `getSchema`. We detect the async method and await
* it when present; otherwise fall back to the sync call (sqlite path).
*/
import { performance } from "node:perf_hooks";
import type { DatabaseDriver, Table } from "./drivers/base.js";
import { logEvent } from "./audit.js";
/** Optional async schema hook exposed by Phase E drivers. */
interface AsyncSchemaDriver {
getSchemaAsync?(
conn: unknown,
schemaName?: string,
tableFilter?: string | null,
): Promise
;
}
/** Cache entry record. */
interface CacheEntry {
ts: number; // performance.now() ms
data: Table[];
}
/** Cached schema introspection via a database driver. */
export class SchemaManager {
private readonly _driver: DatabaseDriver;
private readonly _ttl: number; // seconds
private readonly _cache: Map;
constructor(driver: DatabaseDriver, ttl: number = 300.0) {
this._driver = driver;
this._ttl = ttl;
this._cache = new Map();
}
/** Get schema, using cache if within TTL. */
async getSchema(
conn: unknown,
env: string,
schemaName: string = "",
tableFilter: string | null = null,
): Promise {
// Python uses a tuple cache key; emulate with a JSON-encoded key so
// (None, "", "") and ("", "", "") differ exactly as in Python.
const cacheKey = JSON.stringify([env, schemaName, tableFilter]);
const now = performance.now();
const entry = this._cache.get(cacheKey);
if (entry !== undefined) {
// `ts` stored in ms; `_ttl` is seconds → compare in seconds.
if ((now - entry.ts) / 1000 < this._ttl) {
return entry.data;
}
}
let tables: Table[];
try {
const asyncHook = (this._driver as AsyncSchemaDriver).getSchemaAsync;
if (typeof asyncHook === "function") {
tables = await asyncHook.call(
this._driver,
conn,
schemaName,
tableFilter,
);
} else {
tables = this._driver.getSchema(conn, schemaName, tableFilter);
}
} catch {
return [];
}
// A5 (G-DB-AUDIT extension): emit `schema_inspect` BEFORE returning the
// tables to the caller. The event documents which env+filter the
// introspection ran against and how much surface area it exposed
// (column_count is the sum across all tables — the most useful
// single number for capacity-style audits). We never throw from
// logEvent (the audit pipe is best-effort), so this is safe in every
// code path including the cache-hit fast-path above.
let columnCount = 0;
for (const t of tables) columnCount += t.columns.length;
logEvent({
event_type: "schema_inspect",
details: {
env,
table_filter: tableFilter,
column_count: columnCount,
},
});
this._cache.set(cacheKey, { ts: now, data: tables });
return tables;
}
/** Force re-query on next call. */
clearCache(): void {
this._cache.clear();
}
}