Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions src/commands/db-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// `insta db query <service> [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<string, unknown> {
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<Array<string | null>>
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 }

// 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<ApiClient, 'request' | 'rawRequest'>
export type DbQueryDeps = { api: DbQueryApi; project: { projectId: string; branch?: string } }
async function dbQueryDeps(deps?: DbQueryDeps): Promise<DbQueryDeps> {
if (deps) return deps
const [api, project] = [await ApiClient.load(), await requireProject()]
return { api, project }
}

// Resolve <service> (a service NAME) to its id + engine, then dispatch to the console exec API.
export async function dbQuery(service: string, args: string[], opts: Opts = {}, deps?: DbQueryDeps): Promise<void> {
// 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 <service> <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)
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
// --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))
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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))
}
}
10 changes: 8 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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>', '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>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
.action(guard((o) => dbCmd.dbUrl(o)))
Expand All @@ -266,6 +267,11 @@ db.command('volume').description("Show or grow a postgres service's provisioned
.option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
.option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
.action(guard((o) => dbCmd.dbVolume(o)))
db.command('query <service> [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 <db>', 'mongodb only — the database to run against (default admin)')
.option('--branch <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")
Expand Down
190 changes: 190 additions & 0 deletions test/db-query.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// `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', () => {
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('{}')
})
})

// ---- 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)
})
})
Loading