-
Notifications
You must be signed in to change notification settings - Fork 0
feat(db): insta db query <service> — managed-DB (mysql/redis/mongo) queries via console exec #154
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)) | ||
| 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)) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| }) | ||
| }) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.