Skip to content

Commit 16a399e

Browse files
authored
Merge pull request #154 from InsForge/feat/db-query-managed
feat(db): insta db query <service> — managed-DB (mysql/redis/mongo) queries via console exec
2 parents 72aebf1 + 8038dc0 commit 16a399e

3 files changed

Lines changed: 309 additions & 2 deletions

File tree

src/commands/db-query.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// `insta db query <service> [args...]` — run a query/command against a MANAGED database
2+
// (mysql/redis/mongodb) through the platform's console exec API. Postgres is not a console target
3+
// (it has the SQL editor / DATABASE_URL, and `insta db url|connect`), so a postgres service is
4+
// rejected here. The shape logic — path, request body, result rendering — lives in pure,
5+
// unit-tested seams; the handler just resolves the service and wires them to the API, this repo's
6+
// pure-seam convention.
7+
import { ApiClient, requireProject } from '../api.js'
8+
import { info, printJson, die, handleApproval } from '../util.js'
9+
import { q } from './services.js'
10+
11+
export const MANAGED_ENGINES = ['mysql', 'redis', 'mongodb'] as const
12+
export type Engine = (typeof MANAGED_ENGINES)[number]
13+
14+
// pure: the console exec route for a managed-DB service.
15+
export function consoleExecPath(projectId: string, serviceId: string): string {
16+
return `/projects/${projectId}/database/console/${serviceId}/exec`
17+
}
18+
19+
// pure: map the engine + trailing args to the exec request body. mysql/mongodb take a single
20+
// command string (args joined with a space — the user quotes the whole statement); redis takes a
21+
// pre-tokenized argv (each arg verbatim, so a value with spaces survives as one token). Only
22+
// mongodb carries an optional --database.
23+
export function execBody(engine: Engine, args: string[], database?: string): Record<string, unknown> {
24+
if (engine === 'redis') return { argv: args }
25+
const command = args.join(' ')
26+
if (engine === 'mongodb') return { command, ...(database ? { database } : {}) }
27+
return { command }
28+
}
29+
30+
// pure: render a mysql result set as a simple left-aligned table — the header from columns, then
31+
// the rows, every column but the last padded so cells line up. A null cell renders as an em-dash
32+
// (the repo norm for a missing value), never an empty string. A trailing count line closes it.
33+
export function renderMysqlRows(data: {
34+
columns?: Array<{ name: string }>
35+
rows?: Array<Array<string | null>>
36+
rowCount?: number
37+
truncated?: boolean
38+
}): string[] {
39+
const headers = (data.columns ?? []).map((c) => c.name)
40+
const rows = data.rows ?? []
41+
const cell = (v: string | null | undefined): string => (v === null || v === undefined ? '—' : String(v))
42+
const widths = headers.map((h, i) => {
43+
let w = h.length
44+
for (const r of rows) w = Math.max(w, cell(r[i]).length)
45+
return w
46+
})
47+
const fmtRow = (vals: string[]): string =>
48+
vals.map((v, i) => (i === vals.length - 1 ? v : v.padEnd(widths[i] ?? 0))).join(' ')
49+
const lines = [fmtRow(headers)]
50+
for (const r of rows) lines.push(fmtRow(headers.map((_, i) => cell(r[i]))))
51+
const rowCount = typeof data.rowCount === 'number' ? data.rowCount : rows.length
52+
lines.push(`(${rowCount} rows${data.truncated ? ', truncated' : ''})`)
53+
return lines
54+
}
55+
56+
// pure: a redis reply — a scalar prints raw, anything structured pretty-prints as JSON.
57+
export function renderRedisReply(reply: unknown): string {
58+
if (typeof reply === 'string' || typeof reply === 'number') return String(reply)
59+
return JSON.stringify(reply, null, 2)
60+
}
61+
62+
// pure: a mongodb result is arbitrary JSON — pretty-print it.
63+
export function renderMongoResult(result: unknown): string {
64+
return JSON.stringify(result, null, 2)
65+
}
66+
67+
type Opts = { database?: string; branch?: string; json?: boolean }
68+
69+
// The API surface + project this command needs, injectable so the handler flow — service
70+
// resolution, the engine guards, --json/202 passthrough — is testable without a network mock
71+
// (the DomainDeps convention in compute.ts). Production loads a real ApiClient + requireProject().
72+
export type DbQueryApi = Pick<ApiClient, 'request' | 'rawRequest'>
73+
export type DbQueryDeps = { api: DbQueryApi; project: { projectId: string; branch?: string } }
74+
async function dbQueryDeps(deps?: DbQueryDeps): Promise<DbQueryDeps> {
75+
if (deps) return deps
76+
const [api, project] = [await ApiClient.load(), await requireProject()]
77+
return { api, project }
78+
}
79+
80+
// Resolve <service> (a service NAME) to its id + engine, then dispatch to the console exec API.
81+
export async function dbQuery(service: string, args: string[], opts: Opts = {}, deps?: DbQueryDeps): Promise<void> {
82+
// An empty command is never valid — reject it before loading config or hitting the network,
83+
// rather than posting an empty statement/argv to the console.
84+
if (args.length === 0) {
85+
die('usage: insta db query <service> <query…> (mysql/mongodb: one quoted statement; redis: e.g. GET mykey)')
86+
}
87+
const { api, project: p } = await dbQueryDeps(deps)
88+
const branch = opts.branch ?? p.branch
89+
const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`)
90+
const svc = (services as Array<{ id: string; type: string; name: string }>).find((s) => s.name === service)
91+
if (!svc) die(`service not found: ${service}`)
92+
if (!(MANAGED_ENGINES as readonly string[]).includes(svc.type)) {
93+
die('db query is for managed databases (mysql/redis/mongodb); postgres uses the SQL editor / DATABASE_URL')
94+
}
95+
const engine = svc.type as Engine
96+
// --database is a mongodb-only selector (execBody drops it for the others). Rejecting it here,
97+
// rather than silently ignoring it, keeps the documented mongodb-only contract honest.
98+
if (opts.database !== undefined && engine !== 'mongodb') {
99+
die('--database is only supported for mongodb services')
100+
}
101+
const res = await api.rawRequest('POST', consoleExecPath(p.projectId, svc.id), execBody(engine, args, opts.database))
102+
if (handleApproval(res, opts.json)) return
103+
if (opts.json) return printJson(res.body)
104+
if (engine === 'mysql') {
105+
for (const line of renderMysqlRows(res.body ?? {})) info(line)
106+
} else if (engine === 'redis') {
107+
info(renderRedisReply(res.body?.reply))
108+
} else {
109+
info(renderMongoResult(res.body?.result))
110+
}
111+
}

src/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { deploy } from './commands/deploy.js'
2020
import { build } from './commands/build.js'
2121
import * as computeCmd from './commands/compute.js'
2222
import * as dbCmd from './commands/db.js'
23+
import * as dbQueryCmd from './commands/db-query.js'
2324
import * as storageCmd from './commands/storage.js'
2425
import { manifest } from './commands/manifest.js'
2526
import * as template from './commands/template.js'
@@ -244,8 +245,8 @@ compute.command('volume [service]').description("Show, attach, grow, or delete a
244245
.option('--delete', 'destroy the volume and ALL its data (irreversible; download anything you need first)')
245246
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeVolume(service, o)))
246247

247-
// ---- db (postgres service controls) ----
248-
const db = program.command('db').description('Postgres service controls (url / connect / limits / volume / always-on / scale-to-zero)')
248+
// ---- db (postgres service controls + managed-DB query) ----
249+
const db = program.command('db').description('Postgres service controls (url / connect / limits / volume / always-on / scale-to-zero) + managed-DB query (mysql/redis/mongodb)')
249250
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')
250251
.option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
251252
.action(guard((o) => dbCmd.dbUrl(o)))
@@ -266,6 +267,11 @@ db.command('volume').description("Show or grow a postgres service's provisioned
266267
.option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
267268
.option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
268269
.action(guard((o) => dbCmd.dbVolume(o)))
270+
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')
271+
.option('--database <db>', 'mongodb only — the database to run against (default admin)')
272+
.option('--branch <branch>', 'branch (default: current)')
273+
.option('--json')
274+
.action(guard((service, args, o) => dbQueryCmd.dbQuery(service, args, o)))
269275

270276
// ---- storage (bucket objects) ----
271277
const storage = program.command('storage').description("Browse, download, and delete a storage service's bucket objects")

test/db-query.test.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// `insta db query` seams — the pure renderers plus the handler flow through an injected api seam
2+
// (the DomainDeps convention), so nothing here reaches a backend (mirrors db-stats.test.ts /
3+
// compute-domain-flow.test.ts).
4+
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'
5+
6+
import {
7+
consoleExecPath, execBody, renderMysqlRows, renderRedisReply, renderMongoResult,
8+
dbQuery, type DbQueryDeps,
9+
} from '../src/commands/db-query.js'
10+
11+
describe('consoleExecPath', () => {
12+
it('is the managed-DB console exec route for the service', () => {
13+
expect(consoleExecPath('pr_1', 'svc_9')).toBe('/projects/pr_1/database/console/svc_9/exec')
14+
})
15+
})
16+
17+
describe('execBody', () => {
18+
// mysql/mongodb quote the whole statement, so the tokens rejoin into one command string.
19+
it('joins mysql args into a single command', () => {
20+
expect(execBody('mysql', ['select', '*', 'from', 'products', 'limit', '10']))
21+
.toEqual({ command: 'select * from products limit 10' })
22+
})
23+
24+
// redis is pre-tokenized: each arg stays a distinct argv element, verbatim — a value that
25+
// contains a space (already one shell token) must not be re-split.
26+
it('keeps redis args as a verbatim argv, never a joined string', () => {
27+
expect(execBody('redis', ['GET', 'mykey'])).toEqual({ argv: ['GET', 'mykey'] })
28+
expect(execBody('redis', ['SET', 'greeting', 'hello world']))
29+
.toEqual({ argv: ['SET', 'greeting', 'hello world'] })
30+
})
31+
32+
it('adds database for mongodb only when --database is given', () => {
33+
expect(execBody('mongodb', ['db.users.find().limit(10).toArray()'], 'shop'))
34+
.toEqual({ command: 'db.users.find().limit(10).toArray()', database: 'shop' })
35+
const bare = execBody('mongodb', ['db.users.find()'])
36+
expect(bare).toEqual({ command: 'db.users.find()' })
37+
expect('database' in bare).toBe(false)
38+
})
39+
})
40+
41+
describe('renderMysqlRows', () => {
42+
it('renders the header and rows as an aligned table with a count footer', () => {
43+
const lines = renderMysqlRows({
44+
columns: [{ name: 'id' }, { name: 'name' }, { name: 'price' }],
45+
rows: [
46+
['1', 'apple', '3'],
47+
['2', 'banana', null],
48+
],
49+
rowCount: 2,
50+
truncated: false,
51+
})
52+
expect(lines).toEqual([
53+
'id name price',
54+
'1 apple 3',
55+
'2 banana —',
56+
'(2 rows)',
57+
])
58+
})
59+
60+
// A null cell is a missing value, rendered as the repo's em-dash — never an empty string or 0.
61+
it('renders a null cell as an em-dash', () => {
62+
const lines = renderMysqlRows({ columns: [{ name: 'v' }], rows: [[null]], rowCount: 1 })
63+
expect(lines[1]).toBe('—')
64+
})
65+
66+
// The footer reports the server's rowCount (not rows.length) and flags a truncated page.
67+
it('uses the returned rowCount and marks truncation', () => {
68+
const lines = renderMysqlRows({
69+
columns: [{ name: 'x' }],
70+
rows: [['a'], ['b']],
71+
rowCount: 100,
72+
truncated: true,
73+
})
74+
expect(lines).toEqual(['x', 'a', 'b', '(100 rows, truncated)'])
75+
})
76+
})
77+
78+
describe('renderRedisReply', () => {
79+
it('prints a scalar reply raw', () => {
80+
expect(renderRedisReply('OK')).toBe('OK')
81+
expect(renderRedisReply(42)).toBe('42')
82+
expect(renderRedisReply(0)).toBe('0')
83+
})
84+
85+
it('pretty-prints a structured reply as JSON', () => {
86+
expect(renderRedisReply(['a', 'b'])).toBe(JSON.stringify(['a', 'b'], null, 2))
87+
expect(renderRedisReply({ field: 'v' })).toBe(JSON.stringify({ field: 'v' }, null, 2))
88+
expect(renderRedisReply(null)).toBe('null')
89+
})
90+
})
91+
92+
describe('renderMongoResult', () => {
93+
it('pretty-prints the result as JSON', () => {
94+
const result = [{ _id: 1, name: 'a' }]
95+
expect(renderMongoResult(result)).toBe(JSON.stringify(result, null, 2))
96+
expect(renderMongoResult({})).toBe('{}')
97+
})
98+
})
99+
100+
// ---- handler flow, through an injected api seam so nothing reaches a backend ----
101+
type Svc = { id: string; type: string; name: string }
102+
type Call = { method: string; path: string; body?: unknown }
103+
function deps(services: Svc[], res: { status: number; body: any } = { status: 200, body: {} }) {
104+
const calls: Call[] = []
105+
const api = {
106+
request: async (method: string, path: string) => {
107+
calls.push({ method, path })
108+
return { services }
109+
},
110+
rawRequest: async (method: string, path: string, body?: unknown) => {
111+
calls.push({ method, path, body })
112+
return res
113+
},
114+
}
115+
return { deps: { api, project: { projectId: 'p1', branch: 'main' } } as unknown as DbQueryDeps, calls }
116+
}
117+
118+
describe('dbQuery (handler flow, injected api — no network)', () => {
119+
const stdout: string[] = []
120+
const stderr: string[] = []
121+
const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation((c: any) => { stdout.push(String(c)); return true })
122+
const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation((c: any) => { stderr.push(String(c)); return true })
123+
afterEach(() => { stdout.length = 0; stderr.length = 0; process.exitCode = undefined })
124+
afterAll(() => { outSpy.mockRestore(); errSpy.mockRestore() })
125+
const out = () => stdout.join('')
126+
const err = () => stderr.join('')
127+
128+
const mysql = [{ id: 'svc_shop', type: 'mysql', name: 'shop' }, { id: 'svc_an', type: 'mysql', name: 'analytics' }]
129+
130+
it('resolves the service by NAME (not type/position) and posts to that id, with the branch on the lookup', async () => {
131+
const { deps: d, calls } = deps(mysql, { status: 200, body: { columns: [{ name: 'id' }], rows: [['1']], rowCount: 1 } })
132+
await dbQuery('analytics', ['select', '*', 'from', 'products'], {}, d)
133+
expect(calls[0]).toEqual({ method: 'GET', path: '/projects/p1/services?branch=main' })
134+
expect(calls[1]).toEqual({ method: 'POST', path: consoleExecPath('p1', 'svc_an'), body: { command: 'select * from products' } })
135+
expect(out()).toBe('id\n1\n(1 rows)\n')
136+
})
137+
138+
it('rejects a postgres/non-managed service BEFORE any exec is posted', async () => {
139+
const { deps: d, calls } = deps([{ id: 'svc_pg', type: 'postgres', name: 'db' }])
140+
await expect(dbQuery('db', ['select 1'], {}, d)).rejects.toThrow('exit 1')
141+
expect(process.exitCode).toBe(1)
142+
expect(err()).toMatch(/managed databases \(mysql\/redis\/mongodb\); postgres uses the SQL editor/)
143+
expect(calls.map((c) => c.method)).toEqual(['GET']) // never reached the POST
144+
})
145+
146+
it('errors when the named service is not on the branch', async () => {
147+
const { deps: d, calls } = deps(mysql)
148+
await expect(dbQuery('nope', ['select 1'], {}, d)).rejects.toThrow('exit 1')
149+
expect(err()).toMatch(/service not found: nope/)
150+
expect(calls.map((c) => c.method)).toEqual(['GET'])
151+
})
152+
153+
it('rejects --database on a non-mongodb engine BEFORE the POST, instead of silently dropping it', async () => {
154+
const { deps: d, calls } = deps(mysql)
155+
await expect(dbQuery('shop', ['select 1'], { database: 'other' }, d)).rejects.toThrow('exit 1')
156+
expect(process.exitCode).toBe(1)
157+
expect(err()).toMatch(/--database is only supported for mongodb services/)
158+
expect(calls.map((c) => c.method)).toEqual(['GET']) // resolved the engine, then refused
159+
})
160+
161+
it('passes --database through to the exec body for a mongodb service', async () => {
162+
const { deps: d, calls } = deps([{ id: 'svc_m', type: 'mongodb', name: 'docs' }], { status: 200, body: { result: [] } })
163+
await dbQuery('docs', ['db.users.find()'], { database: 'shop' }, d)
164+
expect(calls[1]).toEqual({ method: 'POST', path: consoleExecPath('p1', 'svc_m'), body: { command: 'db.users.find()', database: 'shop' } })
165+
})
166+
167+
it('rejects empty args BEFORE loading config or making any request', async () => {
168+
const { deps: d, calls } = deps(mysql)
169+
await expect(dbQuery('shop', [], {}, d)).rejects.toThrow('exit 1')
170+
expect(process.exitCode).toBe(1)
171+
expect(err()).toMatch(/usage: insta db query/)
172+
expect(calls).toEqual([]) // not even the service lookup ran
173+
})
174+
175+
it('relays a 202 approval gate: exit 2, hint on stderr, stdout untouched (non-json)', async () => {
176+
const body = { status: 'approval_required', action: 'db.query', approvalId: 'appr_1' }
177+
const { deps: d } = deps(mysql, { status: 202, body })
178+
await dbQuery('shop', ['select 1'], {}, d) // handleApproval returns, no throw
179+
expect(process.exitCode).toBe(2)
180+
expect(err()).toMatch(/approval required for db\.query run: insta approvals approve appr_1/)
181+
expect(out()).toBe('')
182+
})
183+
184+
it('--json prints the platform body verbatim and skips the human table', async () => {
185+
const body = { columns: [{ name: 'id' }], rows: [['1']], rowCount: 1 }
186+
const { deps: d } = deps(mysql, { status: 200, body })
187+
await dbQuery('shop', ['select 1'], { json: true }, d)
188+
expect(JSON.parse(out())).toEqual(body)
189+
})
190+
})

0 commit comments

Comments
 (0)