// mssql's admin client — mssql's own code, living in the module (novox/hq ADR 0039). Both this // module's tools and its provisioner import it, and nothing outside mssql does. // // SQL is executed through `sqlcmd`, not a wire-protocol driver: the module may take NO npm // dependency beyond @novox/mesh-sdk, and hand-rolling the TDS handshake, pre-login and query // protocol is more surface than this should carry — so it shells out to the client the mssql // tools ship, the same way postgres drives itself through `psql`, minio through `mc`, and mailu // through doveadm. One boundary, `run()`, and every method is built on it. // // Structured rows come back as JSON: SQL Server itself renders the result with `FOR JSON`, and // this parses the single JSON document sqlcmd prints — far more robust than parsing sqlcmd's // column-aligned text, since SQL Server owns the quoting and typing. import { randomBytes } from "node:crypto"; import { readFileSync } from "node:fs"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; const run = promisify(execFile); export interface QueryResult { /** The leading keyword of the statement, e.g. "SELECT", "CREATE". */ readonly command: string; readonly rows: Record[]; } export interface MssqlConn { readonly host: string; readonly port: number; readonly user: string; readonly password: string; } export class MssqlClient { constructor(private readonly conn: MssqlConn) {} /** * Build from the module's resolved environment. Reads MESH_MSSQL_* first (the documented * names), falling back to the MESH_PROVISION_* keys the manifest already sets on the provisioner * container. Throws if it cannot find a host and an admin password. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): MssqlClient { const url = env.MESH_PROVISION_MSSQL ? safeUrl(env.MESH_PROVISION_MSSQL) : undefined; const host = env.MESH_MSSQL_HOST ?? url?.hostname; const port = Number(env.MESH_MSSQL_PORT ?? url?.port ?? "1433") || 1433; const user = env.MESH_MSSQL_USER ?? url?.username ?? "sa"; const password = env.MESH_MSSQL_PASSWORD ?? readSecretFile(env.MESH_PROVISION_PASSWORD_FILE); if (!host || !password) { throw new Error("mssql host or admin password is not set — mssql's own code cannot reach the server"); } return new MssqlClient({ host, port, user, password }); } get host(): string { return this.conn.host; } get port(): number { return this.conn.port; } /** * Execute a batch that returns no rows (DDL and the like), through `sqlcmd`. The password is * passed by SQLCMDPASSWORD, never on argv, the way postgres passes PGPASSWORD; `-b` makes a * failed statement an error here rather than a success with a warning, and `-C` trusts the * server's self-signed certificate the mssql image ships with. */ async exec(sql: string, database = "master"): Promise { await this.sqlcmd(sql, database); } /** * Run a SELECT and return its rows as objects. The caller's SQL must be a single SELECT; it is * wrapped so SQL Server renders the result with `FOR JSON PATH`, and the JSON document sqlcmd * prints (split across output lines for a large result, and reassembled here) is parsed. An * empty result yields no output at all — an empty array. */ async query(select: string, database = "master"): Promise[]> { const wrapped = `SET NOCOUNT ON;\n${stripTrailingSemis(select)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`; const stdout = await this.sqlcmd(wrapped, database); return parseJsonRows(stdout); } /** The one execution boundary: invoke `sqlcmd` and return its concatenated stdout. */ private async sqlcmd(sql: string, database: string): Promise { // `-h -1` drops the column-header rule; `-y 0`/`-Y 0` lift the display-width cap so a long // JSON document is not truncated; `-W` trims trailing whitespace so the JSON chunks rejoin // cleanly. sqlcmd from the mssql-tools ships in the runtime container, the way `psql` ships // with postgres's — the module owns its own code (ADR 0039) and shells out to it. const { stdout } = await run( "sqlcmd", [ "-S", `${this.conn.host},${this.conn.port}`, "-U", this.conn.user, "-d", database, "-C", "-b", "-h", "-1", "-y", "0", "-Y", "0", "-W", "-Q", sql, ], { env: { ...process.env, SQLCMDPASSWORD: this.conn.password }, maxBuffer: 16 << 20 }, ); return stdout; } /** * Create a login and a database it owns (mapped as a db_owner user), idempotently. The login, * the database and the user all carry the consumer's minted name, so the consumer owns exactly * its own database — a name it cannot learn is a database it cannot reach (ADR 0048). */ async createDatabaseAndLogin(database: string, login: string, password: string): Promise { const logins = await this.query( `SELECT 1 AS ok FROM sys.server_principals WHERE name = ${literal(login)}`, ); if (logins.length === 0) { await this.exec( `CREATE LOGIN ${ident(login)} WITH PASSWORD = ${literal(password)}, CHECK_POLICY = OFF`, ); } else { await this.exec(`ALTER LOGIN ${ident(login)} WITH PASSWORD = ${literal(password)}`); } const dbs = await this.query( `SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`, ); if (dbs.length === 0) { // CREATE DATABASE must stand alone in its batch; it runs as its own sqlcmd invocation. await this.exec(`CREATE DATABASE ${ident(database)}`); } // Map the login to a db_owner user inside the database it owns. const users = await this.query( `SELECT 1 AS ok FROM sys.database_principals WHERE name = ${literal(login)}`, database, ); if (users.length === 0) { await this.exec(`CREATE USER ${ident(login)} FOR LOGIN ${ident(login)}`, database); } await this.exec(`ALTER ROLE db_owner ADD MEMBER ${ident(login)}`, database); } /** Drop a database and its login, idempotently, after evicting live connections. */ async dropDatabaseAndLogin(database: string, login: string): Promise { const dbs = await this.query( `SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`, ); if (dbs.length > 0) { // SINGLE_USER WITH ROLLBACK IMMEDIATE evicts every other session before the drop. await this.exec(`ALTER DATABASE ${ident(database)} SET SINGLE_USER WITH ROLLBACK IMMEDIATE`); await this.exec(`DROP DATABASE ${ident(database)}`); } const logins = await this.query( `SELECT 1 AS ok FROM sys.server_principals WHERE name = ${literal(login)}`, ); if (logins.length > 0) { await this.exec(`DROP LOGIN ${ident(login)}`); } } /** List the user databases (database_id > 4 excludes the system four), with size, for the tool. */ async listDatabases(): Promise<{ name: string; sizeBytes: number; state: string }[]> { const rows = await this.query( "SELECT d.name AS name, d.state_desc AS state, " + "SUM(CAST(f.size AS bigint)) * 8 * 1024 AS size_bytes " + "FROM sys.databases d JOIN sys.master_files f ON d.database_id = f.database_id " + "WHERE d.database_id > 4 GROUP BY d.name, d.state_desc ORDER BY d.name", ); return rows.map((r) => ({ name: String(r.name), sizeBytes: Number(r.size_bytes ?? 0), state: String(r.state ?? ""), })); } /** Run a read-only SELECT against a named database, for the mssql_query tool. */ async readOnlyQuery(database: string, sql: string): Promise { // The read-only guarantee is a wrapping transaction that is always rolled back: any write the // statement attempts is undone. The rows are rendered by FOR JSON inside query(). const rows = await this.query( `BEGIN TRANSACTION;\n${stripTrailingSemis(sql)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;\nROLLBACK;`, database, ); return { command: sql.trimStart().split(/\s+/)[0]?.toUpperCase() ?? "", rows }; } } /** Generate a URL-safe password. */ export function generatePassword(): string { return randomBytes(24).toString("base64url"); } /** Quote a T-SQL identifier (square brackets, doubled internal `]`). */ export function ident(id: string): string { return "[" + id.replace(/]/g, "]]") + "]"; } /** Quote a T-SQL string literal (single quotes, doubled internal quotes). */ export function literal(val: string): string { return "'" + val.replace(/'/g, "''") + "'"; } /** Strip trailing semicolons and whitespace so FOR JSON can be appended to a caller's SELECT. */ function stripTrailingSemis(sql: string): string { return sql.replace(/[\s;]+$/, ""); } function readSecretFile(path: string | undefined): string | undefined { if (!path) return undefined; try { return readFileSync(path, "utf8").trim(); } catch { return undefined; } } function safeUrl(raw: string): URL | undefined { try { return new URL(raw); } catch { return undefined; } } /** * Parse the JSON a FOR JSON query prints through sqlcmd. SQL Server splits a large FOR JSON result * into ~2033-character chunks, one per output row; with `-h -1 -W` each lands on its own line, so * the document is reassembled by concatenating the non-empty lines. No output (an empty result, or * a pure DDL batch) means no rows. */ function parseJsonRows(stdout: string): Record[] { const joined = stdout .split(/\r?\n/) .map((l) => l.trimEnd()) .filter((l) => l.length > 0) .join(""); if (joined.length === 0) return []; const parsed = JSON.parse(joined); return Array.isArray(parsed) ? (parsed as Record[]) : [parsed as Record]; }