From 250e02b4a0df16d947fdd99d673952d8d2569cde Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 27 Aug 2026 13:59:17 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(db):=20insta=20db=20query=20?= =?UTF-8?q?=20=E2=80=94=20run=20managed-DB=20(mysql/redis/mongo)=20queries?= =?UTF-8?q?=20via=20the=20console=20exec=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/db-query.ts | 91 ++++++++++++++++++++++++++++++++++++++ src/index.ts | 10 ++++- test/db-query.test.ts | 95 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 src/commands/db-query.ts create mode 100644 test/db-query.test.ts diff --git a/src/commands/db-query.ts b/src/commands/db-query.ts new file mode 100644 index 0000000..03ec990 --- /dev/null +++ b/src/commands/db-query.ts @@ -0,0 +1,91 @@ +// `insta db query [args...]` — run a query/command against a MANAGED database +// (mysql/redis/mongodb) through the platform's console exec API. Postgres is not a console target +// (it has the SQL editor / DATABASE_URL, and `insta db url|connect`), so a postgres service is +// rejected here. The shape logic — path, request body, result rendering — lives in pure, +// unit-tested seams; the handler just resolves the service and wires them to the API, this repo's +// pure-seam convention. +import { ApiClient, requireProject } from '../api.js' +import { info, printJson, die, handleApproval } from '../util.js' +import { q } from './services.js' + +export const MANAGED_ENGINES = ['mysql', 'redis', 'mongodb'] as const +export type Engine = (typeof MANAGED_ENGINES)[number] + +// pure: the console exec route for a managed-DB service. +export function consoleExecPath(projectId: string, serviceId: string): string { + return `/projects/${projectId}/database/console/${serviceId}/exec` +} + +// pure: map the engine + trailing args to the exec request body. mysql/mongodb take a single +// command string (args joined with a space — the user quotes the whole statement); redis takes a +// pre-tokenized argv (each arg verbatim, so a value with spaces survives as one token). Only +// mongodb carries an optional --database. +export function execBody(engine: Engine, args: string[], database?: string): Record { + if (engine === 'redis') return { argv: args } + const command = args.join(' ') + if (engine === 'mongodb') return { command, ...(database ? { database } : {}) } + return { command } +} + +// pure: render a mysql result set as a simple left-aligned table — the header from columns, then +// the rows, every column but the last padded so cells line up. A null cell renders as an em-dash +// (the repo norm for a missing value), never an empty string. A trailing count line closes it. +export function renderMysqlRows(data: { + columns?: Array<{ name: string }> + rows?: Array> + rowCount?: number + truncated?: boolean +}): string[] { + const headers = (data.columns ?? []).map((c) => c.name) + const rows = data.rows ?? [] + const cell = (v: string | null | undefined): string => (v === null || v === undefined ? '—' : String(v)) + const widths = headers.map((h, i) => { + let w = h.length + for (const r of rows) w = Math.max(w, cell(r[i]).length) + return w + }) + const fmtRow = (vals: string[]): string => + vals.map((v, i) => (i === vals.length - 1 ? v : v.padEnd(widths[i] ?? 0))).join(' ') + const lines = [fmtRow(headers)] + for (const r of rows) lines.push(fmtRow(headers.map((_, i) => cell(r[i])))) + const rowCount = typeof data.rowCount === 'number' ? data.rowCount : rows.length + lines.push(`(${rowCount} rows${data.truncated ? ', truncated' : ''})`) + return lines +} + +// pure: a redis reply — a scalar prints raw, anything structured pretty-prints as JSON. +export function renderRedisReply(reply: unknown): string { + if (typeof reply === 'string' || typeof reply === 'number') return String(reply) + return JSON.stringify(reply, null, 2) +} + +// pure: a mongodb result is arbitrary JSON — pretty-print it. +export function renderMongoResult(result: unknown): string { + return JSON.stringify(result, null, 2) +} + +type Opts = { database?: string; branch?: string; json?: boolean } + +// Resolve (a service NAME) to its id + engine, then dispatch to the console exec API. +export async function dbQuery(service: string, args: string[], opts: Opts = {}): Promise { + const api = await ApiClient.load() + const p = await requireProject() + const branch = opts.branch ?? p.branch + const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`) + const svc = (services as Array<{ id: string; type: string; name: string }>).find((s) => s.name === service) + if (!svc) die(`service not found: ${service}`) + if (!(MANAGED_ENGINES as readonly string[]).includes(svc.type)) { + die('db query is for managed databases (mysql/redis/mongodb); postgres uses the SQL editor / DATABASE_URL') + } + const engine = svc.type as Engine + const res = await api.rawRequest('POST', consoleExecPath(p.projectId, svc.id), execBody(engine, args, opts.database)) + if (handleApproval(res, opts.json)) return + if (opts.json) return printJson(res.body) + if (engine === 'mysql') { + for (const line of renderMysqlRows(res.body ?? {})) info(line) + } else if (engine === 'redis') { + info(renderRedisReply(res.body?.reply)) + } else { + info(renderMongoResult(res.body?.result)) + } +} diff --git a/src/index.ts b/src/index.ts index d0436c1..7893057 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ import { deploy } from './commands/deploy.js' import { build } from './commands/build.js' import * as computeCmd from './commands/compute.js' import * as dbCmd from './commands/db.js' +import * as dbQueryCmd from './commands/db-query.js' import * as storageCmd from './commands/storage.js' import { manifest } from './commands/manifest.js' import * as template from './commands/template.js' @@ -244,8 +245,8 @@ compute.command('volume [service]').description("Show, attach, grow, or delete a .option('--delete', 'destroy the volume and ALL its data (irreversible; download anything you need first)') .option('--json').option('--branch ', 'branch (default: current)').action(guard((service, o) => computeCmd.computeVolume(service, o))) -// ---- db (postgres service controls) ---- -const db = program.command('db').description('Postgres service controls (url / connect / limits / volume / always-on / scale-to-zero)') +// ---- db (postgres service controls + managed-DB query) ---- +const db = program.command('db').description('Postgres service controls (url / connect / limits / volume / always-on / scale-to-zero) + managed-DB query (mysql/redis/mongodb)') db.command('url').description('Print the postgres connection string (DSN) — bare on stdout for piping, e.g. `psql "$(insta db url)"` (gated: secrets.read). Provider credentials are not in `insta secrets` — this is the command that yields the DSN') .option('--json').option('--branch ', 'branch (default: current)').option('--group ', 'postgres service name (default: the sole/default one)') .action(guard((o) => dbCmd.dbUrl(o))) @@ -266,6 +267,11 @@ db.command('volume').description("Show or grow a postgres service's provisioned .option('--size ', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)') .option('--json').option('--branch ', 'branch (default: current)').option('--group ', 'postgres service name (default: the sole/default one)') .action(guard((o) => dbCmd.dbVolume(o))) +db.command('query [args...]').description('Run a query/command against a managed database (mysql/redis/mongodb) via the console exec API. mysql/mongodb take one quoted statement; redis takes a pre-tokenized argv (e.g. `GET mykey`). Not for postgres — use `insta db url|connect` / the SQL editor') + .option('--database ', 'mongodb only — the database to run against (default admin)') + .option('--branch ', 'branch (default: current)') + .option('--json') + .action(guard((service, args, o) => dbQueryCmd.dbQuery(service, args, o))) // ---- storage (bucket objects) ---- const storage = program.command('storage').description("Browse, download, and delete a storage service's bucket objects") diff --git a/test/db-query.test.ts b/test/db-query.test.ts new file mode 100644 index 0000000..e6e4e0c --- /dev/null +++ b/test/db-query.test.ts @@ -0,0 +1,95 @@ +// `insta db query` seams — all pure, so nothing here reaches a backend (mirrors db-stats.test.ts). +import { describe, expect, it } from 'vitest' + +import { + consoleExecPath, execBody, renderMysqlRows, renderRedisReply, renderMongoResult, +} from '../src/commands/db-query.js' + +describe('consoleExecPath', () => { + it('is the managed-DB console exec route for the service', () => { + expect(consoleExecPath('pr_1', 'svc_9')).toBe('/projects/pr_1/database/console/svc_9/exec') + }) +}) + +describe('execBody', () => { + // mysql/mongodb quote the whole statement, so the tokens rejoin into one command string. + it('joins mysql args into a single command', () => { + expect(execBody('mysql', ['select', '*', 'from', 'products', 'limit', '10'])) + .toEqual({ command: 'select * from products limit 10' }) + }) + + // redis is pre-tokenized: each arg stays a distinct argv element, verbatim — a value that + // contains a space (already one shell token) must not be re-split. + it('keeps redis args as a verbatim argv, never a joined string', () => { + expect(execBody('redis', ['GET', 'mykey'])).toEqual({ argv: ['GET', 'mykey'] }) + expect(execBody('redis', ['SET', 'greeting', 'hello world'])) + .toEqual({ argv: ['SET', 'greeting', 'hello world'] }) + }) + + it('adds database for mongodb only when --database is given', () => { + expect(execBody('mongodb', ['db.users.find().limit(10).toArray()'], 'shop')) + .toEqual({ command: 'db.users.find().limit(10).toArray()', database: 'shop' }) + const bare = execBody('mongodb', ['db.users.find()']) + expect(bare).toEqual({ command: 'db.users.find()' }) + expect('database' in bare).toBe(false) + }) +}) + +describe('renderMysqlRows', () => { + it('renders the header and rows as an aligned table with a count footer', () => { + const lines = renderMysqlRows({ + columns: [{ name: 'id' }, { name: 'name' }, { name: 'price' }], + rows: [ + ['1', 'apple', '3'], + ['2', 'banana', null], + ], + rowCount: 2, + truncated: false, + }) + expect(lines).toEqual([ + 'id name price', + '1 apple 3', + '2 banana —', + '(2 rows)', + ]) + }) + + // A null cell is a missing value, rendered as the repo's em-dash — never an empty string or 0. + it('renders a null cell as an em-dash', () => { + const lines = renderMysqlRows({ columns: [{ name: 'v' }], rows: [[null]], rowCount: 1 }) + expect(lines[1]).toBe('—') + }) + + // The footer reports the server's rowCount (not rows.length) and flags a truncated page. + it('uses the returned rowCount and marks truncation', () => { + const lines = renderMysqlRows({ + columns: [{ name: 'x' }], + rows: [['a'], ['b']], + rowCount: 100, + truncated: true, + }) + expect(lines).toEqual(['x', 'a', 'b', '(100 rows, truncated)']) + }) +}) + +describe('renderRedisReply', () => { + it('prints a scalar reply raw', () => { + expect(renderRedisReply('OK')).toBe('OK') + expect(renderRedisReply(42)).toBe('42') + expect(renderRedisReply(0)).toBe('0') + }) + + it('pretty-prints a structured reply as JSON', () => { + expect(renderRedisReply(['a', 'b'])).toBe(JSON.stringify(['a', 'b'], null, 2)) + expect(renderRedisReply({ field: 'v' })).toBe(JSON.stringify({ field: 'v' }, null, 2)) + expect(renderRedisReply(null)).toBe('null') + }) +}) + +describe('renderMongoResult', () => { + it('pretty-prints the result as JSON', () => { + const result = [{ _id: 1, name: 'a' }] + expect(renderMongoResult(result)).toBe(JSON.stringify(result, null, 2)) + expect(renderMongoResult({})).toBe('{}') + }) +}) From 8038dc053a04a63eeeafb90d05ecdcce7c06708c Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 27 Aug 2026 15:08:18 -0700 Subject: [PATCH 2/2] fix(db-query): reject --database on non-mongo + empty args, add handler tests (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reject an empty args array before any config load or network request, instead of posting an empty command/argv to the console exec API. - After resolving the engine, reject --database for non-mongodb services (it was documented mongodb-only but silently dropped for mysql/redis), before the POST. - Add a minimal injected-api seam (DbQueryDeps, the DomainDeps convention) and handler-flow tests: service-name resolution, postgres/non-managed rejection, --database non-mongo rejection, mongodb --database passthrough, empty-args rejection, 202 approval relay, and --json output — all no-network. --- src/commands/db-query.ts | 26 +++++++++-- test/db-query.test.ts | 99 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 5 deletions(-) diff --git a/src/commands/db-query.ts b/src/commands/db-query.ts index 03ec990..7e80da3 100644 --- a/src/commands/db-query.ts +++ b/src/commands/db-query.ts @@ -66,10 +66,25 @@ export function renderMongoResult(result: unknown): string { type Opts = { database?: string; branch?: string; json?: boolean } +// The API surface + project this command needs, injectable so the handler flow — service +// resolution, the engine guards, --json/202 passthrough — is testable without a network mock +// (the DomainDeps convention in compute.ts). Production loads a real ApiClient + requireProject(). +export type DbQueryApi = Pick +export type DbQueryDeps = { api: DbQueryApi; project: { projectId: string; branch?: string } } +async function dbQueryDeps(deps?: DbQueryDeps): Promise { + if (deps) return deps + const [api, project] = [await ApiClient.load(), await requireProject()] + return { api, project } +} + // Resolve (a service NAME) to its id + engine, then dispatch to the console exec API. -export async function dbQuery(service: string, args: string[], opts: Opts = {}): Promise { - const api = await ApiClient.load() - const p = await requireProject() +export async function dbQuery(service: string, args: string[], opts: Opts = {}, deps?: DbQueryDeps): Promise { + // An empty command is never valid — reject it before loading config or hitting the network, + // rather than posting an empty statement/argv to the console. + if (args.length === 0) { + die('usage: insta db query (mysql/mongodb: one quoted statement; redis: e.g. GET mykey)') + } + const { api, project: p } = await dbQueryDeps(deps) const branch = opts.branch ?? p.branch const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`) const svc = (services as Array<{ id: string; type: string; name: string }>).find((s) => s.name === service) @@ -78,6 +93,11 @@ export async function dbQuery(service: string, args: string[], opts: Opts = {}): die('db query is for managed databases (mysql/redis/mongodb); postgres uses the SQL editor / DATABASE_URL') } const engine = svc.type as Engine + // --database is a mongodb-only selector (execBody drops it for the others). Rejecting it here, + // rather than silently ignoring it, keeps the documented mongodb-only contract honest. + if (opts.database !== undefined && engine !== 'mongodb') { + die('--database is only supported for mongodb services') + } const res = await api.rawRequest('POST', consoleExecPath(p.projectId, svc.id), execBody(engine, args, opts.database)) if (handleApproval(res, opts.json)) return if (opts.json) return printJson(res.body) diff --git a/test/db-query.test.ts b/test/db-query.test.ts index e6e4e0c..0440b75 100644 --- a/test/db-query.test.ts +++ b/test/db-query.test.ts @@ -1,8 +1,11 @@ -// `insta db query` seams — all pure, so nothing here reaches a backend (mirrors db-stats.test.ts). -import { describe, expect, it } from 'vitest' +// `insta db query` seams — the pure renderers plus the handler flow through an injected api seam +// (the DomainDeps convention), so nothing here reaches a backend (mirrors db-stats.test.ts / +// compute-domain-flow.test.ts). +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' import { consoleExecPath, execBody, renderMysqlRows, renderRedisReply, renderMongoResult, + dbQuery, type DbQueryDeps, } from '../src/commands/db-query.js' describe('consoleExecPath', () => { @@ -93,3 +96,95 @@ describe('renderMongoResult', () => { expect(renderMongoResult({})).toBe('{}') }) }) + +// ---- handler flow, through an injected api seam so nothing reaches a backend ---- +type Svc = { id: string; type: string; name: string } +type Call = { method: string; path: string; body?: unknown } +function deps(services: Svc[], res: { status: number; body: any } = { status: 200, body: {} }) { + const calls: Call[] = [] + const api = { + request: async (method: string, path: string) => { + calls.push({ method, path }) + return { services } + }, + rawRequest: async (method: string, path: string, body?: unknown) => { + calls.push({ method, path, body }) + return res + }, + } + return { deps: { api, project: { projectId: 'p1', branch: 'main' } } as unknown as DbQueryDeps, calls } +} + +describe('dbQuery (handler flow, injected api — no network)', () => { + const stdout: string[] = [] + const stderr: string[] = [] + const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation((c: any) => { stdout.push(String(c)); return true }) + const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation((c: any) => { stderr.push(String(c)); return true }) + afterEach(() => { stdout.length = 0; stderr.length = 0; process.exitCode = undefined }) + afterAll(() => { outSpy.mockRestore(); errSpy.mockRestore() }) + const out = () => stdout.join('') + const err = () => stderr.join('') + + const mysql = [{ id: 'svc_shop', type: 'mysql', name: 'shop' }, { id: 'svc_an', type: 'mysql', name: 'analytics' }] + + it('resolves the service by NAME (not type/position) and posts to that id, with the branch on the lookup', async () => { + const { deps: d, calls } = deps(mysql, { status: 200, body: { columns: [{ name: 'id' }], rows: [['1']], rowCount: 1 } }) + await dbQuery('analytics', ['select', '*', 'from', 'products'], {}, d) + expect(calls[0]).toEqual({ method: 'GET', path: '/projects/p1/services?branch=main' }) + expect(calls[1]).toEqual({ method: 'POST', path: consoleExecPath('p1', 'svc_an'), body: { command: 'select * from products' } }) + expect(out()).toBe('id\n1\n(1 rows)\n') + }) + + it('rejects a postgres/non-managed service BEFORE any exec is posted', async () => { + const { deps: d, calls } = deps([{ id: 'svc_pg', type: 'postgres', name: 'db' }]) + await expect(dbQuery('db', ['select 1'], {}, d)).rejects.toThrow('exit 1') + expect(process.exitCode).toBe(1) + expect(err()).toMatch(/managed databases \(mysql\/redis\/mongodb\); postgres uses the SQL editor/) + expect(calls.map((c) => c.method)).toEqual(['GET']) // never reached the POST + }) + + it('errors when the named service is not on the branch', async () => { + const { deps: d, calls } = deps(mysql) + await expect(dbQuery('nope', ['select 1'], {}, d)).rejects.toThrow('exit 1') + expect(err()).toMatch(/service not found: nope/) + expect(calls.map((c) => c.method)).toEqual(['GET']) + }) + + it('rejects --database on a non-mongodb engine BEFORE the POST, instead of silently dropping it', async () => { + const { deps: d, calls } = deps(mysql) + await expect(dbQuery('shop', ['select 1'], { database: 'other' }, d)).rejects.toThrow('exit 1') + expect(process.exitCode).toBe(1) + expect(err()).toMatch(/--database is only supported for mongodb services/) + expect(calls.map((c) => c.method)).toEqual(['GET']) // resolved the engine, then refused + }) + + it('passes --database through to the exec body for a mongodb service', async () => { + const { deps: d, calls } = deps([{ id: 'svc_m', type: 'mongodb', name: 'docs' }], { status: 200, body: { result: [] } }) + await dbQuery('docs', ['db.users.find()'], { database: 'shop' }, d) + expect(calls[1]).toEqual({ method: 'POST', path: consoleExecPath('p1', 'svc_m'), body: { command: 'db.users.find()', database: 'shop' } }) + }) + + it('rejects empty args BEFORE loading config or making any request', async () => { + const { deps: d, calls } = deps(mysql) + await expect(dbQuery('shop', [], {}, d)).rejects.toThrow('exit 1') + expect(process.exitCode).toBe(1) + expect(err()).toMatch(/usage: insta db query/) + expect(calls).toEqual([]) // not even the service lookup ran + }) + + it('relays a 202 approval gate: exit 2, hint on stderr, stdout untouched (non-json)', async () => { + const body = { status: 'approval_required', action: 'db.query', approvalId: 'appr_1' } + const { deps: d } = deps(mysql, { status: 202, body }) + await dbQuery('shop', ['select 1'], {}, d) // handleApproval returns, no throw + expect(process.exitCode).toBe(2) + expect(err()).toMatch(/approval required for db\.query — run: insta approvals approve appr_1/) + expect(out()).toBe('') + }) + + it('--json prints the platform body verbatim and skips the human table', async () => { + const body = { columns: [{ name: 'id' }], rows: [['1']], rowCount: 1 } + const { deps: d } = deps(mysql, { status: 200, body }) + await dbQuery('shop', ['select 1'], { json: true }, d) + expect(JSON.parse(out())).toEqual(body) + }) +})