diff --git a/.claude/skills/dotaz/SKILL.md b/.claude/skills/dotaz/SKILL.md new file mode 100644 index 00000000..3e4f072d --- /dev/null +++ b/.claude/skills/dotaz/SKILL.md @@ -0,0 +1,85 @@ +--- +name: dotaz +description: Read databases and submit writes for approval through the running Dotaz app via the `dotaz` CLI. Use when you need to inspect a database the user has configured in Dotaz — list tables, read schema, sample rows, run SELECTs — to change data (submit the SQL with `dotaz propose` and the user approves it in the app), or to open a table or SQL console in their app window. Triggers include "look at the database", "co je v tabulce X", "run this query", "show me the schema", "uprav ten záznam", "fix this row", "open this in Dotaz". +--- + +# Dotaz CLI + +`dotaz` attaches to the user's running Dotaz desktop app and reuses its configured +connections. You never need credentials — the app already has them. + +## Before anything else + +```bash +dotaz status +``` + +Exit code 5 means the app is not running, or CLI access is off. Tell the user to launch +Dotaz and enable **Settings → Allow CLI access**. Do not try to connect to their database +another way. + +## Reading data + +Navigate with paths — `connection/database/schema/table`: + +```bash +dotaz ls # connections +dotaz ls prod # databases +dotaz ls prod/app/public # tables +dotaz describe prod/app/public/orders # columns, PK, indexes, FKs both directions +dotaz rows prod/app/public/orders --where "status='new'" --limit 20 +dotaz query prod "SELECT count(*) FROM orders WHERE created_at > $1" --param 2024-01-01 +``` + +Rules that matter: + +- **Always bound your reads.** `rows` and `query` cap output, but a query that scans a huge + table still costs the user time. Add `--limit`, and prefer `describe` over `SELECT *` when + you only need shape. +- **Parameterise.** Use `--param` instead of pasting values into SQL. +- `--json` when you need to parse the result; the default table output is for humans. +- Truncation is always reported, but where depends on the format: `table`/`md` on the last + line of stdout, `--json` in the `truncated`/`shown`/`total` fields, `csv`/`jsonl` on stderr. + `--quiet` never hides it. Do not conclude "the table has 20 rows" from a truncated result. + +## Writes need the user + +The CLI session is read-only at the database level. An INSERT/UPDATE/DELETE/DDL exits with +code 4. That is not a bug to route around — propose it instead: + +```bash +dotaz propose prod "UPDATE orders SET status='paid' WHERE id=42" --reason "user asked to mark order 42 paid" +``` + +This opens the SQL in the user's app with Run/Reject buttons. `dotaz approvals wait ` +blocks until they decide (exit 7 = still pending, 8 = rejected). Tell the user you are +waiting on their approval rather than silently polling. + +Never try to get around the read-only session — no `--param` injection, no DDL disguised as +a read, no asking the user for direct database credentials. + +## Driving the app + +```bash +dotaz ui state # what the user currently has open +dotaz ui open prod/app/public/orders # open a data grid tab +dotaz ui console prod --sql "SELECT …" # open a SQL console, prefilled +``` + +`ui state` before `ui open` is usually worth it — it tells you which connection and database +the user is actually working in, so you can act in their context instead of guessing. + +## Exit codes + +| Code | Meaning | +| --- | --- | +| 0 | success | +| 2 | usage error | +| 3 | database error | +| 4 | read-only violation — use `dotaz propose` | +| 5 | Dotaz not running or CLI access disabled | +| 6 | timeout | +| 7 | proposal still pending | +| 8 | proposal rejected | + +Full reference: `docs/agent-cli.md` in the dotaz repo. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 402bd724..b5e0cadf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,10 +46,15 @@ jobs: - name: Start docker-compose services run: docker compose up -d --wait + # readonly-session also runs in the `check` job, where its PostgreSQL and MySQL blocks + # skip for want of docker. It has to run here too, or engine-enforced read-only — the + # guarantee the agent CLI rests on — is never exercised in CI for anything but SQLite. - name: Test (integration — requires docker) + env: + DOTAZ_REQUIRE_DB: '1' run: | shopt -s extglob - bun test tests/@(pg-*|postgres-*|mysql-*|multi-database|driver-iterate).test.ts + bun test tests/@(pg-*|postgres-*|mysql-*|multi-database|driver-iterate|readonly-session).test.ts - name: Show docker-compose logs on failure if: failure() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0315df8f..fa76d5a4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -233,8 +233,8 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - # ── npm package (trusted publisher — no NPM_TOKEN needed) ─ - npm: + # ── Server npm package (trusted publisher) ─────────────── + npm-server: needs: prepare runs-on: ubuntu-latest permissions: @@ -264,9 +264,50 @@ jobs: run: npm publish --tag ${{ needs.prepare.outputs.npm-tag }} --access public --provenance working-directory: dist-server + # ── Agent CLI npm package (trusted publisher) ──────────── + npm-cli: + needs: prepare + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.TAG }} + + - uses: oven-sh/setup-bun@v2 + + - uses: actions/setup-node@v4 + with: + node-version: '24.x' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build agent CLI package + run: bun run build:agent-cli + env: + VERSION: ${{ needs.prepare.outputs.version }} + + - name: Smoke-test agent CLI package + working-directory: dist-agent-cli + run: | + test "$(./bin/dotaz.js --version)" = "dotaz ${{ needs.prepare.outputs.version }}" + ./bin/dotaz.js --help > /dev/null + PACKAGE_TARBALL=$(npm pack --silent) + TEST_DIR=$(mktemp -d) + npm install --prefix "$TEST_DIR" "$PWD/$PACKAGE_TARBALL" + test "$(cd "$TEST_DIR" && bunx --no-install @dotaz/cli --version)" = "dotaz ${{ needs.prepare.outputs.version }}" + + - name: Publish to npm + run: npm publish --tag ${{ needs.prepare.outputs.npm-tag }} --access public --provenance + working-directory: dist-agent-cli + # ── GitHub Release ──────────────────────────────────────── release: - needs: [prepare, desktop, docker, npm] + needs: [prepare, desktop, docker, npm-server, npm-cli] runs-on: ubuntu-latest permissions: contents: write diff --git a/.gitignore b/.gitignore index dd99d527..d45954e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules dist/ +dist-agent-cli/ dist-server/ dist-electron/ build/ diff --git a/CLAUDE.md b/CLAUDE.md index c20b83a6..521ec6a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,9 @@ bun run build:canary # Production build (web server) bun run build:server +# Production build (agent CLI npm package) +bun run build:agent-cli + # Type checking (must pass with zero errors) bunx tsc --noEmit @@ -81,6 +84,7 @@ src/ backend-desktop/ ← Electrobun backend entry point backend-web/ ← HTTP/WebSocket server entry point cli/ ← CLI entry point (bunx @dotaz/server) + cli-agent/ ← `dotaz` agent CLI — attaches to the running desktop app (docs/agent-cli.md) frontend-shared/ ← Solid.js UI: components, stores, lib (transport/storage registries) frontend-desktop/ ← Desktop entry: setTransport(electrobun) + setStorage(rpc) frontend-web/ ← Web entry: setTransport(websocket) + setStorage(indexeddb) @@ -100,6 +104,7 @@ frontend-demo ← frontend-shared + backend-shared (runtime — createHan backend-desktop ← backend-shared backend-web ← backend-shared cli ← backend-web (starts server with CLI argument parsing) +cli-agent ← shared (talks to a running app over HTTP — no backend imports) ``` ### Transport & storage — registration pattern @@ -131,6 +136,7 @@ This triggers the release workflow which: - Builds desktop apps for 5 platforms (Linux x64/ARM64, macOS x64/ARM64, Windows x64) - Publishes Docker image to `ghcr.io/contember/dotaz` - Publishes `@dotaz/server` npm package +- Publishes `@dotaz/cli` npm package - Creates GitHub Release with all artifacts Pre-release tags (containing `-beta`, `-alpha`, `-rc`) get `canary` electrobun env and `beta` npm tag. diff --git a/README.md b/README.md index ecf9008f..30257338 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ A lightweight Bun HTTP server you can self-host or run via Docker. Like [Adminer **Navigation** — Connection tree with databases, schemas, and tables. Schema viewer showing columns, indexes, and foreign keys. Command palette, query history, saved views, bookmarks, cross-table search. Dark theme throughout. +**Agent CLI** — A `dotaz` command-line client that attaches to the running app so an AI coding agent can work with your databases without ever holding your credentials. Reads run directly against a session the database itself enforces as read-only. Writes go through you: the agent submits the SQL, it opens in the app with Run/Reject, and only your click executes it. Off by default — see [docs/agent-cli.md](docs/agent-cli.md). + ## Install ### Desktop app @@ -75,6 +77,17 @@ Request isolation depends on how the server is bound: - **Non-loopback (e.g. `--host 0.0.0.0`)** — `DOTAZ_ENCRYPTION_KEY` is required so saved credentials remain decryptable across restarts. Dotaz still rejects cross-site browser requests, but it does not implement user authentication; put it behind your own auth/proxy if the URL is not trusted. - **Behind a reverse proxy** — serve the UI and `/rpc`/`/api` under the same origin. Keep the loopback bind (default); if the proxy preserves a public `Host` header, allow that host with `DOTAZ_ALLOWED_HOSTS`. +### Agent CLI + +With the desktop app running and CLI access enabled in Settings: + +```sh +bunx @dotaz/cli status +bunx @dotaz/cli rows local/users --limit 20 +``` + +The CLI reads through backend-owned read-only sessions. Writes are submitted to the desktop app for explicit approval. + ### Docker ```sh diff --git a/bun.lock b/bun.lock index 7c7fb8aa..d34dc16a 100644 --- a/bun.lock +++ b/bun.lock @@ -64,12 +64,22 @@ }, }, "src/cli": { - "name": "@dotaz/cli", + "name": "@dotaz/server-cli", "version": "0.0.0", "dependencies": { "@dotaz/backend-web": "workspace:*", }, }, + "src/cli-agent": { + "name": "@dotaz/cli", + "version": "0.0.0", + "bin": { + "dotaz": "./main.ts", + }, + "dependencies": { + "@dotaz/shared": "workspace:*", + }, + }, "src/frontend-demo": { "name": "@dotaz/frontend-demo", "version": "0.0.0", @@ -188,7 +198,7 @@ "@dotaz/backend-web": ["@dotaz/backend-web@workspace:src/backend-web"], - "@dotaz/cli": ["@dotaz/cli@workspace:src/cli"], + "@dotaz/cli": ["@dotaz/cli@workspace:src/cli-agent"], "@dotaz/frontend-demo": ["@dotaz/frontend-demo@workspace:src/frontend-demo"], @@ -198,6 +208,8 @@ "@dotaz/frontend-web": ["@dotaz/frontend-web@workspace:src/frontend-web"], + "@dotaz/server-cli": ["@dotaz/server-cli@workspace:src/cli"], + "@dotaz/shared": ["@dotaz/shared@workspace:src/shared"], "@dprint/darwin-arm64": ["@dprint/darwin-arm64@0.54.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-yqRI4enH+BDp+4+ZsPVdZM5h873JK1lN7li9l9A5u4C4cvh1oEsiBWAzEPccRkJ2ctF8LgaizBSxO38sqEVYbw=="], diff --git a/docs/agent-cli.md b/docs/agent-cli.md new file mode 100644 index 00000000..d6ed9869 --- /dev/null +++ b/docs/agent-cli.md @@ -0,0 +1,306 @@ +# Agent CLI + +`dotaz` — a command-line client that lets an AI agent (or a human) browse databases, run +read-only queries, submit writes for the user to approve, and drive the running desktop app. + +This document is the implementation contract. Everything below is normative. + +## Scope (v1) + +- **Transport:** attach to a running desktop app only. No headless mode, no remote mode. +- **Data plane:** schema navigation + read-only queries, enforced by the database engine. +- **Control plane:** open tabs, prefill the SQL console, read what the user is looking at. +- **Writes:** never executed by the CLI. The CLI submits a _proposal_; the user approves it + in the app and the app executes it. + +## Invariants + +1. Every CLI database operation runs in a backend-owned read-only session that is **never** + switched to read-write. An approved write executes in the frontend's own session, not in + an agent session. + No control-plane method may be used to get around this: `ui.openConsole` will prefill a + write, but refuses to auto-run one (`run: true` is rejected for non-read-only SQL), and + `ui.openTable`'s `where` must be a single boolean expression. +2. Read-only is enforced by the database engine, not by parsing SQL. Statement + classification exists only to fail fast with a good message. +3. The control endpoint does not exist unless the user enabled it (`cli.enabled` setting or + `DOTAZ_CLI=1`). No setting, no socket, no endpoint file. + +The endpoint enforces invariant 1 itself. It exposes only the operation-level `agent.query`, +`agent.schema`, and `agent.search` methods. Their backend handlers create, verify, and destroy +the read-only session internally. Generic `session.*`, `query.execute`, `schema.load`, and +`search.searchDatabase` methods are not exposed, and session IDs never cross the CLI boundary. + +## Transport + +### Control server + +The Electrobun backend process serves plain HTTP over a unix domain socket (macOS, Linux) or +loopback TCP (Windows). Not WebSocket — every CLI invocation is one-shot, and Bun's `fetch` +speaks unix sockets natively via `fetch(url, { unix })`. + +| Route | Method | Purpose | +| --------- | ------ | ---------------------------------------------------- | +| `/health` | GET | `{ ok, version, pid, protocol }` — no token required | +| `/rpc` | POST | `{ method, params }` → dispatch, token required | + +Auth: `x-dotaz-token` header, compared with `timingSafeEqual`. Required on `/rpc` even over a +unix socket. + +The endpoint exposes an **allowlist**, not the app's full handler map +(`backend-shared/rpc/cli-surface.ts`). Connection mutation, import/export, settings writes +and anything that returns decrypted secrets stay unreachable, and `connections.list` +responses are stripped of passwords on the way out. `ui.snapshot.set` and +`agent.proposals.resolve` are frontend-only and equally unreachable. A forbidden method is +indistinguishable from a nonexistent one. + +Also absent, and worth naming because each was reachable at one point: all `session.*` +methods, the generic data methods `query.execute`, `schema.load`, and +`search.searchDatabase`, and `ui.runCommand` (it could execute any registered app command, +including `run-query` in the frontend's writable session). + +Socket path: `${XDG_RUNTIME_DIR ?? tmpdir()}/dotaz-${pid}.sock`. A stale socket at that path +is unlinked on startup. + +### Endpoint discovery + +One file per running instance, so two open windows never overwrite or delete each other's +endpoint: `${userData}/cli/endpoint-.json`, directory mode `0700`, files `0600`. + +```jsonc +{ + "pid": 12345, + "transport": "unix", // or "tcp" + "socket": "/run/user/1000/dotaz-12345.sock", + "port": null, // set when transport is "tcp" + "token": "…64 hex chars…", + "version": "0.0.42", + "protocol": 1, + "startedAt": 1717430000000 +} +``` + +The CLI reads every file in that directory, drops the ones whose pid is no longer alive +(`process.kill(pid, 0)`), and connects to the newest surviving `startedAt`. `--instance ` +picks a specific one — an unknown or dead pid is a usage error listing the live instances. +`--endpoint ` and `DOTAZ_ENDPOINT` still override with one explicit file. No live +instance, or a refused connection ⇒ exit code 5. + +An instance prunes files belonging to dead pids at startup, and on a graceful shutdown +(closing the window, or switching the setting off) removes only its own file and socket. + +A killed instance leaves both behind: Electrobun installs its own signal handlers and exits +through a native quit, so neither our signal handlers nor `process.on('exit')` get a turn. +Nothing depends on that cleanup — the next instance prunes dead pids at startup, and the CLI +skips any endpoint whose pid is gone. Verified: after `kill -TERM`, `dotaz status` reports +`pid is not running` and exits 5. + +The transport follows the platform, but `DOTAZ_CLI_TRANSPORT=tcp|unix` overrides it — that +is how the Windows path gets tested on Linux. + +### Wire format + +The request/response envelope matches the existing web-mode WebSocket protocol so both +transports share one dispatcher (`backend-shared/rpc/dispatch.ts`): + +```jsonc +// request +{ "method": "connections.list", "params": {} } +// response +{ "type": "response", "id": 0, "success": true, "payload": [ … ] } +{ "type": "response", "id": 0, "success": false, "error": "…", "errorCode": "…" } +``` + +## RPC surface + +Existing handlers are reused wherever possible. New methods: + +| Method | Params | Returns | +| ------------------------- | ----------------------------------------------------------------- | ---------------------------------- | +| `agent.hello` | — | `{ version, mode, pid, protocol }` | +| `agent.schema` | `{ connectionId, database? }` | `SchemaData` | +| `agent.query` | `{ connectionId, database?, sql, queryId, params?, searchPath? }` | `QueryResult[]` | +| `agent.search` | `{ connectionId, database?, searchTerm, scope, … }` | `SearchDatabaseResult` | +| `agent.proposeWrite` | `{ connectionId, database?, sql, reason? }` | `{ proposalId }` | +| `agent.proposals.list` | `{ status?, connectionId? }` | `Proposal[]` | +| `agent.proposals.get` | `{ proposalId }` | `Proposal` | +| `agent.proposals.wait` | `{ proposalId, timeoutMs? }` | `Proposal` (long-poll) | +| `agent.proposals.cancel` | `{ proposalId }` | `void` | +| `agent.proposals.resolve` | `{ proposalId, status, result?, error? }` | `Proposal` — **frontend only** | +| `ui.state` | — | `UiSnapshot` | +| `ui.openTable` | `{ connectionId, database?, schema, table, where?, limit? }` | `{ ok: true }` | +| `ui.openConsole` | `{ connectionId, database?, sql?, run? }` | `{ ok: true }` | +| `ui.snapshot.set` | `{ snapshot }` | `void` — **frontend only** | + +### Backend → frontend messages + +| Channel | Payload | +| -------------- | ------------------------------------------------------------- | +| `cli.proposal` | `Proposal` — emitted on every state change, not only creation | +| `cli.command` | `{ kind: 'open-table' \| 'open-console', … }` | + +## Read-only sessions + +Each `agent.query`, `agent.schema`, and `agent.search` call creates a session with +`readOnly: true`. The handler checks that the driver confirmed it as read-only, runs exactly +one operation, and destroys the session in `finally`. The CLI neither creates sessions nor +receives their IDs. If the CLI disconnects mid-request, the backend handler keeps ownership +and releases the session when the operation ends. + +Creating the session reaches `driver.reserveSession(sessionId, { readOnly: true })`: + +| Driver | Enforcement | +| ---------- | ---------------------------------------------------------------------------------------------------- | +| PostgreSQL | `SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY` on the session's dedicated connection | +| MySQL | `SET SESSION TRANSACTION READ ONLY` on the session's dedicated connection | +| SQLite | dedicated handle opened `readonly`, plus `PRAGMA query_only = ON`; the session's queries route to it | + +Neither mechanism protects itself, so both are re-established rather than set once: + +- PostgreSQL's `default_transaction_read_only` is an ordinary GUC, and + `SELECT set_config('default_transaction_read_only','off',false)` classifies as a _read_. The + driver therefore re-asserts the session characteristics (and the timeout) before every + statement. Verified against PostgreSQL 17: re-asserting blocks the write, and a single + statement cannot both clear the GUC and write under it. +- SQLite's `PRAGMA query_only` can be revoked with `PRAGMA query_only(0)` — the function form + carries no `=`, so it too classifies as a read. The handle is opened read-only at the VFS + level, which no statement can revoke; the pragma is a second layer. +- An unknown `sessionId` throws on every driver. SQLite used to fall through to the shared + writable handle, which silently downgraded a session whose handle had been lost. + +`SessionInfo.readOnly` is read back from `driver.isSessionReadOnly()`, not echoed from the +request. A session the driver does not confirm as read-only is released and refused before +the operation starts. + +Read-only is not the same as cheap, so the same sessions also carry an engine-enforced +statement timeout, driven by the existing `queryTimeout` setting (30 s; `0` disables it): +`statement_timeout` on PostgreSQL, `MAX_EXECUTION_TIME` on MySQL — falling back to +`max_statement_time` on MariaDB, which has no `MAX_EXECUTION_TIME`. Both surface as +`QUERY_CANCELED`. + +**SQLite has no cap.** `bun:sqlite` exposes neither an interrupt nor a progress handler, and +`busy_timeout` bounds lock waits rather than query runtime, so an agent session against +SQLite can still run an unbounded scan. Nothing here fakes it with a client-side race that +would leave the query running. + +Normal UI sessions never get a cap — a deliberate ten-minute report from the SQL console must +keep working. + +On top of that, `QueryExecutor` rejects a statement classified as a write before it reaches +the driver, with error code `READ_ONLY_SESSION`. `classifyStatement()` in +`shared/sql/statements.ts` returns `'read' | 'write' | 'ddl' | 'unknown'`; `'unknown'` is +treated as a write (fail closed). + +## Proposals + +```ts +type ProposalStatus = + | 'pending' + | 'approved' + | 'rejected' + | 'executed' + | 'failed' + | 'cancelled' + | 'expired' + +interface Proposal { + id: string + connectionId: string + database?: string + sql: string + reason?: string + status: ProposalStatus + createdAt: number + resolvedAt?: number + result?: { affectedRows?: number; statements?: number } + error?: string +} +``` + +Lifecycle: + +1. CLI calls `agent.proposeWrite` → store creates a `pending` proposal, backend emits + `cli.proposal` to the frontend. +2. Frontend opens a SQL console tab with the SQL prefilled and a banner offering Run / Reject. +3. On Run the frontend executes the SQL in the tab's own session, then calls + `agent.proposals.resolve` with `executed` (or `failed` + error). On Reject it resolves + `rejected`. The run is matched to the proposal by tab _and_ by SQL — if the user edited the + console, or ran a single statement of a multi-statement proposal, the proposal resolves + `failed` rather than reporting a write that did not happen. So `executed` means the + proposed SQL ran, not merely that something ran in that tab. +4. `agent.proposals.wait` returns as soon as the status leaves `pending`, or on timeout. + +Proposals live in memory for one hour, then become `expired`. `pending` is the only status +the frontend may act on. + +A proposal can also leave `pending` behind the app's back — the agent cancels it, the TTL +expires it, or another window resolves it. The backend therefore emits `cli.proposal` on +every transition, and the app invalidates that banner: the tab stays, Run and Reject go away. +Because the message may not have arrived yet, Run additionally re-checks the proposal is +still pending before executing anything. Without both, a click on a stale banner would run a +write for a proposal that no longer exists. + +## CLI + +Public package `@dotaz/cli`, binary `dotaz`. Its private source workspace lives in +`src/cli-agent/`; `scripts/build-agent-cli.ts` bundles it and `@dotaz/shared` into the +dependency-free `dist-agent-cli/` package published by the tag-driven release workflow. + +Paths address objects as `connection/database/schema/table`. A connection is matched by id, +by exact name, or by a unique case-insensitive prefix. Drivers without databases or schemas +(SQLite) accept the shortened form `connection/table`. + +``` +dotaz status # is the app running, is CLI access enabled +dotaz ls [path] # connections → databases → schemas → tables +dotaz describe # columns, PK, indexes, foreign keys both ways +dotaz rows [--where] [--order] [--limit] [--offset] [--columns] +dotaz query [--param v]… [--limit] +dotaz explain [--analyze] +dotaz search [--scope database|schema|table] +dotaz history [--conn] [--limit] +dotaz bookmarks list [--conn] [--search] +dotaz propose [--reason] [--wait [sec]] +dotaz approvals list | status | wait [--wait sec] | cancel +dotaz ui state +dotaz ui open [--where] +dotaz ui console [--sql] [--run] +``` + +Global flags: `--json`, `--format table|json|jsonl|csv|md`, `--max-bytes N` (default 65536), +`--timeout ms`, `--endpoint `, `--instance `, `--quiet`. + +Output rules: + +- Default format is a compact aligned table on stdout; diagnostics go to stderr. +- Row output is always capped, and truncation is always reported — `--quiet` silences + diagnostics, never a missing row. `table`/`md` end with `rows: 20/1543 (truncated, use + --limit)`; `--json` carries `truncated`/`shown`/`total` inside the object; `csv`/`jsonl` + keep stdout a clean stream and report on stderr. +- `--json` emits a single object; `--format jsonl` emits one row per line. +- `--limit` on `query` is pushed into the SQL when the statement is a single unlimited + read-only `SELECT`/`WITH` with no `OFFSET`, locking clause or `INTO` — so the database + stops early. Anything else falls back to trimming the printed rows, and the CLI says which + of the two happened. The distinction matters: only the first one bounds what the database + actually does. +- A timed-out or interrupted query is cancelled in the app (`query.cancel`) rather than left + running. The `agent.query` handler then releases its session in `finally`, and the CLI + reports whether the cancel succeeded. + +Exit codes: + +| Code | Meaning | +| ---- | ------------------------------------------------- | +| 0 | success | +| 2 | usage error | +| 3 | database error (`errorCode` printed when present) | +| 4 | read-only violation — use `dotaz propose` | +| 5 | Dotaz is not running or CLI access is disabled | +| 6 | timeout | +| 7 | proposal still pending | +| 8 | proposal rejected | + +## Enabling CLI access + +Off by default. `cli.enabled` in app settings (Settings UI toggle), or `DOTAZ_CLI=1` in the +environment. Toggling the setting starts or stops the control server without a restart. diff --git a/package.json b/package.json index 6b7ec437..bae74eeb 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "start": "vite build && WEBKIT_DISABLE_COMPOSITING_MODE=1 electrobun dev", "dev": "concurrently \"vite --port 6400\" \"WEBKIT_DISABLE_COMPOSITING_MODE=1 electrobun dev --watch\"", "build:canary": "vite build && electrobun build --env=canary", + "build:agent-cli": "bun scripts/build-agent-cli.ts", "build:server": "bun scripts/build-server.ts", "build:docker": "docker build -t dotaz .", "test": "bun test", diff --git a/scripts/build-agent-cli.ts b/scripts/build-agent-cli.ts new file mode 100644 index 00000000..ba4d64ac --- /dev/null +++ b/scripts/build-agent-cli.ts @@ -0,0 +1,58 @@ +// Build the dependency-free @dotaz/cli package published by the release workflow. + +import { chmodSync, mkdirSync, rmSync } from 'node:fs' +import { resolve } from 'node:path' +import rootPkg from '../package.json' + +const ROOT = resolve(import.meta.dir, '..') +const OUT = resolve(ROOT, 'dist-agent-cli') +const VERSION = process.env.VERSION || rootPkg.version + +rmSync(OUT, { recursive: true, force: true }) +mkdirSync(resolve(OUT, 'bin'), { recursive: true }) + +const build = await Bun.build({ + entrypoints: [resolve(ROOT, 'src/cli-agent/main.ts')], + outdir: resolve(OUT, 'bin'), + target: 'bun', + minify: true, + naming: 'dotaz.js', + define: { + __DOTAZ_CLI_VERSION__: JSON.stringify(VERSION), + }, +}) + +if (!build.success) { + for (const log of build.logs) console.error(log) + throw new Error('Failed to bundle @dotaz/cli') +} + +const packageJson = { + name: '@dotaz/cli', + version: VERSION, + description: 'Agent CLI for the running Dotaz desktop database client', + type: 'module', + license: rootPkg.license, + author: rootPkg.author, + repository: { + type: 'git', + url: 'git+https://github.com/contember/dotaz.git', + directory: 'src/cli-agent', + }, + homepage: 'https://github.com/contember/dotaz#agent-cli', + bugs: 'https://github.com/contember/dotaz/issues', + keywords: ['database', 'cli', 'agent', 'postgresql', 'mysql', 'sqlite'], + bin: { dotaz: 'bin/dotaz.js' }, + files: ['bin/', 'README.md', 'LICENSE'], + engines: { bun: '>=1.3.0' }, + publishConfig: { access: 'public' }, +} + +await Promise.all([ + Bun.write(resolve(OUT, 'package.json'), `${JSON.stringify(packageJson, null, '\t')}\n`), + Bun.write(resolve(OUT, 'README.md'), Bun.file(resolve(ROOT, 'src/cli-agent/README.md'))), + Bun.write(resolve(OUT, 'LICENSE'), Bun.file(resolve(ROOT, 'LICENSE'))), +]) + +chmodSync(resolve(OUT, 'bin/dotaz.js'), 0o755) +console.log(`Agent CLI package ${VERSION} built at ${OUT}`) diff --git a/src/backend-desktop/control-server.ts b/src/backend-desktop/control-server.ts new file mode 100644 index 00000000..27f83b19 --- /dev/null +++ b/src/backend-desktop/control-server.ts @@ -0,0 +1,221 @@ +// Local control endpoint for the `dotaz` CLI (see docs/agent-cli.md). +// +// Plain HTTP over a unix socket (macOS/Linux) or loopback TCP (Windows, or on request) — +// every CLI invocation is one-shot, so a WebSocket would buy nothing. The endpoint only +// exists while the user has CLI access enabled; there is no way to reach it otherwise. + +import { createCliHandlerLookup } from '@dotaz/backend-shared/rpc/cli-surface' +import { dispatchRpc, parseRpcRequest, type RpcHandler } from '@dotaz/backend-shared/rpc/dispatch' +import { randomBytes, timingSafeEqual } from 'node:crypto' +import { chmodSync, existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +export const CLI_PROTOCOL_VERSION = 1 + +// Layout must match src/cli-agent/endpoint.ts — one file per instance, so two running +// instances never overwrite or delete each other's endpoint. +const CLI_DIR = 'cli' +const ENDPOINT_FILE_PATTERN = /^endpoint-(\d+)\.json$/ + +export type ControlTransport = 'unix' | 'tcp' + +export interface ControlServerOptions { + /** RPC handlers to expose — the same map the webview RPC uses. */ + handlers: Record + /** Parent of the `cli/` endpoint directory, normally Utils.paths.userData. */ + userDataDir: string + appVersion: string + /** Overrides the platform default — also settable with DOTAZ_CLI_TRANSPORT. */ + transport?: ControlTransport +} + +export type ControlServerAddress = + | { transport: 'unix'; socket: string } + | { transport: 'tcp'; port: number } + +export interface ControlServerHandle { + address: ControlServerAddress + endpointDir: string + endpointFile: string + token: string + stop(): Promise +} + +export function endpointDirPath(userDataDir: string): string { + return join(userDataDir, CLI_DIR) +} + +export function endpointFilePath(userDataDir: string, pid: number = process.pid): string { + return join(endpointDirPath(userDataDir), `endpoint-${pid}.json`) +} + +/** Explicit option first, then DOTAZ_CLI_TRANSPORT, then the platform (Windows has no unix sockets). */ +export function resolveTransport( + opts: { transport?: ControlTransport; env?: Record; platform?: string } = {}, +): ControlTransport { + if (opts.transport) return opts.transport + const fromEnv = (opts.env ?? process.env).DOTAZ_CLI_TRANSPORT + if (fromEnv === 'tcp' || fromEnv === 'unix') return fromEnv + return (opts.platform ?? process.platform) === 'win32' ? 'tcp' : 'unix' +} + +/** Socket lives in the runtime dir so the OS cleans it up on reboot. */ +function socketPathForPid(pid: number): string { + return join(process.env.XDG_RUNTIME_DIR ?? tmpdir(), `dotaz-${pid}.sock`) +} + +function tokensMatch(expected: string, received: string | null): boolean { + if (received === null) return false + const encoder = new TextEncoder() + const a = encoder.encode(expected) + const b = encoder.encode(received) + // timingSafeEqual throws on length mismatch, and the length itself is not a secret + if (a.length !== b.length) return false + return timingSafeEqual(a, b) +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function removeIfExists(path: string): void { + try { + if (existsSync(path)) unlinkSync(path) + } catch { /* best effort — a leftover file must never block startup or shutdown */ } +} + +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (err) { + // EPERM means the process exists but belongs to another user — still alive + return err instanceof Error && 'code' in err && err.code === 'EPERM' + } +} + +/** Drops files left behind by crashed instances; a live instance's file is never touched. */ +function pruneDeadEndpointFiles(dir: string, ownPid: number): void { + let entries: string[] + try { + entries = readdirSync(dir) + } catch { + return + } + for (const entry of entries) { + const match = ENDPOINT_FILE_PATTERN.exec(entry) + if (!match) continue + const pid = Number(match[1]) + if (pid !== ownPid && isPidAlive(pid)) continue + removeIfExists(join(dir, entry)) + } +} + +export async function startControlServer(opts: ControlServerOptions): Promise { + const token = randomBytes(32).toString('hex') + const getHandler = createCliHandlerLookup(opts.handlers) + const pid = process.pid + const transport = resolveTransport({ transport: opts.transport }) + const socketPath = transport === 'unix' ? socketPathForPid(pid) : null + const endpointDir = endpointDirPath(opts.userDataDir) + const endpointFile = endpointFilePath(opts.userDataDir, pid) + + mkdirSync(endpointDir, { recursive: true, mode: 0o700 }) + // mkdir applies the mode only when creating, and umask can strip bits off it + chmodSync(endpointDir, 0o700) + pruneDeadEndpointFiles(endpointDir, pid) + + // A socket left behind by a crashed instance would make bind fail + if (socketPath) removeIfExists(socketPath) + + const handleRequest = async (req: Request): Promise => { + const url = new URL(req.url) + + if (url.pathname === '/health' && req.method === 'GET') { + return jsonResponse({ ok: true, version: opts.appVersion, pid, protocol: CLI_PROTOCOL_VERSION }) + } + + if (url.pathname === '/rpc' && req.method === 'POST') { + if (!tokensMatch(token, req.headers.get('x-dotaz-token'))) { + return jsonResponse({ type: 'response', id: 0, success: false, error: 'Invalid token' }, 401) + } + const raw = await req.text() + const request = parseRpcRequest(raw) + if (!request) { + return jsonResponse({ type: 'response', id: 0, success: false, error: 'Invalid JSON' }, 400) + } + const response = await dispatchRpc(request, getHandler) + return jsonResponse(response) + } + + return jsonResponse({ error: 'Not found' }, 404) + } + + let server: ReturnType + let address: ControlServerAddress + if (socketPath) { + server = Bun.serve({ unix: socketPath, fetch: handleRequest }) + address = { transport: 'unix', socket: socketPath } + } else { + // No unix sockets on Windows — an ephemeral loopback port plus the token instead + const tcpServer = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: handleRequest }) + server = tcpServer + const port = tcpServer.port + if (port === undefined) { + await tcpServer.stop(true) + throw new Error('CLI control server bound no port') + } + address = { transport: 'tcp', port } + } + + writeFileSync( + endpointFile, + JSON.stringify( + { + pid, + transport: address.transport, + socket: socketPath, + port: socketPath ? null : server.port, + token, + version: opts.appVersion, + protocol: CLI_PROTOCOL_VERSION, + startedAt: Date.now(), + }, + null, + 2, + ), + { mode: 0o600 }, + ) + // writeFileSync only applies mode when creating — an existing file keeps its old perms + chmodSync(endpointFile, 0o600) + + // Only our own file and socket — another instance may still be serving from this directory + const cleanup = () => { + if (socketPath) removeIfExists(socketPath) + removeIfExists(endpointFile) + } + // Covers graceful paths only. Electrobun installs its own SIGINT/SIGTERM handlers that end + // the process through a native quit, so a signalled instance runs neither these nor 'exit' + // and leaves its files behind — that is what the startup prune and the CLI's dead-pid + // filter are for. + process.once('exit', cleanup) + + let stopped = false + return { + address, + endpointDir, + endpointFile, + token, + async stop() { + if (stopped) return + stopped = true + process.off('exit', cleanup) + await server.stop(true) + cleanup() + }, + } +} diff --git a/src/backend-desktop/index.ts b/src/backend-desktop/index.ts index b9c9c619..0d069ef9 100644 --- a/src/backend-desktop/index.ts +++ b/src/backend-desktop/index.ts @@ -6,6 +6,7 @@ import type { DotazRPC } from '@dotaz/backend-types' import { ApplicationMenu, BrowserView, BrowserWindow, Updater, Utils } from 'electrobun/bun' import { existsSync, mkdirSync } from 'node:fs' import { join, resolve } from 'node:path' +import { type ControlServerHandle, startControlServer } from './control-server' const DEV_SERVER_PORT = 6400 const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}` @@ -54,16 +55,53 @@ const userDataDir = Utils.paths.userData const devDemoPath = resolve(import.meta.dir, '../../scripts/seed/bookstore.db') const bundledDemoPath = resolve(import.meta.dir, '../resources/bookstore.db') const demoDbSourcePath = existsSync(devDemoPath) ? devDemoPath : bundledDemoPath +const appVersion = await Updater.localInfo.version() const { handlers, sessionManager } = createHandlers(connectionManager, undefined, appDb, Utils, { emitMessage: (channel, payload) => emitToFrontend?.(channel, payload), demoDbSourcePath, demoDbTargetPath: join(userDataDir, 'bookstore-demo.db'), + appVersion, + mode: 'desktop', }) + +// ── CLI control endpoint (see docs/agent-cli.md) ───────── +// Off unless the user opts in, and started/stopped live so the Settings toggle +// takes effect without a restart. +let controlServer: ControlServerHandle | null = null + +function cliAccessEnabled(): boolean { + return process.env.DOTAZ_CLI === '1' || appDb.getBooleanSetting('cli.enabled') === true +} + +async function syncControlServer(): Promise { + const enabled = cliAccessEnabled() + if (enabled && !controlServer) { + try { + controlServer = await startControlServer({ + handlers, + userDataDir, + appVersion, + }) + const { address } = controlServer + console.log(`CLI endpoint listening on ${address.transport === 'unix' ? address.socket : `127.0.0.1:${address.port}`}`) + } catch (err) { + console.error('CLI endpoint failed to start:', err instanceof Error ? err.message : err) + } + } else if (!enabled && controlServer) { + await controlServer.stop() + controlServer = null + console.log('CLI endpoint stopped') + } +} const rpc = BrowserView.defineRPC({ maxRequestTime: 30000, handlers: { requests: { ...handlers, + 'settings.set': (params: { key: string; value: string }) => { + handlers['settings.set'](params) + if (params.key === 'cli.enabled') void syncControlServer() + }, 'update.apply': async () => { await Updater.applyUpdate() }, @@ -315,6 +353,13 @@ connectionManager.onStatusChanged(async (event) => { } }) +// Started after the window exists so backend → frontend messages have somewhere to land +await syncControlServer() + +mainWindow.on('close', () => { + void controlServer?.stop() +}) + // ── Auto-update ────────────────────────────────────────── const currentChannel = await Updater.localInfo.channel() if (currentChannel !== 'dev') { diff --git a/src/backend-shared/db/driver.ts b/src/backend-shared/db/driver.ts index 6cb06a5c..b2bf12b4 100644 --- a/src/backend-shared/db/driver.ts +++ b/src/backend-shared/db/driver.ts @@ -4,6 +4,20 @@ import type { SchemaData } from '@dotaz/shared/types/database' import type { QueryResult } from '@dotaz/shared/types/query' import type { DriverConnectionHandleInfo } from '@dotaz/shared/types/rpc' +export interface ReserveSessionOptions { + /** + * Open the session read-only, enforced by the engine — never by inspecting SQL. + * Used by CLI/agent sessions, which must not be able to write (see docs/agent-cli.md). + */ + readOnly?: boolean + /** + * Engine-enforced cap on how long a single statement may run. Applied to read-only + * sessions only — a user's own long report must never be cut short. Omitted or <= 0 + * means no cap. SQLite ignores it: the engine has no statement timeout. + */ + statementTimeoutMs?: number +} + export interface DatabaseDriver extends SqlDialect { // Lifecycle connect(config: ConnectionConfig): Promise @@ -11,9 +25,11 @@ export interface DatabaseDriver extends SqlDialect { isConnected(): boolean // Session management - reserveSession(sessionId: string): Promise + reserveSession(sessionId: string, opts?: ReserveSessionOptions): Promise releaseSession(sessionId: string): Promise getSessionIds(): string[] + /** Whether the session was reserved read-only — the driver is the source of truth. */ + isSessionReadOnly(sessionId: string): boolean // Query execution execute(sql: string, params?: unknown[], sessionId?: string, poolQueryKey?: symbol): Promise diff --git a/src/backend-shared/db/error-mapping.ts b/src/backend-shared/db/error-mapping.ts index 2a61e716..0087bf38 100644 --- a/src/backend-shared/db/error-mapping.ts +++ b/src/backend-shared/db/error-mapping.ts @@ -8,6 +8,12 @@ export function mapPostgresError(err: unknown): DatabaseError { // Bun SQL stores SQLSTATE in errno, postgres.js uses code const pgCode = ((err as any)?.errno ?? (err as any)?.code) as string | undefined + // Before the generic timeout check — a statement timeout says "timeout" but the + // connection is fine, and reporting it as a connection failure misleads callers. + if (pgCode === '57014') { + return new QueryError('QUERY_CANCELED', message, { cause: err }) + } + // Connection errors if (/ECONNREFUSED|connection refused/i.test(message)) { return new ConnectionError('CONNECTION_REFUSED', message, { cause: err }) @@ -137,6 +143,12 @@ export function mapMysqlError(err: unknown): DatabaseError { const message = err instanceof Error ? err.message : String(err) const errno = (err as any)?.errno as number | undefined + // 3024 = MySQL MAX_EXECUTION_TIME, 1969 = MariaDB max_statement_time. Checked before the + // generic timeout match, which would otherwise report a healthy connection as broken. + if (errno === 3024 || errno === 1969) { + return new QueryError('QUERY_CANCELED', message, { cause: err }) + } + // Connection errors if (/ECONNREFUSED|connection refused/i.test(message) || errno === 2003) { return new ConnectionError('CONNECTION_REFUSED', message, { cause: err }) diff --git a/src/backend-shared/db/logging-driver.ts b/src/backend-shared/db/logging-driver.ts index beaaed0f..fea3f92d 100644 --- a/src/backend-shared/db/logging-driver.ts +++ b/src/backend-shared/db/logging-driver.ts @@ -2,7 +2,7 @@ import type { ConnectionConfig, ConnectionType } from '@dotaz/shared/types/conne import type { SchemaData } from '@dotaz/shared/types/database' import type { QueryResult } from '@dotaz/shared/types/query' import type { DriverConnectionHandleInfo } from '@dotaz/shared/types/rpc' -import type { DatabaseDriver } from './driver' +import type { DatabaseDriver, ReserveSessionOptions } from './driver' /** * A DatabaseDriver wrapper that logs all SQL queries to the console. @@ -79,8 +79,8 @@ export class LoggingDriver implements DatabaseDriver { isConnected(): boolean { return this.inner.isConnected() } - reserveSession(sessionId: string): Promise { - return this.inner.reserveSession(sessionId) + reserveSession(sessionId: string, opts?: ReserveSessionOptions): Promise { + return this.inner.reserveSession(sessionId, opts) } releaseSession(sessionId: string): Promise { return this.inner.releaseSession(sessionId) @@ -88,6 +88,9 @@ export class LoggingDriver implements DatabaseDriver { getSessionIds(): string[] { return this.inner.getSessionIds() } + isSessionReadOnly(sessionId: string): boolean { + return this.inner.isSessionReadOnly(sessionId) + } cancel(sessionId?: string, poolQueryKey?: symbol): Promise { return this.inner.cancel(sessionId, poolQueryKey) } diff --git a/src/backend-shared/drivers/mysql-driver.ts b/src/backend-shared/drivers/mysql-driver.ts index 4844cd80..0d6d87e1 100644 --- a/src/backend-shared/drivers/mysql-driver.ts +++ b/src/backend-shared/drivers/mysql-driver.ts @@ -6,7 +6,7 @@ import type { QueryResult, QueryResultColumn } from '@dotaz/shared/types/query' import type { DriverConnectionHandleInfo } from '@dotaz/shared/types/rpc' import type { SQL } from 'bun' import { ConnectionPool, type PoolConnectionSnapshot } from '../db/connection-pool' -import type { DatabaseDriver } from '../db/driver' +import type { DatabaseDriver, ReserveSessionOptions } from '../db/driver' import { mapMysqlError } from '../db/error-mapping' import { getAffectedRowCount } from '../db/result-utils' import { isConnectionLevelError, safeCloseConnection, syncTxActive } from './driver-utils' @@ -136,6 +136,7 @@ export class MysqlDriver implements DatabaseDriver { private pool: ConnectionPool | null = null private connected = false private sessions = new Map() + private readOnlySessions = new Set() private defaultSessionPending = false private poolActiveQueries = new Map>() @@ -176,6 +177,7 @@ export class MysqlDriver implements DatabaseDriver { await safeCloseConnection(session.conn, { rollback: session.txActive }) } this.sessions.clear() + this.readOnlySessions.clear() if (this.pool) { await this.pool.disconnectAll() @@ -187,16 +189,32 @@ export class MysqlDriver implements DatabaseDriver { return this.connected } - async reserveSession(sessionId: string): Promise { + async reserveSession(sessionId: string, opts?: ReserveSessionOptions): Promise { this.ensureConnected() if (this.sessions.has(sessionId)) { throw new Error(`Session "${sessionId}" already exists`) } const conn = await this.pool!.createConnection() + if (opts?.readOnly) { + try { + // Applies to every transaction started later on this connection. + await conn.unsafe('SET SESSION TRANSACTION READ ONLY') + const timeoutMs = Math.floor(opts.statementTimeoutMs ?? 0) + if (Number.isFinite(timeoutMs) && timeoutMs > 0) { + await this.setStatementTimeout(conn, timeoutMs) + } + } catch (err) { + await safeCloseConnection(conn) + await this.pool!.destroyConnection(conn) + throw err instanceof DatabaseError ? err : mapMysqlError(err) + } + this.readOnlySessions.add(sessionId) + } this.sessions.set(sessionId, { conn, txActive: false, iterating: false, activeQueries: new Set() }) } async releaseSession(sessionId: string): Promise { + this.readOnlySessions.delete(sessionId) const session = this.sessions.get(sessionId) if (!session) return // idempotent — already released or never existed this.sessions.delete(sessionId) @@ -210,6 +228,10 @@ export class MysqlDriver implements DatabaseDriver { return [...this.sessions.keys()].filter((id) => id !== DEFAULT_SESSION) } + isSessionReadOnly(sessionId: string): boolean { + return this.readOnlySessions.has(sessionId) + } + async execute(sql: string, params?: unknown[], sessionId?: string, poolQueryKey?: symbol): Promise { this.ensureConnected() const session = this.resolveSession(sessionId) @@ -692,6 +714,15 @@ export class MysqlDriver implements DatabaseDriver { return '?' } + /** Cap statement runtime (SELECTs only, on both flavours). MariaDB has no MAX_EXECUTION_TIME — it takes seconds instead. */ + private async setStatementTimeout(conn: SQL, timeoutMs: number): Promise { + try { + await conn.unsafe(`SET SESSION MAX_EXECUTION_TIME = ${timeoutMs}`) + } catch { + await conn.unsafe(`SET SESSION max_statement_time = ${timeoutMs / 1000}`) + } + } + private ensureConnected(): void { if (!this.pool || !this.connected) { throw new Error('Not connected. Call connect() first.') diff --git a/src/backend-shared/drivers/postgres-driver.ts b/src/backend-shared/drivers/postgres-driver.ts index cc4feef5..d196a58d 100644 --- a/src/backend-shared/drivers/postgres-driver.ts +++ b/src/backend-shared/drivers/postgres-driver.ts @@ -6,7 +6,7 @@ import type { QueryResult, QueryResultColumn } from '@dotaz/shared/types/query' import type { DriverConnectionHandleInfo } from '@dotaz/shared/types/rpc' import type { SQL } from 'bun' import { ConnectionPool, type PoolConnectionSnapshot } from '../db/connection-pool' -import type { DatabaseDriver } from '../db/driver' +import type { DatabaseDriver, ReserveSessionOptions } from '../db/driver' import { mapPostgresError } from '../db/error-mapping' import { getAffectedRowCount } from '../db/result-utils' import { isConnectionLevelError, safeCloseConnection, syncTxActive } from './driver-utils' @@ -161,6 +161,8 @@ export class PostgresDriver implements DatabaseDriver { private pool: ConnectionPool | null = null private connected = false private sessions = new Map() + /** sessionId → the SQL that re-asserts read-only mode, re-run before every statement. */ + private readOnlySessions = new Map() private defaultSessionPending = false private poolActiveQueries = new Map>() @@ -204,6 +206,7 @@ export class PostgresDriver implements DatabaseDriver { await safeCloseConnection(session.conn, { rollback: true }) } this.sessions.clear() + this.readOnlySessions.clear() if (this.pool) { await this.pool.disconnectAll() @@ -217,16 +220,37 @@ export class PostgresDriver implements DatabaseDriver { // --- Session management --- - async reserveSession(sessionId: string): Promise { + async reserveSession(sessionId: string, opts?: ReserveSessionOptions): Promise { this.ensureConnected() if (this.sessions.has(sessionId)) { throw new Error(`Session "${sessionId}" already exists`) } const conn = await this.pool!.createConnection() + if (opts?.readOnly) { + // Both settings are plain GUCs, so `SELECT set_config(…)` — which classifies as a + // read — can clear them from inside the session. Re-asserting before every + // statement is what makes them stick; see reassertReadOnly(). + const statements = ['SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY'] + const timeoutMs = Math.floor(opts.statementTimeoutMs ?? 0) + if (Number.isFinite(timeoutMs) && timeoutMs > 0) { + // A bare integer is milliseconds; the server cancels the statement itself. + statements.push(`SET SESSION statement_timeout = ${timeoutMs}`) + } + const reassertSql = statements.join('; ') + try { + await conn.unsafe(reassertSql) + } catch (err) { + await safeCloseConnection(conn) + await this.pool!.destroyConnection(conn) + throw err instanceof DatabaseError ? err : mapPostgresError(err) + } + this.readOnlySessions.set(sessionId, reassertSql) + } this.sessions.set(sessionId, { conn, txActive: false, txAborted: false, iterating: false, activeQueries: new Set() }) } async releaseSession(sessionId: string): Promise { + this.readOnlySessions.delete(sessionId) const session = this.sessions.get(sessionId) if (!session) return // idempotent — already released or never existed this.sessions.delete(sessionId) @@ -240,6 +264,28 @@ export class PostgresDriver implements DatabaseDriver { return [...this.sessions.keys()].filter((id) => id !== DEFAULT_SESSION) } + isSessionReadOnly(sessionId: string): boolean { + return this.readOnlySessions.has(sessionId) + } + + /** + * Re-apply a read-only session's characteristics before each statement. + * + * `default_transaction_read_only` and `statement_timeout` are ordinary GUCs, and + * `SELECT set_config('default_transaction_read_only','off',false)` classifies as a read — + * so without this a read-only session could clear its own enforcement and then write. + * Verified against PostgreSQL 17: re-asserting between statements blocks the write, and a + * single statement cannot both clear the GUC and write under it. + * + * Skipped inside an explicit transaction: session characteristics only affect transactions + * started afterwards, and that transaction already began read-only. + */ + private async reassertReadOnly(sessionId: string, session: SessionState, conn: SQL): Promise { + const reassertSql = this.readOnlySessions.get(sessionId) + if (!reassertSql || session.txActive) return + await conn.unsafe(reassertSql) + } + // --- Query execution --- async execute(sql: string, params?: unknown[], sessionId?: string, poolQueryKey?: symbol): Promise { @@ -247,6 +293,9 @@ export class PostgresDriver implements DatabaseDriver { const session = this.resolveSession(sessionId) const conn = session ? session.conn : await this.pool!.acquireConnection() const releaseConn = !session + if (session && sessionId !== undefined) { + await this.reassertReadOnly(sessionId, session, conn) + } const start = performance.now() const query = conn.unsafe(sql, params ?? []) const effectiveQueryKey = session ? undefined : (poolQueryKey ?? Symbol()) diff --git a/src/backend-shared/drivers/sqlite-driver.ts b/src/backend-shared/drivers/sqlite-driver.ts index 5d65c503..ac777f15 100644 --- a/src/backend-shared/drivers/sqlite-driver.ts +++ b/src/backend-shared/drivers/sqlite-driver.ts @@ -14,9 +14,10 @@ import { DatabaseError } from '@dotaz/shared/types/errors' import type { QueryResult, QueryResultColumn } from '@dotaz/shared/types/query' import type { DriverConnectionHandleInfo } from '@dotaz/shared/types/rpc' import { SQL } from 'bun' -import type { DatabaseDriver } from '../db/driver' +import type { DatabaseDriver, ReserveSessionOptions } from '../db/driver' import { mapSqliteError } from '../db/error-mapping' import { getAffectedRowCount } from '../db/result-utils' +import { safeCloseConnection, syncTxActive } from './driver-utils' /** Row shape from sqlite_master */ interface SqliteMasterRow { @@ -55,6 +56,19 @@ interface SqlitePragmaForeignKeyRow { on_delete: string } +/** + * A read-only session's own connection. SQLite has no per-session state on a shared + * handle, so `PRAGMA query_only` needs a handle nobody else uses. + */ +interface ReadOnlyHandle { + conn: SQL + txActive: boolean + iterating: boolean + handleId: string + createdAt: number + lastUsedAt: number +} + /** Map SQLite type affinity strings to DatabaseDataType. */ function mapSqliteDataType(type: string): DatabaseDataType { const t = type.toUpperCase() @@ -80,6 +94,8 @@ export class SqliteDriver implements DatabaseDriver { private txActive = false private txOwnerSession: string | null = null private sessionIds = new Set() + /** sessionId → dedicated read-only handle. Absent for normal sessions. */ + private readOnlySessions = new Map() private iterating = false /** Separate read-only connection used by iterate() so it doesn't block the main connection. */ private iterateDb: SQL | null = null @@ -128,6 +144,10 @@ export class SqliteDriver implements DatabaseDriver { async disconnect(): Promise { this.connected = false + for (const readOnly of this.readOnlySessions.values()) { + await safeCloseConnection(readOnly.conn, { rollback: readOnly.txActive }) + } + this.readOnlySessions.clear() if (this.iterateDb) { try { await this.iterateDb.close() @@ -156,11 +176,23 @@ export class SqliteDriver implements DatabaseDriver { return this.connected } - async reserveSession(sessionId: string): Promise { + async reserveSession(sessionId: string, opts?: ReserveSessionOptions): Promise { + if (opts?.readOnly) { + // statementTimeoutMs is ignored: SQLite has no statement timeout and bun:sqlite + // exposes no interrupt or progress hook, so nothing can stop a running query. + await this.openReadOnlyHandle(sessionId) + } this.sessionIds.add(sessionId) } async releaseSession(sessionId: string): Promise { + const readOnly = this.readOnlySessions.get(sessionId) + if (readOnly) { + this.readOnlySessions.delete(sessionId) + await safeCloseConnection(readOnly.conn, { rollback: readOnly.txActive }) + this.sessionIds.delete(sessionId) + return + } if (this.txActive && this.txOwnerSession === sessionId) { try { await this.db!.unsafe('ROLLBACK') @@ -175,29 +207,50 @@ export class SqliteDriver implements DatabaseDriver { return [...this.sessionIds] } + isSessionReadOnly(sessionId: string): boolean { + return this.readOnlySessions.has(sessionId) + } + async execute(sql: string, params?: unknown[], sessionId?: string): Promise { this.ensureConnected() + this.ensureKnownSession(sessionId) + + const readOnly = sessionId === undefined ? undefined : this.readOnlySessions.get(sessionId) + if (readOnly) { + readOnly.lastUsedAt = Date.now() + const result = await this.runStatement(readOnly.conn, sql, params) + syncTxActive(readOnly, sql) + return result + } + this.ensureSessionCanExecute(sessionId) this.markMainUsed() + const result = await this.runStatement(this.db!, sql, params) + + // Sync txActive for raw transaction-control statements + const upper = sql.trim().toUpperCase() + if (/^(BEGIN|START\s+TRANSACTION)\b/.test(upper)) { + this.txActive = true + this.txOwnerSession = sessionId ?? null + } else if (/^(COMMIT|END)\b/.test(upper)) { + this.txActive = false + this.txOwnerSession = null + } else if (/^ROLLBACK\b/.test(upper) && !/^ROLLBACK\s+TO\b/.test(upper)) { + this.txActive = false + this.txOwnerSession = null + } + + return result + } + + /** Run one statement on a specific handle and shape the driver result. */ + private async runStatement(conn: SQL, sql: string, params?: unknown[]): Promise { const start = performance.now() try { - const result = await this.db!.unsafe(sql, params ?? []) + const result = await conn.unsafe(sql, params ?? []) const durationMs = Math.round(performance.now() - start) const rows = [...result] as Record[] - // Sync txActive for raw transaction-control statements - const upper = sql.trim().toUpperCase() - if (/^(BEGIN|START\s+TRANSACTION)\b/.test(upper)) { - this.txActive = true - this.txOwnerSession = sessionId ?? null - } else if (/^(COMMIT|END)\b/.test(upper)) { - this.txActive = false - this.txOwnerSession = null - } else if (/^ROLLBACK\b/.test(upper) && !/^ROLLBACK\s+TO\b/.test(upper)) { - this.txActive = false - this.txOwnerSession = null - } - const columns: QueryResultColumn[] = rows.length > 0 ? Object.keys(rows[0]).map((name) => ({ name, dataType: DatabaseDataType.Unknown })) : [] @@ -214,6 +267,43 @@ export class SqliteDriver implements DatabaseDriver { } } + /** + * Open the session's own read-only handle so the shared connection stays writable. + * + * Read-only is set when the handle is opened, not by `PRAGMA query_only` — a pragma can be + * revoked from inside the session (`PRAGMA query_only(0)`), and the argument form carries + * no `=`, so statement classification reads it as a plain read. The pragma stays as a + * second layer. + */ + private async openReadOnlyHandle(sessionId: string): Promise { + this.ensureConnected() + if (this.readOnlySessions.has(sessionId)) return + if (!this.dbPath || this.dbPath === ':memory:') { + throw new Error('Read-only sessions require a file-backed SQLite database') + } + const conn = new SQL({ adapter: 'sqlite', filename: this.dbPath, readonly: true }) + try { + await conn.unsafe('PRAGMA foreign_keys = ON') + await this.applyInitSql(conn) + // Last, so initSql can still configure the handle + await conn.unsafe('PRAGMA query_only = ON') + } catch (err) { + try { + await conn.close() + } catch { /* already dead */ } + throw err instanceof DatabaseError ? err : mapSqliteError(err) + } + const now = Date.now() + this.readOnlySessions.set(sessionId, { + conn, + txActive: false, + iterating: false, + handleId: this.createHandleId(), + createdAt: now, + lastUsedAt: now, + }) + } + async cancel(_sessionId?: string, _poolQueryKey?: symbol): Promise { // SQLite operations are synchronous under the hood; // cancellation is not supported. @@ -379,13 +469,21 @@ export class SqliteDriver implements DatabaseDriver { sessionId?: string, ): AsyncGenerator[]> { this.ensureConnected() - // For file-based databases, use a separate read-only connection so - // iteration doesn't block the main connection (WAL mode allows - // concurrent readers). In-memory databases can't share across - // connections, so they must fall back to the main connection. - const useMainConn = this.dbPath === ':memory:' - const readConn = useMainConn ? this.db! : await this.getIterateDb() - if (useMainConn) { + // A read-only session iterates on its own handle; for other file-based + // databases, use a separate read-only connection so iteration doesn't block + // the main connection (WAL mode allows concurrent readers). In-memory + // databases can't share across connections, so they fall back to the main one. + this.ensureKnownSession(sessionId) + const readOnly = sessionId === undefined ? undefined : this.readOnlySessions.get(sessionId) + const useMainConn = !readOnly && this.dbPath === ':memory:' + let readConn: SQL + if (readOnly) { + if (readOnly.txActive) throw new Error('Cannot iterate with an active transaction') + readConn = readOnly.conn + readOnly.iterating = true + readOnly.lastUsedAt = Date.now() + } else if (useMainConn) { + readConn = this.db! this.markMainUsed() this.ensureSessionCanExecute(sessionId) if (this.txActive) throw new Error('Cannot iterate with an active transaction') @@ -393,6 +491,7 @@ export class SqliteDriver implements DatabaseDriver { this.txOwnerSession = sessionId ?? null this.iterating = true } else { + readConn = await this.getIterateDb() this.iterateActive = true this.markIterateUsed() } @@ -425,7 +524,10 @@ export class SqliteDriver implements DatabaseDriver { await readConn.unsafe('ROLLBACK') } catch { /* ignore */ } } - if (useMainConn) { + if (readOnly) { + readOnly.iterating = false + readOnly.lastUsedAt = Date.now() + } else if (useMainConn) { this.iterating = false this.txActive = false this.txOwnerSession = null @@ -494,6 +596,15 @@ export class SqliteDriver implements DatabaseDriver { async beginTransaction(sessionId?: string): Promise { this.ensureConnected() + this.ensureKnownSession(sessionId) + const readOnly = sessionId === undefined ? undefined : this.readOnlySessions.get(sessionId) + if (readOnly) { + if (readOnly.txActive) throw new Error('A transaction is already active') + await readOnly.conn.unsafe('BEGIN') + readOnly.txActive = true + readOnly.lastUsedAt = Date.now() + return + } this.markMainUsed() if (this.txActive) { throw new Error( @@ -509,6 +620,16 @@ export class SqliteDriver implements DatabaseDriver { async commit(sessionId?: string): Promise { this.ensureConnected() + this.ensureKnownSession(sessionId) + const readOnly = sessionId === undefined ? undefined : this.readOnlySessions.get(sessionId) + if (readOnly) { + if (!readOnly.txActive) throw new Error('No active transaction') + if (readOnly.iterating) throw new Error('Cannot commit during active iteration') + await readOnly.conn.unsafe('COMMIT') + readOnly.txActive = false + readOnly.lastUsedAt = Date.now() + return + } this.ensureSessionOwnsTx(sessionId) if (this.iterating) throw new Error('Cannot commit during active iteration') this.markMainUsed() @@ -519,6 +640,19 @@ export class SqliteDriver implements DatabaseDriver { async rollback(sessionId?: string): Promise { this.ensureConnected() + this.ensureKnownSession(sessionId) + const readOnly = sessionId === undefined ? undefined : this.readOnlySessions.get(sessionId) + if (readOnly) { + if (!readOnly.txActive) throw new Error('No active transaction') + if (readOnly.iterating) throw new Error('Cannot rollback during active iteration') + try { + await readOnly.conn.unsafe('ROLLBACK') + } finally { + readOnly.txActive = false + readOnly.lastUsedAt = Date.now() + } + return + } this.ensureSessionOwnsTx(sessionId) if (this.iterating) throw new Error('Cannot rollback during active iteration') this.markMainUsed() @@ -532,6 +666,8 @@ export class SqliteDriver implements DatabaseDriver { inTransaction(sessionId?: string): boolean { if (sessionId !== undefined) { + const readOnly = this.readOnlySessions.get(sessionId) + if (readOnly) return readOnly.txActive return this.txActive && this.txOwnerSession === sessionId } return this.txActive && this.txOwnerSession === null @@ -541,7 +677,11 @@ export class SqliteDriver implements DatabaseDriver { return false } - isIterating(_sessionId?: string): boolean { + isIterating(sessionId?: string): boolean { + if (sessionId !== undefined) { + const readOnly = this.readOnlySessions.get(sessionId) + if (readOnly) return readOnly.iterating + } return this.iterating } @@ -582,6 +722,23 @@ export class SqliteDriver implements DatabaseDriver { }) } + for (const [sessionId, readOnly] of this.readOnlySessions) { + handles.push({ + handleId: readOnly.handleId, + role: 'session', + state: readOnly.txActive ? 'transaction' : (readOnly.iterating ? 'active' : 'idle'), + label: 'Read-only session', + sessionId, + createdAt: readOnly.createdAt, + lastUsedAt: readOnly.lastUsedAt, + activeQueryCount: readOnly.iterating ? 1 : 0, + inTransaction: readOnly.txActive, + txAborted: false, + iterating: readOnly.iterating, + canTerminate: true, + }) + } + return handles } @@ -591,6 +748,12 @@ export class SqliteDriver implements DatabaseDriver { await this.reopenMainConnection() return } + for (const [sessionId, readOnly] of this.readOnlySessions) { + if (readOnly.handleId === handleId) { + await this.releaseSession(sessionId) + return + } + } if (this.iterateHandleId === handleId && this.iterateDb) { try { await this.iterateDb.close() @@ -622,6 +785,19 @@ export class SqliteDriver implements DatabaseDriver { return `$${index}` } + /** + * Reject a session id this driver never reserved. Without this, SQLite would fall through + * to the shared writable handle — silently downgrading a read-only session to read-write + * whenever its handle was lost (terminated, reconnected) or the id was simply made up. + * PostgreSQL and MySQL already throw for the same input. + */ + private ensureKnownSession(sessionId?: string): void { + if (sessionId === undefined) return + if (!this.sessionIds.has(sessionId)) { + throw new Error(`Session "${sessionId}" not found`) + } + } + private ensureSessionCanExecute(sessionId?: string): void { if (!this.txActive) return if (this.iterating) { diff --git a/src/backend-shared/rpc/adapter.ts b/src/backend-shared/rpc/adapter.ts index 0bc88b75..b4838602 100644 --- a/src/backend-shared/rpc/adapter.ts +++ b/src/backend-shared/rpc/adapter.ts @@ -4,11 +4,16 @@ import type { ExportOptions, ExportPreviewRequest, ExportRawPreviewRequest, Expo import type { ImportOptions, ImportPreviewRequest, ImportPreviewResult, ImportResult } from '@dotaz/shared/types/import' import type { QueryHistoryEntry, QueryResult } from '@dotaz/shared/types/query' import type { + AgentHelloResult, AiGenerateSqlParams, AiGenerateSqlResult, ConnectionHandleInfo, HistoryListParams, OpenDialogParams, + Proposal, + ProposalListParams, + ProposalResolveParams, + ProposeWriteParams, QueryBookmark, SaveDialogParams, SavedView, @@ -18,6 +23,8 @@ import type { SessionInfo, TransactionLogParams, TransactionLogResult, + UiCommandPayload, + UiSnapshot, } from '@dotaz/shared/types/rpc' import type { DatabaseDriver } from '../db/driver' @@ -45,7 +52,7 @@ export interface RpcAdapter { terminateConnectionHandle(connectionId: string, database: string | undefined, handleId: string): Promise // ── Sessions ────────────────────────────────────────── - createSession(connectionId: string, database?: string): Promise + createSession(connectionId: string, database?: string, opts?: { readOnly?: boolean; label?: string }): Promise destroySession(sessionId: string): Promise listSessions(connectionId: string): SessionInfo[] @@ -158,6 +165,20 @@ export interface RpcAdapter { saveWorkspace(data: string): void loadWorkspace(): string | null + // ── Agent CLI (see docs/agent-cli.md) ───────────────── + agentHello(): AgentHelloResult + proposeWrite(params: ProposeWriteParams): Proposal + listProposals(filter?: ProposalListParams): Proposal[] + getProposal(proposalId: string): Proposal | null + waitForProposal(proposalId: string, timeoutMs: number): Promise + cancelProposal(proposalId: string): Proposal + resolveProposal(params: ProposalResolveParams): Proposal + + // ── UI control ──────────────────────────────────────── + getUiSnapshot(): UiSnapshot + setUiSnapshot(snapshot: UiSnapshot): void + sendUiCommand(payload: UiCommandPayload): void + // ── Demo ────────────────────────────────────────────── initializeDemo?(): Promise } diff --git a/src/backend-shared/rpc/backend-adapter.ts b/src/backend-shared/rpc/backend-adapter.ts index b348d4a3..703bd6af 100644 --- a/src/backend-shared/rpc/backend-adapter.ts +++ b/src/backend-shared/rpc/backend-adapter.ts @@ -5,11 +5,16 @@ import type { ExportOptions, ExportPreviewRequest, ExportRawPreviewRequest, Expo import type { ImportOptions, ImportPreviewRequest, ImportPreviewResult, ImportResult } from '@dotaz/shared/types/import' import type { QueryHistoryEntry, QueryResult } from '@dotaz/shared/types/query' import type { + AgentHelloResult, AiGenerateSqlParams, AiGenerateSqlResult, ConnectionHandleInfo, HistoryListParams, OpenDialogParams, + Proposal, + ProposalListParams, + ProposalResolveParams, + ProposeWriteParams, SaveDialogParams, SavedView, SavedViewConfig, @@ -18,6 +23,8 @@ import type { SessionInfo, TransactionLogParams, TransactionLogResult, + UiCommandPayload, + UiSnapshot, } from '@dotaz/shared/types/rpc' import { settingsToAiConfig } from '@dotaz/shared/types/settings' import type { DatabaseDriver } from '../db/driver' @@ -27,6 +34,8 @@ import type { ConnectionManager } from '../services/connection-manager' import type { EncryptionService } from '../services/encryption' import { buildExportSelectQuery, exportPreview, exportToFile } from '../services/export-service' import { importFromStream, importPreviewFromStream } from '../services/import-service' +import { ProposalStore } from '../services/proposal-store' +import { assertSessionWritable } from '../services/query-executor' import type { QueryExecutor } from '../services/query-executor' import { searchDatabase } from '../services/search-service' import type { SessionManager } from '../services/session-manager' @@ -37,6 +46,9 @@ import type { RpcAdapter } from './adapter' export type EmitMessage = (channel: string, payload: unknown) => void +/** Wire protocol version reported by `agent.hello` and `/health` (docs/agent-cli.md). */ +export const AGENT_PROTOCOL_VERSION = 1 + export interface BackendAdapterOptions { encryption?: EncryptionService Utils?: typeof import('electrobun/bun').Utils @@ -45,6 +57,8 @@ export interface BackendAdapterOptions { demoDbSourcePath?: string demoDbTargetPath?: string allowServerFileAccess?: boolean + appVersion?: string + mode?: 'desktop' | 'web' | 'demo' } export class BackendAdapter implements RpcAdapter { @@ -56,6 +70,12 @@ export class BackendAdapter implements RpcAdapter { private demoDbSourcePath?: string private demoDbTargetPath?: string private allowServerFileAccess: boolean + private appVersion: string + private mode: 'desktop' | 'web' | 'demo' + private proposals = new ProposalStore() + private unsubscribeProposals: () => void + /** Last snapshot published by the frontend — absent until the UI reports in. */ + private uiSnapshot: UiSnapshot | null = null constructor( private cm: ConnectionManager, @@ -71,6 +91,13 @@ export class BackendAdapter implements RpcAdapter { this.demoDbSourcePath = opts?.demoDbSourcePath this.demoDbTargetPath = opts?.demoDbTargetPath this.allowServerFileAccess = opts?.allowServerFileAccess ?? true + this.appVersion = opts?.appVersion ?? '0.0.0' + this.mode = opts?.mode ?? 'web' + // Every transition, not just creation — an approval banner whose proposal was cancelled + // or expired elsewhere must stop being actionable. + this.unsubscribeProposals = this.proposals.onChange((proposal) => { + this.emitMessage?.('cli.proposal', proposal) + }) } // ── Connections ──────────────────────────────────────── @@ -192,9 +219,9 @@ export class BackendAdapter implements RpcAdapter { // ── Sessions ────────────────────────────────────────── - async createSession(connectionId: string, database?: string): Promise { + async createSession(connectionId: string, database?: string, opts?: { readOnly?: boolean; label?: string }): Promise { if (!this.sessionManager) throw new Error('SessionManager not available') - const info = await this.sessionManager.createSession(connectionId, database) + const info = await this.sessionManager.createSession(connectionId, database, opts) this.emitMessage?.('session.changed', { connectionId, sessions: this.sessionManager.listSessions(connectionId) }) return info } @@ -263,6 +290,8 @@ export class BackendAdapter implements RpcAdapter { sessionId?: string, ): Promise { const driver = this.cm.getDriver(connectionId, database) + // The engine blocks these anyway; this turns the raw engine error into READ_ONLY_SESSION + for (const stmt of statements) assertSessionWritable(driver, stmt.sql, sessionId) const runInSession = async (effectiveSessionId: string) => { const inExistingTx = driver.inTransaction(effectiveSessionId) @@ -492,6 +521,7 @@ export class BackendAdapter implements RpcAdapter { schemaName: params.schemaName, tableNames: params.tableNames, resultsPerTable: params.resultsPerTable ?? 50, + sessionId: params.sessionId, }, () => {}, () => false, @@ -791,9 +821,58 @@ export class BackendAdapter implements RpcAdapter { return conn } + // ── Agent CLI ───────────────────────────────────────── + + agentHello(): AgentHelloResult { + return { version: this.appVersion, mode: this.mode, pid: process.pid, protocol: AGENT_PROTOCOL_VERSION } + } + + proposeWrite(params: ProposeWriteParams): Proposal { + return this.proposals.create(params) + } + + listProposals(filter?: ProposalListParams): Proposal[] { + return this.proposals.list(filter) + } + + getProposal(proposalId: string): Proposal | null { + return this.proposals.get(proposalId) + } + + async waitForProposal(proposalId: string, timeoutMs: number): Promise { + return this.proposals.wait(proposalId, timeoutMs) + } + + cancelProposal(proposalId: string): Proposal { + return this.proposals.cancel(proposalId) + } + + resolveProposal(params: ProposalResolveParams): Proposal { + return this.proposals.resolve(params) + } + + // ── UI control ──────────────────────────────────────── + + getUiSnapshot(): UiSnapshot { + return this.uiSnapshot ?? { tabs: [], activeTabId: null, activeConnectionId: null, updatedAt: 0 } + } + + setUiSnapshot(snapshot: UiSnapshot): void { + this.uiSnapshot = snapshot + } + + sendUiCommand(payload: UiCommandPayload): void { + this.emitMessage?.('cli.command', payload) + } + // ── Session Manager access ──────────────────────────── getSessionManager(): SessionManager | undefined { return this.sessionManager } + + dispose(): void { + this.unsubscribeProposals() + this.proposals.dispose() + } } diff --git a/src/backend-shared/rpc/cli-surface.ts b/src/backend-shared/rpc/cli-surface.ts new file mode 100644 index 00000000..75a5e906 --- /dev/null +++ b/src/backend-shared/rpc/cli-surface.ts @@ -0,0 +1,69 @@ +// What the local CLI endpoint is allowed to reach (see docs/agent-cli.md). +// +// The control server must NOT expose the full handler map. That map is built for the app's +// own webview, which is already trusted with everything — it includes connection deletion, +// imports, settings writes and methods that hand back decrypted credentials. A local CLI +// client gets an explicit allowlist instead, and even allowed responses are stripped of +// secrets before they leave the process. + +import type { ConnectionInfo } from '@dotaz/shared/types/connection' +import { stripSecrets } from '@dotaz/shared/types/connection' +import type { RpcHandler, RpcHandlerLookup } from './dispatch' + +/** + * Methods reachable over the CLI control endpoint. + * + * Deliberately absent: everything that mutates stored connections, imports or exports data, + * writes settings, or decrypts secrets. `ui.snapshot.set` and `agent.proposals.resolve` are + * absent too — those belong to the frontend, which reaches handlers directly. + */ +export const CLI_ALLOWED_METHODS: ReadonlySet = new Set([ + // Discovery + 'connections.list', + 'connections.connect', + 'databases.list', + // Read-only querying + 'agent.schema', + 'agent.query', + 'agent.search', + 'query.cancel', + 'query.format', + // Context the agent can read but not change + 'history.list', + 'bookmarks.list', + 'transaction.getLog', + // Agent surface + 'agent.hello', + 'agent.proposeWrite', + 'agent.proposals.list', + 'agent.proposals.get', + 'agent.proposals.wait', + 'agent.proposals.cancel', + 'ui.state', + 'ui.openTable', + 'ui.openConsole', +]) + +function isConnectionInfoArray(value: unknown): value is ConnectionInfo[] { + return Array.isArray(value) && value.every((item) => typeof item === 'object' && item !== null && 'config' in item) +} + +/** Connection listings carry decrypted passwords in-process — never let those reach a CLI client. */ +function redact(method: string, payload: unknown): unknown { + if (method !== 'connections.list' || !isConnectionInfoArray(payload)) return payload + return payload.map((connection) => ({ ...connection, config: stripSecrets(connection.config) })) +} + +/** + * Wrap a handler map so only allowlisted methods resolve and their results are redacted. + * An unknown or forbidden method looks identical from outside, so the caller learns nothing + * about which handlers exist. + */ +export function createCliHandlerLookup(handlers: Record): RpcHandlerLookup { + return (method) => { + if (!CLI_ALLOWED_METHODS.has(method)) return undefined + const handler = handlers[method] + if (!handler) return undefined + return async (params: unknown) => redact(method, await handler(params)) + } +} diff --git a/src/backend-shared/rpc/dispatch.ts b/src/backend-shared/rpc/dispatch.ts new file mode 100644 index 00000000..28eb82e9 --- /dev/null +++ b/src/backend-shared/rpc/dispatch.ts @@ -0,0 +1,68 @@ +// Transport-agnostic RPC dispatch — shared by the web WebSocket server and the CLI control server +import { DatabaseError } from '@dotaz/shared/types/errors' +import type { DatabaseErrorCode } from '@dotaz/shared/types/errors' + +export interface RpcRequest { + id?: number + method: string + params?: unknown +} + +export interface RpcResponse { + type: 'response' + id: number + success: boolean + payload?: unknown + error?: string + errorCode?: DatabaseErrorCode +} + +// Handlers are heterogeneous (each has its own params shape) — any is intentional here. +export type RpcHandler = (params: any) => unknown | Promise +export type RpcHandlerLookup = (method: string) => RpcHandler | undefined + +/** The envelope sent when the raw payload didn't parse as JSON at all. */ +export function invalidJsonResponse(): RpcResponse { + return { type: 'response', id: 0, success: false, error: 'Invalid JSON' } +} + +/** Dispatch a parsed request. Never throws — handler errors become error responses. */ +export async function dispatchRpc(req: RpcRequest, getHandler: RpcHandlerLookup): Promise { + const id = req.id ?? 0 + const handler = getHandler(req.method) + if (!handler) { + return { type: 'response', id, success: false, error: `Unknown method: ${req.method}` } + } + + try { + const payload = await handler(req.params) + return { type: 'response', id, success: true, payload } + } catch (err) { + return { + type: 'response', + id, + success: false, + error: err instanceof Error ? err.message : String(err), + errorCode: err instanceof DatabaseError ? err.code : undefined, + } + } +} + +/** Parse a raw JSON payload into a request. Returns null when it is not a valid request. */ +export function parseRpcRequest(raw: string | Uint8Array | ArrayBuffer): RpcRequest | null { + const text = typeof raw === 'string' ? raw : new TextDecoder().decode(raw) + + let msg: unknown + try { + msg = JSON.parse(text) + } catch { + return null + } + + if (typeof msg !== 'object' || msg === null || typeof (msg as { method?: unknown }).method !== 'string') { + return null + } + + const { id, method, params } = msg as { id?: number; method: string; params?: unknown } + return { id, method, params } +} diff --git a/src/backend-shared/rpc/handlers.ts b/src/backend-shared/rpc/handlers.ts index 59756903..013d9161 100644 --- a/src/backend-shared/rpc/handlers.ts +++ b/src/backend-shared/rpc/handlers.ts @@ -1,16 +1,122 @@ +import { isReadOnlySql } from '@dotaz/shared/sql/statements' import type { ConnectionConfig } from '@dotaz/shared/types/connection' +import { DatabaseError } from '@dotaz/shared/types/errors' import type { ExportOptions, ExportPreviewRequest, ExportRawPreviewRequest } from '@dotaz/shared/types/export' import type { ImportOptions, ImportPreviewRequest } from '@dotaz/shared/types/import' import type { + AgentQueryParams, + AgentSchemaParams, + AgentSearchParams, AiGenerateSqlParams, HistoryListParams, OpenDialogParams, + ProposalListParams, + ProposalResolveParams, + ProposeWriteParams, SaveDialogParams, SavedViewConfig, SearchDatabaseParams, TransactionLogParams, + UiSnapshot, } from '@dotaz/shared/types/rpc' import type { RpcAdapter } from './adapter' + +/** Long-poll ceiling for `agent.proposals.wait` — anything above is clamped, not rejected. */ +const MAX_PROPOSAL_WAIT_MS = 10 * 60 * 1000 +const DEFAULT_PROPOSAL_WAIT_MS = 30 * 1000 +const AGENT_SESSION_LABEL = 'cli' + +/** + * Reject a `where` fragment that is not a single boolean expression. + * + * The grid splices this straight into `WHERE (…)` and loads without any user interaction, so + * `1=1); DELETE FROM orders; --` would run as three statements in the tab's writable session. + * The filter box has always interpolated raw SQL — safe for a human typing into their own + * app, but this is a trust boundary in front of it. + */ +function assertBooleanExpression(where: string | undefined): void { + if (where === undefined) return + const fragment = where.trim() + if (fragment === '') return + + let depth = 0 + let i = 0 + while (i < fragment.length) { + const ch = fragment[i] + const next = fragment[i + 1] ?? '' + + // Skip over string literals and quoted identifiers — a `;` inside one is just data + if (ch === "'" || ch === '"') { + i++ + while (i < fragment.length) { + if (fragment[i] !== ch) { + i++ + continue + } + i++ + if (fragment[i] === ch) i++ // doubled quote escapes itself + else break + } + continue + } + if (ch === ';') { + throw new Error('where must be a single boolean expression, not a statement list') + } + if ((ch === '-' && next === '-') || (ch === '/' && next === '*')) { + throw new Error('where must not contain SQL comments') + } + if (ch === '(') depth++ + if (ch === ')' && --depth < 0) throw new Error('where has unbalanced parentheses') + i++ + } + if (depth !== 0) throw new Error('where has unbalanced parentheses') +} + +function requireKnownConnection(adapter: RpcAdapter, connectionId: string): void { + if (!connectionId) { + throw new Error('connectionId is required') + } + if (!adapter.listConnections().some((c) => c.id === connectionId)) { + throw new Error(`Unknown connection: ${connectionId}`) + } +} + +async function destroyAgentSession(adapter: RpcAdapter, sessionId: string): Promise { + try { + await adapter.destroySession(sessionId) + } catch (error) { + console.warn(`Failed to destroy agent session ${sessionId}`, error) + } +} + +async function withAgentReadOnlySession( + adapter: RpcAdapter, + connectionId: string, + database: string | undefined, + operation: (sessionId: string) => Promise, +): Promise { + requireKnownConnection(adapter, connectionId) + const session = await adapter.createSession(connectionId, database, { readOnly: true, label: AGENT_SESSION_LABEL }) + if (session.readOnly !== true) { + await destroyAgentSession(adapter, session.sessionId) + throw new DatabaseError('READ_ONLY_SESSION', 'The database did not confirm a read-only agent session') + } + + try { + return await operation(session.sessionId) + } finally { + await destroyAgentSession(adapter, session.sessionId) + } +} + +function clampProposalWait(timeoutMs?: number): number { + if (timeoutMs === undefined) return DEFAULT_PROPOSAL_WAIT_MS + if (!Number.isFinite(timeoutMs) || timeoutMs < 0) { + throw new Error('timeoutMs must be a non-negative number') + } + return Math.min(timeoutMs, MAX_PROPOSAL_WAIT_MS) +} + export function createHandlers(adapter: RpcAdapter) { return { // ── Connection Management ───────────────────────── @@ -78,8 +184,10 @@ export function createHandlers(adapter: RpcAdapter) { }, // ── Sessions ───────────────────────────────────── - 'session.create': async ({ connectionId, database }: { connectionId: string; database?: string }) => { - return adapter.createSession(connectionId, database) + 'session.create': async ( + { connectionId, database, readOnly, label }: { connectionId: string; database?: string; readOnly?: boolean; label?: string }, + ) => { + return adapter.createSession(connectionId, database, { readOnly, label }) }, 'session.destroy': async ({ sessionId }: { sessionId: string }) => { await adapter.destroySession(sessionId) @@ -352,6 +460,124 @@ export function createHandlers(adapter: RpcAdapter) { return adapter.showSaveDialog(params) }, + // ── Agent CLI (see docs/agent-cli.md) ───────────── + 'agent.hello': () => { + return adapter.agentHello() + }, + 'agent.schema': async ({ connectionId, database }: AgentSchemaParams) => { + return withAgentReadOnlySession(adapter, connectionId, database, (sessionId) => { + return adapter.getDriver(connectionId, database).loadSchema(sessionId) + }) + }, + 'agent.query': async ({ connectionId, database, sql, queryId, params, searchPath }: AgentQueryParams) => { + if (!sql?.trim()) { + throw new Error('sql is required') + } + if (!queryId) { + throw new Error('queryId is required') + } + return withAgentReadOnlySession(adapter, connectionId, database, (sessionId) => { + return adapter.executeQuery(connectionId, sql, params, queryId, database, sessionId, searchPath) + }) + }, + 'agent.search': async (params: AgentSearchParams) => { + return withAgentReadOnlySession(adapter, params.connectionId, params.database, (sessionId) => { + return adapter.searchDatabase({ ...params, sessionId }) + }) + }, + 'agent.proposeWrite': ({ connectionId, database, sql, reason }: ProposeWriteParams) => { + requireKnownConnection(adapter, connectionId) + if (!sql?.trim()) { + throw new Error('sql is required') + } + const proposal = adapter.proposeWrite({ + connectionId, + database, + sql: sql.trim(), + reason: reason?.trim() || undefined, + }) + return { proposalId: proposal.id } + }, + 'agent.proposals.list': (params?: ProposalListParams) => { + return adapter.listProposals(params) + }, + 'agent.proposals.get': ({ proposalId }: { proposalId: string }) => { + if (!proposalId) { + throw new Error('proposalId is required') + } + const proposal = adapter.getProposal(proposalId) + if (!proposal) { + throw new Error(`Proposal not found: ${proposalId}`) + } + return proposal + }, + 'agent.proposals.wait': async ({ proposalId, timeoutMs }: { proposalId: string; timeoutMs?: number }) => { + if (!proposalId) { + throw new Error('proposalId is required') + } + return adapter.waitForProposal(proposalId, clampProposalWait(timeoutMs)) + }, + 'agent.proposals.cancel': ({ proposalId }: { proposalId: string }) => { + if (!proposalId) { + throw new Error('proposalId is required') + } + adapter.cancelProposal(proposalId) + }, + // Frontend only — the app resolves a proposal after the user ran or rejected it. + 'agent.proposals.resolve': ({ proposalId, status, result, error }: ProposalResolveParams) => { + if (!proposalId) { + throw new Error('proposalId is required') + } + if (!status) { + throw new Error('status is required') + } + return adapter.resolveProposal({ proposalId, status, result, error }) + }, + + // ── UI control ──────────────────────────────────── + 'ui.state': () => { + return adapter.getUiSnapshot() + }, + 'ui.openTable': ({ connectionId, database, schema, table, where, limit }: { + connectionId: string + database?: string + schema?: string + table: string + where?: string + limit?: number + }) => { + requireKnownConnection(adapter, connectionId) + if (!table?.trim()) { + throw new Error('table is required') + } + if (limit !== undefined && (!Number.isInteger(limit) || limit <= 0)) { + throw new Error('limit must be a positive integer') + } + assertBooleanExpression(where) + // SQLite has no schemas, so the CLI's shortened path form omits it. + adapter.sendUiCommand({ kind: 'open-table', connectionId, database, schema: schema ?? '', table: table.trim(), where, limit }) + return { ok: true } as const + }, + 'ui.openConsole': ({ connectionId, database, sql, run }: { connectionId: string; database?: string; sql?: string; run?: boolean }) => { + requireKnownConnection(adapter, connectionId) + if (run && !sql?.trim()) { + throw new Error('run requires sql') + } + // Auto-run would otherwise be a way around the approval flow — writes must go through agent.proposeWrite + if (run && !isReadOnlySql(sql ?? '')) { + throw new DatabaseError('READ_ONLY_SESSION', 'Only read-only SQL can be auto-run — submit writes via agent.proposeWrite') + } + adapter.sendUiCommand({ kind: 'open-console', connectionId, database, sql, run }) + return { ok: true } as const + }, + // Frontend only — the app publishes what the user is currently looking at. + 'ui.snapshot.set': ({ snapshot }: { snapshot: UiSnapshot }) => { + if (!snapshot || !Array.isArray(snapshot.tabs)) { + throw new Error('snapshot is required') + } + adapter.setUiSnapshot(snapshot) + }, + // ── Demo ────────────────────────────────────────────── 'demo.initialize': async () => { if (!adapter.initializeDemo) { diff --git a/src/backend-shared/rpc/rpc-handlers.ts b/src/backend-shared/rpc/rpc-handlers.ts index 78f42044..3d582377 100644 --- a/src/backend-shared/rpc/rpc-handlers.ts +++ b/src/backend-shared/rpc/rpc-handlers.ts @@ -12,6 +12,9 @@ export interface HandlerOptions { demoDbSourcePath?: string demoDbTargetPath?: string allowServerFileAccess?: boolean + /** Reported by `agent.hello` — the entry point knows these, `backend-shared` does not. */ + appVersion?: string + mode?: 'desktop' | 'web' | 'demo' } function requireAppDb(appDb: AppDatabase | undefined): AppDatabase { @@ -39,6 +42,8 @@ export function createHandlers( demoDbSourcePath: opts?.demoDbSourcePath, demoDbTargetPath: opts?.demoDbTargetPath, allowServerFileAccess: opts?.allowServerFileAccess, + appVersion: opts?.appVersion, + mode: opts?.mode, }) return { handlers: createSharedHandlers(adapter), sessionManager, adapter } } diff --git a/src/backend-shared/services/proposal-store.ts b/src/backend-shared/services/proposal-store.ts new file mode 100644 index 00000000..91a29537 --- /dev/null +++ b/src/backend-shared/services/proposal-store.ts @@ -0,0 +1,221 @@ +import type { Proposal, ProposalListParams, ProposalResolveParams, ProposalStatus, ProposeWriteParams } from '@dotaz/shared/types/rpc' + +/** A pending proposal stops being actionable after this long (docs/agent-cli.md). */ +export const PROPOSAL_TTL_MS = 60 * 60 * 1000 + +/** Upper bound on retained proposals — resolved ones are evicted oldest-first past this. */ +export const MAX_PROPOSALS = 500 + +/** Every status a `pending` proposal may transition into — `pending` itself is not one of them. */ +const RESOLVED_STATUSES: readonly ProposalStatus[] = ['approved', 'rejected', 'executed', 'failed', 'cancelled', 'expired'] + +export interface ProposalStoreOptions { + /** Lifetime of a pending proposal. */ + ttlMs?: number + /** Injectable clock — tests advance it instead of waiting out the TTL. */ + now?: () => number +} + +type Waiter = (proposal: Proposal) => void + +/** + * In-memory registry of writes the CLI submitted for user approval. + * Never persisted — proposals belong to the backend process that created them. + */ +export class ProposalStore { + private proposals = new Map() + private waiters = new Map>() + private observers = new Set<(proposal: Proposal) => void>() + private readonly ttlMs: number + private readonly now: () => number + private disposed = false + + constructor(opts?: ProposalStoreOptions) { + this.ttlMs = opts?.ttlMs ?? PROPOSAL_TTL_MS + this.now = opts?.now ?? (() => Date.now()) + } + + create(params: ProposeWriteParams): Proposal { + this.assertUsable() + this.sweep() + const proposal: Proposal = { + id: crypto.randomUUID(), + connectionId: params.connectionId, + database: params.database, + sql: params.sql, + reason: params.reason, + status: 'pending', + createdAt: this.now(), + } + this.proposals.set(proposal.id, proposal) + this.notify(proposal) + return { ...proposal } + } + + get(id: string): Proposal | null { + this.sweep() + const proposal = this.proposals.get(id) + return proposal ? { ...proposal } : null + } + + list(filter?: ProposalListParams): Proposal[] { + this.sweep() + let items = [...this.proposals.values()] + if (filter?.status) items = items.filter((p) => p.status === filter.status) + if (filter?.connectionId) items = items.filter((p) => p.connectionId === filter.connectionId) + return items.map((p) => ({ ...p })) + } + + resolve(params: ProposalResolveParams): Proposal { + this.assertUsable() + this.sweep() + if (!RESOLVED_STATUSES.includes(params.status)) { + throw new Error(`Cannot resolve a proposal to status "${params.status}"`) + } + return this.transition(this.require(params.proposalId), params.status, params.result, params.error) + } + + cancel(id: string): Proposal { + this.assertUsable() + this.sweep() + return this.transition(this.require(id), 'cancelled') + } + + /** + * Resolves as soon as status leaves 'pending', or on timeout (returns the current state). + * `async` so a bad id rejects instead of throwing synchronously — the body still registers + * its waiter before returning. + */ + async wait(id: string, timeoutMs: number): Promise { + this.assertUsable() + this.sweep() + const current = this.require(id) + if (current.status !== 'pending') return { ...current } + + return new Promise((resolve) => { + let timer: ReturnType | undefined + const waiter: Waiter = (proposal) => { + if (timer !== undefined) clearTimeout(timer) + this.removeWaiter(id, waiter) + resolve({ ...proposal }) + } + this.addWaiter(id, waiter) + timer = setTimeout(() => { + this.removeWaiter(id, waiter) + this.sweep() + const latest = this.proposals.get(id) + resolve({ ...(latest ?? current) }) + }, timeoutMs) + }) + } + + /** In-flight `wait()` calls — exposed so tests can assert nothing leaks. */ + waiterCount(): number { + let count = 0 + for (const set of this.waiters.values()) count += set.size + return count + } + + dispose(): void { + this.disposed = true + // Hand every long-poll the last known state so no caller is left hanging. + for (const [id, waiters] of [...this.waiters]) { + const proposal = this.proposals.get(id) + if (!proposal) continue + for (const waiter of [...waiters]) waiter(proposal) + } + this.waiters.clear() + this.observers.clear() + this.proposals.clear() + } + + // ── Internals ──────────────────────────────────────── + + private require(id: string): Proposal { + const proposal = this.proposals.get(id) + if (!proposal) throw new Error(`Proposal not found: ${id}`) + return proposal + } + + private transition( + proposal: Proposal, + status: ProposalStatus, + result?: Proposal['result'], + error?: string, + ): Proposal { + if (proposal.status !== 'pending') { + throw new Error(`Proposal ${proposal.id} is already ${proposal.status}`) + } + proposal.status = status + proposal.resolvedAt = this.now() + if (result !== undefined) proposal.result = result + if (error !== undefined) proposal.error = error + this.notify(proposal) + return { ...proposal } + } + + /** Lazy expiry — cheaper than a background timer and nothing keeps the process alive. */ + private sweep(): void { + const now = this.now() + const cutoff = now - this.ttlMs + for (const [id, proposal] of this.proposals) { + if (proposal.status === 'pending') { + if (proposal.createdAt > cutoff) continue + proposal.status = 'expired' + proposal.resolvedAt = now + this.notify(proposal) + continue + } + // Resolved proposals are kept only long enough for the CLI to read the outcome + if ((proposal.resolvedAt ?? proposal.createdAt) <= cutoff) this.proposals.delete(id) + } + this.evictOverflow() + } + + /** A caller submitting proposals in a loop must not grow this map without bound. */ + private evictOverflow(): void { + if (this.proposals.size <= MAX_PROPOSALS) return + // Map preserves insertion order, so the oldest resolved entries come first + for (const [id, proposal] of this.proposals) { + if (this.proposals.size <= MAX_PROPOSALS) return + if (proposal.status === 'pending') continue + this.proposals.delete(id) + } + } + + /** + * Observe every state change, including expiry. The app uses this to invalidate an + * approval banner whose proposal was resolved behind its back. + */ + onChange(observer: (proposal: Proposal) => void): () => void { + this.observers.add(observer) + return () => this.observers.delete(observer) + } + + private notify(proposal: Proposal): void { + for (const observer of [...this.observers]) observer({ ...proposal }) + const waiters = this.waiters.get(proposal.id) + if (!waiters) return + for (const waiter of [...waiters]) waiter(proposal) + } + + private addWaiter(id: string, waiter: Waiter): void { + const existing = this.waiters.get(id) + if (existing) { + existing.add(waiter) + return + } + this.waiters.set(id, new Set([waiter])) + } + + private removeWaiter(id: string, waiter: Waiter): void { + const waiters = this.waiters.get(id) + if (!waiters) return + waiters.delete(waiter) + if (waiters.size === 0) this.waiters.delete(id) + } + + private assertUsable(): void { + if (this.disposed) throw new Error('ProposalStore has been disposed') + } +} diff --git a/src/backend-shared/services/query-executor.ts b/src/backend-shared/services/query-executor.ts index 2858f159..21a4dd4c 100644 --- a/src/backend-shared/services/query-executor.ts +++ b/src/backend-shared/services/query-executor.ts @@ -1,4 +1,4 @@ -import { parseErrorPosition, splitStatements, stripLiteralsAndComments } from '@dotaz/shared/sql/statements' +import { isReadOnlySql, parseErrorPosition, splitStatements, stripLiteralsAndComments } from '@dotaz/shared/sql/statements' import { DatabaseError } from '@dotaz/shared/types/errors' import type { ExplainNode, ExplainResult, QueryResult } from '@dotaz/shared/types/query' import type { TransactionLogEntry, TransactionLogStatus } from '@dotaz/shared/types/rpc' @@ -127,6 +127,20 @@ function isTopLevelTransactionControl(statement: string): boolean { && !/^(COMMIT|ROLLBACK) PREPARED\b/.test(normalized) } +/** + * Fail fast when a read-only session is asked to run something we can't prove is a read. + * The engine is what actually guarantees read-only (see docs/agent-cli.md) — this only + * turns its late, driver-specific error into an actionable one, and fails closed. + */ +export function assertSessionWritable(driver: DatabaseDriver, sql: string, sessionId?: string): void { + if (sessionId === undefined || !driver.isSessionReadOnly(sessionId)) return + if (isReadOnlySql(sql)) return + throw new DatabaseError( + 'READ_ONLY_SESSION', + 'This session is read-only and cannot execute writes. Submit the statement with `dotaz propose` so it can be approved and run in the app.', + ) +} + export class QueryExecutor { private connectionManager: ConnectionManager private runningQueries = new Map() @@ -162,6 +176,8 @@ export class QueryExecutor { return [] } + assertSessionWritable(driver, sql, sessionId) + // Reject transaction-control statements without a session — running // BEGIN/COMMIT/ROLLBACK on the pool sends each to a different connection, // giving false transactional semantics and poisoning the pool. @@ -302,6 +318,8 @@ export class QueryExecutor { ): Promise { const driver = this.connectionManager.getDriver(connectionId, database) const driverType = driver.getDriverType() + // Only ANALYZE actually runs the statement, so classify the EXPLAIN as executed + assertSessionWritable(driver, `EXPLAIN ${analyze ? 'ANALYZE ' : ''}${sql}`, sessionId) const start = performance.now() const runWithSession = async (effectiveSessionId: string | undefined): Promise => { diff --git a/src/backend-shared/services/search-service.ts b/src/backend-shared/services/search-service.ts index bb034a1c..a72f1bf6 100644 --- a/src/backend-shared/services/search-service.ts +++ b/src/backend-shared/services/search-service.ts @@ -10,6 +10,7 @@ export interface SearchDatabaseOptions { schemaName?: string tableNames?: string[] resultsPerTable: number + sessionId?: string } export interface SearchDatabaseResult { @@ -36,7 +37,7 @@ export async function searchDatabase( isCancelled: () => boolean, ): Promise { const start = performance.now() - const schema = await driver.loadSchema() + const schema = await driver.loadSchema(opts.sessionId) // Determine which tables to search based on scope const tablesToSearch: TableInfo[] = [] @@ -94,7 +95,7 @@ export async function searchDatabase( params.push(opts.resultsPerTable) try { - const result = await driver.execute(sql, params) + const result = await driver.execute(sql, params, opts.sessionId) for (const row of result.rows) { // Find which column(s) matched const rowRecord = row as Record diff --git a/src/backend-shared/services/session-manager.ts b/src/backend-shared/services/session-manager.ts index e0d6dff9..14deee8b 100644 --- a/src/backend-shared/services/session-manager.ts +++ b/src/backend-shared/services/session-manager.ts @@ -1,4 +1,5 @@ import type { SessionInfo } from '@dotaz/shared/types/rpc' +import type { DatabaseDriver } from '../db/driver' import type { AppDatabase } from '../storage/app-db' import { DEFAULT_SETTINGS } from '../storage/app-db' import type { ConnectionManager } from './connection-manager' @@ -20,7 +21,7 @@ export class SessionManager { // Track label counters per connection for auto-naming private labelCounters = new Map() // Saved session metadata for restoration after reconnect - private pendingRestore = new Map>() + private pendingRestore = new Map>() // Track when we first observed a session with an active transaction private txFirstSeen = new Map() private idleCheckTimer: ReturnType | null = null @@ -42,7 +43,11 @@ export class SessionManager { this.stopIdleTransactionCheck() } - async createSession(connectionId: string, database?: string): Promise { + async createSession( + connectionId: string, + database?: string, + opts?: { readOnly?: boolean; label?: string }, + ): Promise { const maxSessions = this.appDb.getNumberSetting('maxSessionsPerConnection') ?? Number(DEFAULT_SETTINGS.maxSessionsPerConnection) const connSessions = this.sessions.get(connectionId) @@ -56,7 +61,11 @@ export class SessionManager { const sessionId = crypto.randomUUID() const driver = this.cm.getDriver(connectionId, database) - await driver.reserveSession(sessionId) + await driver.reserveSession(sessionId, { + readOnly: opts?.readOnly, + statementTimeoutMs: opts?.readOnly ? this.readOnlyStatementTimeoutMs() : undefined, + }) + const readOnly = await this.confirmReadOnly(driver, sessionId, opts?.readOnly) const counter = (this.labelCounters.get(connectionId) ?? 0) + 1 this.labelCounters.set(connectionId, counter) @@ -65,10 +74,11 @@ export class SessionManager { sessionId, connectionId, database, - label: `Session ${counter}`, + label: opts?.label ?? `Session ${counter}`, inTransaction: false, txAborted: false, createdAt: Date.now(), + readOnly, } if (!this.sessions.has(connectionId)) { @@ -79,6 +89,28 @@ export class SessionManager { return info } + /** + * Report read-only as the driver sees it, not as the caller asked for it. + * + * Agent handlers require `SessionInfo.readOnly === true`, so echoing the request would make + * a driver that accepts `readOnly` and ignores it look enforced. A requested-but-unconfirmed + * session is released and refused rather than handed over. + */ + private async confirmReadOnly( + driver: DatabaseDriver, + sessionId: string, + requested: boolean | undefined, + ): Promise { + const actual = driver.isSessionReadOnly(sessionId) + if (requested && !actual) { + try { + await driver.releaseSession(sessionId) + } catch { /* best effort — the session is being refused either way */ } + throw new Error('The driver did not open this session read-only') + } + return requested ? true : undefined + } + async destroySession(sessionId: string): Promise { const info = this.findSession(sessionId) if (!info) { @@ -185,7 +217,7 @@ export class SessionManager { if (connSessions.size > 0) { this.pendingRestore.set( connectionId, - Array.from(connSessions.values()).map((s) => ({ database: s.database, label: s.label })), + Array.from(connSessions.values()).map((s) => ({ database: s.database, label: s.label, readOnly: s.readOnly })), ) } } @@ -212,8 +244,13 @@ export class SessionManager { try { driver = this.cm.getDriver(connectionId, spec.database) sessionId = crypto.randomUUID() - await driver.reserveSession(sessionId) + // A restored agent session must never come back writable — or uncapped + await driver.reserveSession(sessionId, { + readOnly: spec.readOnly, + statementTimeoutMs: spec.readOnly ? this.readOnlyStatementTimeoutMs() : undefined, + }) reserved = true + const readOnly = await this.confirmReadOnly(driver, sessionId, spec.readOnly) const info: SessionInfo = { sessionId, @@ -223,6 +260,7 @@ export class SessionManager { inTransaction: false, txAborted: false, createdAt: Date.now(), + readOnly, } if (!this.sessions.has(connectionId)) { @@ -253,6 +291,12 @@ export class SessionManager { return restored } + /** Statement cap for read-only (agent) sessions — read-only is not the same as cheap. 0 means uncapped. */ + private readOnlyStatementTimeoutMs(): number | undefined { + const timeoutMs = this.appDb.getNumberSetting('queryTimeout') ?? Number(DEFAULT_SETTINGS.queryTimeout) + return timeoutMs > 0 ? timeoutMs : undefined + } + private startIdleTransactionCheck(): void { this.idleCheckTimer = setInterval(() => { this.checkIdleTransactions() diff --git a/src/backend-shared/storage/app-db.ts b/src/backend-shared/storage/app-db.ts index 43aedf33..5e2b1e1f 100644 --- a/src/backend-shared/storage/app-db.ts +++ b/src/backend-shared/storage/app-db.ts @@ -11,6 +11,7 @@ export const DEFAULT_SETTINGS: Record = { defaultPageSize: '100', defaultTxMode: 'auto-commit', theme: 'dark', + // Engine-enforced statement cap on read-only (agent) sessions; 0 means uncapped queryTimeout: '30000', maxHistoryEntries: '1000', clipboardIncludeHeaders: 'true', @@ -21,6 +22,8 @@ export const DEFAULT_SETTINGS: Record = { maxSessionsPerConnection: '5', idleTransactionTimeoutMs: '300000', 'console.queryResponseTimeout': '300000', + // Local CLI control endpoint — off until the user opts in (see docs/agent-cli.md) + 'cli.enabled': 'false', } let instance: AppDatabase | null = null diff --git a/src/backend-web/server.ts b/src/backend-web/server.ts index b487035c..20d79afe 100644 --- a/src/backend-web/server.ts +++ b/src/backend-web/server.ts @@ -3,11 +3,12 @@ // Each WebSocket connection gets its own isolated session (AppDatabase, ConnectionManager, handlers) import type { DatabaseDriver } from '@dotaz/backend-shared/db/driver' +import { dispatchRpc, invalidJsonResponse, parseRpcRequest } from '@dotaz/backend-shared/rpc/dispatch' +import type { RpcHandler, RpcHandlerLookup } from '@dotaz/backend-shared/rpc/dispatch' import { exportToStream } from '@dotaz/backend-shared/services/export-service' import type { ExportParams, ExportWriter } from '@dotaz/backend-shared/services/export-service' import { importFromStream } from '@dotaz/backend-shared/services/import-service' import type { ImportStreamParams } from '@dotaz/backend-shared/services/import-service' -import { DatabaseError } from '@dotaz/shared/types/errors' import type { ExportFormat } from '@dotaz/shared/types/export' import { resolve } from 'node:path' import { authorizeApiRequest, createWebAuthConfig, failureResponse, isAllowedHost } from './auth' @@ -358,59 +359,32 @@ const server = Bun.serve({ await maybeDestroySession(ws.data) }, async message(ws, data) { - let msg: any - try { - msg = JSON.parse(typeof data === 'string' ? data : new TextDecoder().decode(data)) - } catch { - ws.send(JSON.stringify({ type: 'response', id: 0, success: false, error: 'Invalid JSON' })) + // Bun's Buffer return type doesn't structurally match the plain Uint8Array dispatch.ts expects + const req = parseRpcRequest(typeof data === 'string' ? data : new Uint8Array(data)) + if (!req) { + ws.send(JSON.stringify(invalidJsonResponse())) return } - if (msg.type === 'request') { - // ── Web-specific stream token handlers ───────── - if (msg.method === 'stream.createExportToken') { - const { connectionId, database, ...exportParams } = msg.params - const token = createStreamToken(ws.data, 'export', connectionId, database, exportParams) - ws.send(JSON.stringify({ type: 'response', id: msg.id, success: true, payload: { token } })) - return - } - - if (msg.method === 'stream.createImportToken') { - const { connectionId, database, ...importParams } = msg.params - const token = createStreamToken(ws.data, 'import', connectionId, database, importParams) - ws.send(JSON.stringify({ type: 'response', id: msg.id, success: true, payload: { token } })) - return + // Web-specific stream token handlers — routed through the same lookup as regular handlers + const getHandler: RpcHandlerLookup = (method) => { + if (method === 'stream.createExportToken') { + return (({ connectionId, database, ...exportParams }) => { + const token = createStreamToken(ws.data, 'export', connectionId, database, exportParams) + return { token } + }) satisfies RpcHandler } - - const handler = (ws.data.handlers as any)[msg.method] - if (!handler) { - ws.send(JSON.stringify({ - type: 'response', - id: msg.id, - success: false, - error: `Unknown method: ${msg.method}`, - })) - return - } - - try { - const result = await handler(msg.params) - ws.send(JSON.stringify({ - type: 'response', - id: msg.id, - success: true, - payload: result, - })) - } catch (err: any) { - ws.send(JSON.stringify({ - type: 'response', - id: msg.id, - success: false, - error: err?.message ?? String(err), - errorCode: err instanceof DatabaseError ? err.code : undefined, - })) + if (method === 'stream.createImportToken') { + return (({ connectionId, database, ...importParams }) => { + const token = createStreamToken(ws.data, 'import', connectionId, database, importParams) + return { token } + }) satisfies RpcHandler } + return (ws.data.handlers as Record)[method] } + + const res = await dispatchRpc(req, getHandler) + ws.send(JSON.stringify(res)) }, }, }) diff --git a/src/cli-agent/README.md b/src/cli-agent/README.md new file mode 100644 index 00000000..c60762ef --- /dev/null +++ b/src/cli-agent/README.md @@ -0,0 +1,25 @@ +# @dotaz/cli + +Command-line access to a running [Dotaz](https://github.com/contember/dotaz) desktop app for humans and coding agents. + +The CLI never receives database credentials. Reads run in backend-owned read-only sessions. Writes become proposals that only the user can run in the desktop app. + +## Usage + +Install [Bun](https://bun.sh/), start the Dotaz desktop app, and enable **Settings → Allow CLI access**. Then run: + +```sh +bunx @dotaz/cli status +bunx @dotaz/cli --help +bunx @dotaz/cli rows local/users --limit 20 +bunx @dotaz/cli query local "SELECT count(*) FROM users" --json +``` + +For a persistent `dotaz` command: + +```sh +bun add --global @dotaz/cli +dotaz status +``` + +Use `dotaz --help` for command-specific options. The full contract and exit-code reference live in the [agent CLI documentation](https://github.com/contember/dotaz/blob/main/docs/agent-cli.md). diff --git a/src/cli-agent/args.ts b/src/cli-agent/args.ts new file mode 100644 index 00000000..ecd73f3f --- /dev/null +++ b/src/cli-agent/args.ts @@ -0,0 +1,183 @@ +// Minimal argv parser — pure, so command wiring stays testable without a running app. + +import { usageError } from './errors' + +export type FlagKind = + | 'boolean' + | 'string' + | 'number' + /** Repeatable string flag, e.g. `--param a --param b`. */ + | 'strings' + /** `--wait` (bare) or `--wait 30` — the value is optional. */ + | 'optionalNumber' + +export interface FlagSpec { + kind: FlagKind + /** Single-character alias, given without the leading dash. */ + alias?: string + /** Placeholder shown in `--help`, e.g. ``. */ + placeholder?: string + description: string +} + +export type FlagSpecs = Record + +export type FlagValue = boolean | string | number | string[] + +export interface ParsedArgs { + positionals: string[] + flags: Map +} + +function specFor(specs: FlagSpecs, name: string): FlagSpec { + const spec = specs[name] + if (!spec) throw usageError(`Unknown option: --${name}`) + return spec +} + +function specForAlias(specs: FlagSpecs, alias: string): { name: string; spec: FlagSpec } { + for (const [name, spec] of Object.entries(specs)) { + if (spec.alias === alias) return { name, spec } + } + throw usageError(`Unknown option: -${alias}`) +} + +function parseNumber(name: string, raw: string): number { + const value = Number(raw) + if (!Number.isFinite(value)) throw usageError(`--${name} expects a number, got "${raw}"`) + return value +} + +function parseBoolean(name: string, raw: string): boolean { + if (raw === 'true') return true + if (raw === 'false') return false + throw usageError(`--${name} is a switch and only accepts true/false, got "${raw}"`) +} + +function looksLikeNumber(token: string): boolean { + return token.length > 0 && Number.isFinite(Number(token)) +} + +export function parseArgs(argv: string[], specs: FlagSpecs): ParsedArgs { + const positionals: string[] = [] + const flags = new Map() + + const assign = (name: string, spec: FlagSpec, inlineValue: string | undefined, next: () => string | undefined): void => { + switch (spec.kind) { + case 'boolean': + flags.set(name, inlineValue === undefined ? true : parseBoolean(name, inlineValue)) + return + case 'string': { + const raw = inlineValue ?? next() + if (raw === undefined) throw usageError(`--${name} requires a value`) + flags.set(name, raw) + return + } + case 'number': { + const raw = inlineValue ?? next() + if (raw === undefined) throw usageError(`--${name} requires a value`) + flags.set(name, parseNumber(name, raw)) + return + } + case 'strings': { + const raw = inlineValue ?? next() + if (raw === undefined) throw usageError(`--${name} requires a value`) + const existing = flags.get(name) + flags.set(name, Array.isArray(existing) ? [...existing, raw] : [raw]) + return + } + case 'optionalNumber': { + if (inlineValue !== undefined) { + flags.set(name, parseNumber(name, inlineValue)) + return + } + const peeked = next() + if (peeked === undefined) { + flags.set(name, true) + return + } + flags.set(name, parseNumber(name, peeked)) + return + } + } + } + + let i = 0 + while (i < argv.length) { + const token = argv[i] + + if (token === '--') { + positionals.push(...argv.slice(i + 1)) + break + } + + if (token.startsWith('--')) { + const eq = token.indexOf('=') + const name = eq === -1 ? token.slice(2) : token.slice(2, eq) + const inlineValue = eq === -1 ? undefined : token.slice(eq + 1) + const spec = specFor(specs, name) + // `optionalNumber` only swallows the next token when it actually is a number + const consumesNext = spec.kind === 'optionalNumber' + ? (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && looksLikeNumber(argv[i + 1])) + : spec.kind !== 'boolean' + assign(name, spec, inlineValue, () => (consumesNext ? argv[++i] : undefined)) + i++ + continue + } + + if (token.startsWith('-') && token.length > 1) { + const { name, spec } = specForAlias(specs, token.slice(1)) + const consumesNext = spec.kind === 'optionalNumber' + ? (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && looksLikeNumber(argv[i + 1])) + : spec.kind !== 'boolean' + assign(name, spec, undefined, () => (consumesNext ? argv[++i] : undefined)) + i++ + continue + } + + positionals.push(token) + i++ + } + + return { positionals, flags } +} + +export function flagBool(args: ParsedArgs, name: string): boolean { + const value = args.flags.get(name) + return value === true +} + +export function flagString(args: ParsedArgs, name: string): string | undefined { + const value = args.flags.get(name) + return typeof value === 'string' ? value : undefined +} + +export function flagNumber(args: ParsedArgs, name: string): number | undefined { + const value = args.flags.get(name) + return typeof value === 'number' ? value : undefined +} + +export function flagStrings(args: ParsedArgs, name: string): string[] { + const value = args.flags.get(name) + return Array.isArray(value) ? value : [] +} + +/** `{ present: false }` when the flag was absent, `{ present: true, value }` when it carried a number. */ +export function flagOptionalNumber(args: ParsedArgs, name: string): { present: boolean; value?: number } { + const value = args.flags.get(name) + if (value === undefined) return { present: false } + if (typeof value === 'number') return { present: true, value } + return { present: true } +} + +export function requirePositiveInt(name: string, value: number | undefined): number | undefined { + if (value === undefined) return undefined + if (!Number.isInteger(value) || value <= 0) throw usageError(`--${name} must be a positive integer`) + return value +} + +export function requireNonNegativeInt(name: string, value: number | undefined): number | undefined { + if (value === undefined) return undefined + if (!Number.isInteger(value) || value < 0) throw usageError(`--${name} must be a non-negative integer`) + return value +} diff --git a/src/cli-agent/cli.ts b/src/cli-agent/cli.ts new file mode 100644 index 00000000..d0c99610 --- /dev/null +++ b/src/cli-agent/cli.ts @@ -0,0 +1,88 @@ +// Command dispatch. Kept apart from main.ts so the argument handling is testable without +// touching process state. + +import { flagBool, flagNumber, type FlagSpecs, flagString, parseArgs, type ParsedArgs } from './args' +import { DEFAULT_TIMEOUT_MS } from './client' +import { usageError } from './errors' +import { DEFAULT_MAX_BYTES, type OutputFormat, parseFormat } from './format' +import { GLOBAL_FLAGS } from './help' +import type { OutputOptions } from './output' + +export interface Invocation { + command?: string + args: ParsedArgs + specs: FlagSpecs +} + +function globalSpecFor(token: string): { name: string; spec: FlagSpecs[string] } | undefined { + if (token.startsWith('--')) { + const name = token.slice(2).split('=')[0] + const spec = GLOBAL_FLAGS[name] + return spec ? { name, spec } : undefined + } + const alias = token.slice(1) + for (const [name, spec] of Object.entries(GLOBAL_FLAGS)) { + if (spec.alias === alias) return { name, spec } + } + return undefined +} + +/** + * Pull the command name out of argv so global flags may appear before it + * (`dotaz --json ls` as well as `dotaz ls --json`). + */ +export function extractCommand(argv: string[]): { command?: string; rest: string[] } { + for (let i = 0; i < argv.length; i++) { + const token = argv[i] + if (token === '--') break + if (token.startsWith('-') && token.length > 1) { + const match = globalSpecFor(token) + // An unknown flag before the command belongs to a command we have not identified yet + if (!match) break + const needsValue = match.spec.kind !== 'boolean' && !token.includes('=') + if (needsValue) i++ + continue + } + return { command: token, rest: [...argv.slice(0, i), ...argv.slice(i + 1)] } + } + return { rest: argv } +} + +export function mergeSpecs(commandFlags: FlagSpecs): FlagSpecs { + // A command flag that shadows a global one is a bug, not a feature: the global reader still + // reads the same key, so it would silently reinterpret the command's value (a `--timeout` + // spelled in seconds became a millisecond RPC deadline). + for (const name of Object.keys(commandFlags)) { + if (name in GLOBAL_FLAGS) { + throw new Error(`Command flag --${name} collides with a global flag`) + } + } + return { ...GLOBAL_FLAGS, ...commandFlags } +} + +export function outputOptions(args: ParsedArgs): OutputOptions { + const explicit = flagString(args, 'format') + const format: OutputFormat = explicit ? parseFormat(explicit) : flagBool(args, 'json') ? 'json' : 'table' + const maxBytes = flagNumber(args, 'max-bytes') ?? DEFAULT_MAX_BYTES + if (!Number.isInteger(maxBytes) || maxBytes <= 0) throw usageError('--max-bytes must be a positive integer') + return { format, maxBytes, quiet: flagBool(args, 'quiet') } +} + +export function timeoutMs(args: ParsedArgs): number { + const value = flagNumber(args, 'timeout') + if (value === undefined) return DEFAULT_TIMEOUT_MS + if (!Number.isFinite(value) || value <= 0) throw usageError('--timeout must be a positive number of milliseconds') + return value +} + +/** Split argv into the command and its parsed arguments, without contacting the app. */ +export function parseInvocation(argv: string[], flagsFor: (command: string) => FlagSpecs | undefined): Invocation { + const { command, rest } = extractCommand(argv) + if (!command) return { args: parseArgs(rest, GLOBAL_FLAGS), specs: GLOBAL_FLAGS } + + const commandFlags = flagsFor(command) + if (!commandFlags) throw usageError(`Unknown command "${command}"`, 'Run `dotaz --help` for the command list.') + + const specs = mergeSpecs(commandFlags) + return { command, args: parseArgs(rest, specs), specs } +} diff --git a/src/cli-agent/client.ts b/src/cli-agent/client.ts new file mode 100644 index 00000000..01f9e88a --- /dev/null +++ b/src/cli-agent/client.ts @@ -0,0 +1,232 @@ +// One-shot JSON RPC over the control server's unix socket (loopback TCP on Windows). + +import type { DatabaseErrorCode } from '@dotaz/shared/types/errors' +import { randomUUID } from 'node:crypto' +import type { EndpointInfo } from './endpoint' +import { CliError, EXIT, messageOf, notRunningError, readOnlyError, START_DOTAZ_HINT, timeoutError } from './errors' + +export const DEFAULT_TIMEOUT_MS = 30_000 + +/** A cancel issued because we stopped waiting must not start a long wait of its own. */ +export const CANCEL_TIMEOUT_MS = 3_000 + +export interface RpcFailure { + message: string + errorCode?: DatabaseErrorCode +} + +/** Thrown for `success: false` envelopes so callers can branch on `errorCode`. */ +export class RpcError extends CliError { + readonly errorCode?: DatabaseErrorCode + + constructor(failure: RpcFailure) { + super(EXIT.database, failure.message) + this.name = 'RpcError' + this.errorCode = failure.errorCode + } +} + +/** The socket died under an in-flight request — the app was there and then quit. */ +export class ConnectionLostError extends CliError { + constructor(message: string) { + super(EXIT.notRunning, message, START_DOTAZ_HINT) + this.name = 'ConnectionLostError' + } +} + +export interface CallOptions { + /** Overrides the client-wide timeout for this one request. */ + timeoutMs?: number + /** Aborts the in-flight request; the caller then unwinds normally so cleanup still runs. */ + signal?: AbortSignal +} + +export interface HealthInfo { + ok: boolean + version: string + pid: number + protocol: number +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function knownErrorCode(value: unknown): DatabaseErrorCode | undefined { + return typeof value === 'string' && value.length > 0 ? toErrorCode(value) : undefined +} + +// The wire carries a plain string; narrow it without asserting. +const ERROR_CODES: readonly DatabaseErrorCode[] = [ + 'CONNECTION_REFUSED', + 'CONNECTION_TIMEOUT', + 'HOST_NOT_FOUND', + 'CONNECTION_LIMIT', + 'SSL_ERROR', + 'AUTH_FAILED', + 'DATABASE_NOT_FOUND', + 'QUERY_SYNTAX', + 'QUERY_EXECUTION', + 'TABLE_NOT_FOUND', + 'COLUMN_NOT_FOUND', + 'CONSTRAINT_UNIQUE', + 'CONSTRAINT_FK', + 'CONSTRAINT_CHECK', + 'CONSTRAINT_NOT_NULL', + 'PERMISSION_DENIED', + 'SERIALIZATION_FAILURE', + 'DEADLOCK_DETECTED', + 'TRANSACTION_ABORTED', + 'COMMIT_UNCERTAIN', + 'STATEMENT_UNCERTAIN', + 'READ_ONLY_SESSION', + 'UNKNOWN', +] + +function toErrorCode(value: string): DatabaseErrorCode | undefined { + for (const code of ERROR_CODES) { + if (code === value) return code + } + return undefined +} + +export class DotazClient { + private requestId = 0 + + constructor(readonly endpoint: EndpointInfo, private readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {} + + private url(path: string): string { + if (this.endpoint.transport === 'tcp') return `http://127.0.0.1:${this.endpoint.port}${path}` + // The host is ignored when `unix` is set, but fetch still needs a well-formed URL + return `http://localhost${path}` + } + + private init(base: RequestInit): RequestInit & { unix?: string } { + if (this.endpoint.transport === 'unix' && this.endpoint.socket) { + return { ...base, unix: this.endpoint.socket } + } + return base + } + + private async send(path: string, init: RequestInit, timeoutMs: number, external?: AbortSignal): Promise { + const timeout = AbortSignal.timeout(timeoutMs) + const signal = external ? AbortSignal.any([timeout, external]) : timeout + try { + return await fetch(this.url(path), { ...this.init(init), signal }) + } catch (err) { + // Check the caller's signal first — an interrupt and a timeout both surface as an abort + if (external?.aborted) throw timeoutError('Interrupted while waiting for Dotaz') + if (err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError')) { + throw timeoutError(`Dotaz did not respond within ${timeoutMs}ms`) + } + throw new ConnectionLostError(`Cannot reach the Dotaz control endpoint (${messageOf(err)})`) + } + } + + async health(): Promise { + const res = await this.send('/health', { method: 'GET' }, this.timeoutMs) + if (!res.ok) throw notRunningError(`Health check failed with HTTP ${res.status}`) + const body: unknown = await res.json() + if (!isRecord(body)) throw notRunningError('Health check returned an unexpected payload') + return { + ok: body.ok === true, + version: typeof body.version === 'string' ? body.version : 'unknown', + pid: typeof body.pid === 'number' ? body.pid : this.endpoint.pid, + protocol: typeof body.protocol === 'number' ? body.protocol : 0, + } + } + + /** Returns the raw payload — callers narrow it with the decoders in `decode.ts`. */ + async call(method: string, params: unknown = {}, opts: CallOptions = {}): Promise { + const id = ++this.requestId + const res = await this.send( + '/rpc', + { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-dotaz-token': this.endpoint.token }, + body: JSON.stringify({ id, method, params }), + }, + opts.timeoutMs ?? this.timeoutMs, + opts.signal, + ) + + if (res.status === 401) { + throw new CliError( + EXIT.notRunning, + 'Dotaz rejected the CLI token — the endpoint file is stale.', + `Restart Dotaz, or toggle Settings → Allow CLI access off and on. ${START_DOTAZ_HINT}`, + ) + } + + const text = await res.text() + let body: unknown + try { + body = JSON.parse(text) + } catch { + throw new CliError(EXIT.database, `Dotaz returned a non-JSON response (HTTP ${res.status}): ${text.slice(0, 200)}`) + } + + if (!isRecord(body)) { + throw new CliError(EXIT.database, `Dotaz returned an unexpected response envelope (HTTP ${res.status})`) + } + + if (body.success === true) return body.payload + + const message = typeof body.error === 'string' ? body.error : `RPC ${method} failed (HTTP ${res.status})` + const errorCode = knownErrorCode(body.errorCode) + if (errorCode === 'READ_ONLY_SESSION') throw readOnlyError(message) + throw new RpcError({ message, errorCode }) + } + + /** + * Ask the app to stop a running query. Best-effort by contract: the caller is already on an + * error path, and a failed cancel must never replace the error the user has to see. + */ + async cancelQuery(queryId: string): Promise { + try { + await this.call('query.cancel', { queryId }, { timeoutMs: CANCEL_TIMEOUT_MS }) + return true + } catch { + return false + } + } +} + +export interface QueryExecuteParams { + connectionId: string + database?: string + sql: string + params?: unknown[] +} + +/** + * Run `agent.query` under a queryId we own, so that giving up on the answer also stops the + * work: on a timeout or a Ctrl-C the query is cancelled inside the app instead of running on + * for nobody. The backend owns the read-only session and releases it when the handler settles. + */ +export async function executeQuery(client: DotazClient, params: QueryExecuteParams): Promise { + const queryId = randomUUID() + const interrupt = new AbortController() + const onInterrupt = () => { + // Detach at once so a second Ctrl-C kills the process the usual way + process.off('SIGINT', onInterrupt) + interrupt.abort() + } + process.on('SIGINT', onInterrupt) + + try { + return await client.call('agent.query', { ...params, queryId }, { signal: interrupt.signal }) + } catch (err) { + // Both the timeout and the interrupt land on EXIT.timeout — nobody is waiting for the rows + if (!(err instanceof CliError) || err.exitCode !== EXIT.timeout) throw err + const cancelled = await client.cancelQuery(queryId) + // Same error, same exit code — plus whether the query is actually stopped + throw new CliError( + err.exitCode, + `${err.message} — ${cancelled ? 'the query was cancelled in Dotaz' : `could not cancel query ${queryId}, it may still be running in Dotaz`}`, + err.hint, + ) + } finally { + process.off('SIGINT', onInterrupt) + } +} diff --git a/src/cli-agent/commands/approvals.ts b/src/cli-agent/commands/approvals.ts new file mode 100644 index 00000000..0dfe37e6 --- /dev/null +++ b/src/cli-agent/commands/approvals.ts @@ -0,0 +1,101 @@ +import { flagNumber, flagString } from '../args' +import { decodeProposal, decodeProposals, PROPOSAL_STATUSES } from '../decode' +import { EXIT, usageError } from '../errors' +import { resolveConnectionRef } from '../paths' +import { DEFAULT_WAIT_SECONDS, exitCodeForProposal, proposalOutput, waitForProposal } from '../proposals' +import type { Command, CommandContext, CommandResult } from './types' + +const SUBCOMMANDS = ['list', 'status', 'wait', 'cancel'] as const + +function sqlPreview(sql: string): string { + return sql.replace(/\s+/g, ' ').trim() +} + +async function list(ctx: CommandContext): Promise { + const status = flagString(ctx.args, 'status') + if (status !== undefined && !PROPOSAL_STATUSES.some((s) => s === status)) { + throw usageError(`Unknown --status "${status}"`, `Expected one of: ${PROPOSAL_STATUSES.join(', ')}`) + } + const connRef = flagString(ctx.args, 'conn') + const connection = connRef ? resolveConnectionRef(await ctx.app.listConnections(), connRef) : undefined + + const proposals = decodeProposals( + await ctx.client.call('agent.proposals.list', { status, connectionId: connection?.id }), + ) + const names = new Map((await ctx.app.listConnections()).map((c) => [c.id, c.name])) + const rows = proposals.map((p) => ({ + id: p.id, + status: p.status, + connection: names.get(p.connectionId) ?? p.connectionId, + createdAt: new Date(p.createdAt).toISOString(), + sql: sqlPreview(p.sql), + })) + + return { + output: { + sections: [{ + columns: [{ name: 'id' }, { name: 'status' }, { name: 'connection' }, { name: 'createdAt' }, { name: 'sql' }], + rows, + empty: '(no proposals)', + }], + json: { rows: proposals }, + }, + } +} + +export const approvalsCommand: Command = { + name: 'approvals', + flags: { + status: { kind: 'string', placeholder: '', description: 'Filter `list` by status (pending, executed, rejected, …)' }, + conn: { kind: 'string', placeholder: '', description: 'Filter `list` by connection' }, + // Named --wait like `propose --wait`; the global --timeout stays the RPC deadline + wait: { kind: 'number', placeholder: '', description: `Seconds to block in \`wait\` (default ${DEFAULT_WAIT_SECONDS})` }, + }, + help: { + usage: 'approvals list | status | wait [--wait sec] | cancel ', + summary: 'inspect and wait on write proposals', + notes: [ + '`status` and `wait` also report through the exit code: 0 executed · 3 failed · 7 pending · 8 rejected.', + ], + }, + async run(ctx) { + const [sub, id, ...extra] = ctx.args.positionals + if (!sub) throw usageError(`approvals requires a subcommand (${SUBCOMMANDS.join(', ')})`) + if (extra.length > 0) throw usageError(`approvals ${sub} takes at most one proposal id`) + + switch (sub) { + case 'list': + if (id) throw usageError('approvals list takes no proposal id') + return list(ctx) + + case 'status': { + if (!id) throw usageError('approvals status requires a proposal id') + const proposal = decodeProposal(await ctx.client.call('agent.proposals.get', { proposalId: id })) + return { output: proposalOutput(proposal), exitCode: exitCodeForProposal(proposal.status) } + } + + case 'wait': { + if (!id) throw usageError('approvals wait requires a proposal id') + const seconds = flagNumber(ctx.args, 'wait') ?? DEFAULT_WAIT_SECONDS + if (!(seconds > 0)) throw usageError('--wait must be a positive number of seconds') + const proposal = await waitForProposal(ctx.client, id, seconds * 1000) + return { output: proposalOutput(proposal), exitCode: exitCodeForProposal(proposal.status) } + } + + case 'cancel': { + if (!id) throw usageError('approvals cancel requires a proposal id') + await ctx.client.call('agent.proposals.cancel', { proposalId: id }) + return { + output: { + sections: [{ kind: 'kv', columns: [{ name: 'id' }, { name: 'status' }], rows: [{ id, status: 'cancelled' }] }], + json: { id, status: 'cancelled' }, + }, + exitCode: EXIT.ok, + } + } + + default: + throw usageError(`Unknown approvals subcommand "${sub}"`, `Expected one of: ${SUBCOMMANDS.join(', ')}`) + } + }, +} diff --git a/src/cli-agent/commands/bookmarks.ts b/src/cli-agent/commands/bookmarks.ts new file mode 100644 index 00000000..a23c9861 --- /dev/null +++ b/src/cli-agent/commands/bookmarks.ts @@ -0,0 +1,84 @@ +import type { QueryBookmark } from '@dotaz/shared/types/rpc' +import { flagString } from '../args' +import { isRecord } from '../decode' +import { databaseError, usageError } from '../errors' +import { resolveConnectionRef } from '../paths' +import type { Command } from './types' + +const SUBCOMMANDS = ['list'] as const + +function str(value: unknown, fallback = ''): string { + return typeof value === 'string' ? value : fallback +} + +function optStr(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function decodeBookmarks(payload: unknown): QueryBookmark[] { + if (!Array.isArray(payload)) throw databaseError('Dotaz returned an unexpected payload for bookmarks.list') + return payload.map((entry) => { + const obj = isRecord(entry) ? entry : {} + return { + id: str(obj.id), + connectionId: str(obj.connectionId), + database: optStr(obj.database), + name: str(obj.name), + description: str(obj.description), + sql: str(obj.sql), + createdAt: str(obj.createdAt), + updatedAt: str(obj.updatedAt), + } + }).filter((bookmark) => bookmark.id.length > 0) +} + +export const bookmarksCommand: Command = { + name: 'bookmarks', + flags: { + conn: { kind: 'string', placeholder: '', description: 'Only bookmarks of this connection' }, + search: { kind: 'string', placeholder: '', description: 'Only bookmarks whose name or SQL contains this text' }, + }, + help: { + usage: 'bookmarks list [--conn ] [--search ]', + summary: 'list the queries the user saved in the app', + notes: ['Bookmarks belong to a connection; without --conn every connection is asked in turn.'], + }, + async run(ctx) { + const [sub, ...extra] = ctx.args.positionals + if (!sub) throw usageError(`bookmarks requires a subcommand (${SUBCOMMANDS.join(', ')})`) + if (sub !== 'list') throw usageError(`Unknown bookmarks subcommand "${sub}"`, `Expected one of: ${SUBCOMMANDS.join(', ')}`) + if (extra.length > 0) throw usageError('bookmarks list takes no positional arguments') + + const connections = await ctx.app.listConnections() + const connRef = flagString(ctx.args, 'conn') + // bookmarks.list is per connection — fan out so a bare `bookmarks list` still shows everything + const targets = connRef ? [resolveConnectionRef(connections, connRef)] : connections + const search = flagString(ctx.args, 'search') + + const bookmarks: QueryBookmark[] = [] + for (const connection of targets) { + bookmarks.push(...decodeBookmarks(await ctx.client.call('bookmarks.list', { connectionId: connection.id, search }))) + } + + const names = new Map(connections.map((c) => [c.id, c.name])) + const rows = bookmarks.map((bookmark) => ({ + id: bookmark.id, + name: bookmark.name, + connection: names.get(bookmark.connectionId) ?? bookmark.connectionId, + database: bookmark.database ?? null, + updatedAt: bookmark.updatedAt, + sql: bookmark.sql, + })) + + return { + output: { + sections: [{ + columns: [{ name: 'id' }, { name: 'name' }, { name: 'connection' }, { name: 'database' }, { name: 'updatedAt' }, { name: 'sql' }], + rows, + empty: '(no bookmarks)', + }], + json: { rows: bookmarks }, + }, + } + }, +} diff --git a/src/cli-agent/commands/describe.ts b/src/cli-agent/commands/describe.ts new file mode 100644 index 00000000..04436ee1 --- /dev/null +++ b/src/cli-agent/commands/describe.ts @@ -0,0 +1,115 @@ +import { usageError } from '../errors' +import type { Section } from '../format' +import { requireTable, resolvePath } from '../paths' +import type { Command } from './types' + +export const describeCommand: Command = { + name: 'describe', + flags: {}, + help: { + usage: 'describe ', + summary: 'show columns, primary key, indexes and foreign keys in both directions', + }, + async run(ctx) { + const [path, ...extra] = ctx.args.positionals + if (!path) throw usageError('describe requires a table path', 'Example: dotaz describe prod/app/public/orders') + if (extra.length > 0) throw usageError('describe takes exactly one path') + + const resolved = requireTable(await resolvePath(path, ctx.app), path) + const schemaData = resolved.schemaData ?? await ctx.app.loadSchema(resolved.connection.id, resolved.database) + const key = `${resolved.schema}.${resolved.table}` + + const info = (schemaData.tables[resolved.schema] ?? []).find((t) => t.name === resolved.table) + const columns = schemaData.columns[key] ?? [] + const indexes = schemaData.indexes[key] ?? [] + const foreignKeys = schemaData.foreignKeys[key] ?? [] + const referencedBy = schemaData.referencingForeignKeys[key] ?? [] + const primaryKey = columns.filter((c) => c.isPrimaryKey).map((c) => c.name) + + const columnRows = columns.map((c) => ({ + column: c.name, + type: c.dataType, + nullable: c.nullable, + default: c.defaultValue, + pk: c.isPrimaryKey, + auto: c.isAutoIncrement, + maxLength: c.maxLength ?? null, + })) + const indexRows = indexes.map((i) => ({ index: i.name, columns: i.columns.join(', '), unique: i.isUnique, primary: i.isPrimary })) + const fkRows = foreignKeys.map((f) => ({ + constraint: f.name, + columns: f.columns.join(', '), + references: `${f.referencedSchema}.${f.referencedTable}(${f.referencedColumns.join(', ')})`, + onUpdate: f.onUpdate, + onDelete: f.onDelete, + })) + const refRows = referencedBy.map((r) => ({ + constraint: r.constraintName, + from: `${r.referencingSchema}.${r.referencingTable}(${r.referencingColumns.join(', ')})`, + to: r.referencedColumns.join(', '), + })) + + const sections: Section[] = [ + { + kind: 'kv', + columns: [{ name: 'connection' }, { name: 'database' }, { name: 'schema' }, { name: 'table' }, { name: 'type' }, { name: 'rowCount' }, { + name: 'primaryKey', + }], + rows: [{ + connection: resolved.connection.name, + database: resolved.database ?? null, + schema: resolved.schema, + table: resolved.table, + type: info?.type ?? 'table', + rowCount: info?.rowCount ?? null, + primaryKey: primaryKey.length > 0 ? primaryKey.join(', ') : null, + }], + }, + { + title: 'Columns', + columns: [{ name: 'column' }, { name: 'type' }, { name: 'nullable' }, { name: 'default' }, { name: 'pk' }, { name: 'auto' }, { + name: 'maxLength', + }], + rows: columnRows, + empty: '(no columns)', + }, + { + title: 'Indexes', + columns: [{ name: 'index' }, { name: 'columns' }, { name: 'unique' }, { name: 'primary' }], + rows: indexRows, + empty: '(none)', + }, + { + title: 'Foreign keys', + columns: [{ name: 'constraint' }, { name: 'columns' }, { name: 'references' }, { name: 'onUpdate' }, { name: 'onDelete' }], + rows: fkRows, + empty: '(none)', + }, + { + title: 'Referenced by', + columns: [{ name: 'constraint' }, { name: 'from' }, { name: 'to' }], + rows: refRows, + empty: '(none)', + }, + ] + + return { + output: { + sections, + json: { + connection: { id: resolved.connection.id, name: resolved.connection.name, type: resolved.connection.type }, + database: resolved.database, + schema: resolved.schema, + table: resolved.table, + tableType: info?.type ?? 'table', + rowCount: info?.rowCount, + primaryKey, + columns, + indexes, + foreignKeys, + referencedBy, + }, + }, + } + }, +} diff --git a/src/cli-agent/commands/explain.ts b/src/cli-agent/commands/explain.ts new file mode 100644 index 00000000..53f5f9c9 --- /dev/null +++ b/src/cli-agent/commands/explain.ts @@ -0,0 +1,50 @@ +import { flagBool } from '../args' +import { executeQuery } from '../client' +import { decodeQueryResults } from '../decode' +import { databaseError, usageError } from '../errors' +import { resolveScope } from '../paths' +import { firstResultError, queryResultJson, queryResultSections } from '../query-output' +import { buildExplainSql } from '../sql' +import type { Command } from './types' + +export const explainCommand: Command = { + name: 'explain', + flags: { + analyze: { kind: 'boolean', description: 'Execute the statement and report actual timings (not supported on SQLite)' }, + }, + help: { + usage: 'explain [--analyze]', + summary: 'show the query plan for a statement', + notes: ['--analyze really runs the statement — the read-only session still blocks writes.'], + }, + async run(ctx) { + const [scopePath, sql, ...extra] = ctx.args.positionals + if (!scopePath || !sql) { + throw usageError('explain requires a connection path and SQL', 'Example: dotaz explain prod "SELECT * FROM orders WHERE id = 1"') + } + if (extra.length > 0) throw usageError('explain takes exactly one SQL string — quote it') + + const scope = await resolveScope(scopePath, ctx.app) + await ctx.app.ensureConnected(scope.connection.id) + const explainSql = buildExplainSql(scope.connection.type, sql, flagBool(ctx.args, 'analyze')) + + const results = decodeQueryResults( + await executeQuery(ctx.client, { + connectionId: scope.connection.id, + database: scope.database, + sql: explainSql, + }), + ) + + const failure = firstResultError(results) + if (failure) throw databaseError(failure) + + return { + output: { + sections: queryResultSections(results), + json: { sql: explainSql, ...queryResultJson(results) }, + notes: [explainSql], + }, + } + }, +} diff --git a/src/cli-agent/commands/history.ts b/src/cli-agent/commands/history.ts new file mode 100644 index 00000000..0731ef2c --- /dev/null +++ b/src/cli-agent/commands/history.ts @@ -0,0 +1,56 @@ +import { flagNumber, flagString, requirePositiveInt } from '../args' +import { decodeHistory } from '../decode' +import { usageError } from '../errors' +import { resolveConnectionRef } from '../paths' +import type { Command } from './types' + +export const DEFAULT_HISTORY_LIMIT = 20 + +export const historyCommand: Command = { + name: 'history', + flags: { + conn: { kind: 'string', placeholder: '', description: 'Only entries for this connection' }, + limit: { kind: 'number', placeholder: '', description: `Maximum entries (default ${DEFAULT_HISTORY_LIMIT})` }, + search: { kind: 'string', placeholder: '', description: 'Only entries whose SQL contains this text' }, + }, + help: { + usage: 'history [--conn ] [--limit n]', + summary: 'list queries recently executed in the app', + }, + async run(ctx) { + if (ctx.args.positionals.length > 0) throw usageError('history takes no positional arguments') + + const connRef = flagString(ctx.args, 'conn') + const connection = connRef ? resolveConnectionRef(await ctx.app.listConnections(), connRef) : undefined + const limit = requirePositiveInt('limit', flagNumber(ctx.args, 'limit')) ?? DEFAULT_HISTORY_LIMIT + + const entries = decodeHistory( + await ctx.client.call('history.list', { connectionId: connection?.id, limit, search: flagString(ctx.args, 'search') }), + ) + + const names = new Map((await ctx.app.listConnections()).map((c) => [c.id, c.name])) + const rows = entries.map((entry) => ({ + id: entry.id, + executedAt: entry.executedAt, + connection: names.get(entry.connectionId) ?? entry.connectionId, + database: entry.database ?? null, + status: entry.status, + ms: entry.durationMs ?? null, + rows: entry.rowCount ?? null, + sql: entry.sql, + })) + + return { + output: { + sections: [{ + columns: [{ name: 'id' }, { name: 'executedAt' }, { name: 'connection' }, { name: 'status' }, { name: 'ms' }, { name: 'rows' }, { + name: 'sql', + }], + rows, + empty: '(no history)', + }], + json: { rows: entries }, + }, + } + }, +} diff --git a/src/cli-agent/commands/index.ts b/src/cli-agent/commands/index.ts new file mode 100644 index 00000000..812864e7 --- /dev/null +++ b/src/cli-agent/commands/index.ts @@ -0,0 +1,32 @@ +import { approvalsCommand } from './approvals' +import { bookmarksCommand } from './bookmarks' +import { describeCommand } from './describe' +import { explainCommand } from './explain' +import { historyCommand } from './history' +import { lsCommand } from './ls' +import { proposeCommand } from './propose' +import { queryCommand } from './query' +import { rowsCommand } from './rows' +import { searchCommand } from './search' +import { statusCommand } from './status' +import type { Command } from './types' +import { uiCommand } from './ui' + +export const COMMANDS: Record = { + status: statusCommand, + ls: lsCommand, + describe: describeCommand, + rows: rowsCommand, + query: queryCommand, + explain: explainCommand, + search: searchCommand, + history: historyCommand, + bookmarks: bookmarksCommand, + propose: proposeCommand, + approvals: approvalsCommand, + ui: uiCommand, +} + +export function findCommand(name: string): Command | undefined { + return COMMANDS[name] +} diff --git a/src/cli-agent/commands/ls.ts b/src/cli-agent/commands/ls.ts new file mode 100644 index 00000000..af1f6480 --- /dev/null +++ b/src/cli-agent/commands/ls.ts @@ -0,0 +1,107 @@ +import type { SchemaData } from '@dotaz/shared/types/database' +import type { CliConnection } from '../decode' +import { usageError } from '../errors' +import type { CommandOutput } from '../output' +import { type ResolvedPath, resolvePath } from '../paths' +import type { Command } from './types' + +function connectionsOutput(connections: CliConnection[]): CommandOutput { + const rows = connections.map((c) => ({ + name: c.name, + id: c.id, + type: c.type, + state: c.state, + readOnly: c.readOnly, + group: c.groupName ?? null, + })) + return { + sections: [{ + columns: [{ name: 'name' }, { name: 'id' }, { name: 'type' }, { name: 'state' }, { name: 'readOnly' }, { name: 'group' }], + rows, + empty: '(no connections configured)', + }], + json: { level: 'connection', rows }, + } +} + +function schemasOutput(schemaData: SchemaData): CommandOutput { + const rows = schemaData.schemas.map((s) => ({ schema: s.name, tables: (schemaData.tables[s.name] ?? []).length })) + return { + sections: [{ columns: [{ name: 'schema' }, { name: 'tables' }], rows, empty: '(no schemas)' }], + json: { level: 'schema', rows }, + } +} + +function tablesOutput(schemaData: SchemaData, schema: string): CommandOutput { + const rows = (schemaData.tables[schema] ?? []).map((t) => ({ + table: t.name, + type: t.type, + rows: t.rowCount ?? null, + })) + return { + sections: [{ columns: [{ name: 'table' }, { name: 'type' }, { name: 'rows' }], rows, empty: '(no tables)' }], + json: { level: 'table', schema, rows }, + } +} + +function columnsOutput(schemaData: SchemaData, schema: string, table: string): CommandOutput { + const rows = (schemaData.columns[`${schema}.${table}`] ?? []).map((c) => ({ + column: c.name, + type: c.dataType, + nullable: c.nullable, + pk: c.isPrimaryKey, + })) + return { + sections: [{ columns: [{ name: 'column' }, { name: 'type' }, { name: 'nullable' }, { name: 'pk' }], rows, empty: '(no columns)' }], + json: { level: 'column', schema, table, rows }, + } +} + +/** Single-schema drivers (SQLite, MySQL) skip the schema listing — it would always have one row. */ +function schemaOrTables(schemaData: SchemaData): CommandOutput { + if (schemaData.schemas.length === 1) return tablesOutput(schemaData, schemaData.schemas[0].name) + return schemasOutput(schemaData) +} + +export const lsCommand: Command = { + name: 'ls', + flags: {}, + help: { + usage: 'ls [connection[/database[/schema[/table]]]]', + summary: 'list connections, databases, schemas, tables or columns', + notes: ['With no path it lists connections; each extra segment descends one level.'], + }, + async run(ctx) { + const [path, ...extra] = ctx.args.positionals + if (extra.length > 0) throw usageError(`ls takes at most one path, got ${extra.length + 1}`) + + const resolved: ResolvedPath | null = await resolvePath(path, ctx.app) + if (!resolved) return { output: connectionsOutput(await ctx.app.listConnections()) } + + switch (resolved.level) { + case 'connection': { + if (resolved.connection.type === 'sqlite') { + return { output: schemaOrTables(await ctx.app.loadSchema(resolved.connection.id)) } + } + const databases = await ctx.app.listDatabases(resolved.connection.id) + const rows = databases.map((d) => ({ database: d.name, default: d.isDefault, active: d.isActive })) + return { + output: { + sections: [{ columns: [{ name: 'database' }, { name: 'default' }, { name: 'active' }], rows, empty: '(no databases)' }], + json: { level: 'database', rows }, + }, + } + } + case 'database': + return { output: schemaOrTables(await ctx.app.loadSchema(resolved.connection.id, resolved.database)) } + case 'schema': { + const schemaData = resolved.schemaData ?? await ctx.app.loadSchema(resolved.connection.id, resolved.database) + return { output: tablesOutput(schemaData, resolved.schema ?? '') } + } + case 'table': { + const schemaData = resolved.schemaData ?? await ctx.app.loadSchema(resolved.connection.id, resolved.database) + return { output: columnsOutput(schemaData, resolved.schema ?? '', resolved.table ?? '') } + } + } + }, +} diff --git a/src/cli-agent/commands/propose.ts b/src/cli-agent/commands/propose.ts new file mode 100644 index 00000000..08cb6bf9 --- /dev/null +++ b/src/cli-agent/commands/propose.ts @@ -0,0 +1,55 @@ +import { flagOptionalNumber, flagString } from '../args' +import { decodeProposal, decodeProposalId } from '../decode' +import { EXIT, usageError } from '../errors' +import { resolveScope } from '../paths' +import { DEFAULT_WAIT_SECONDS, exitCodeForProposal, proposalOutput, waitForProposal } from '../proposals' +import type { Command } from './types' + +export const proposeCommand: Command = { + name: 'propose', + flags: { + reason: { kind: 'string', placeholder: '', description: 'Why the write is needed — shown to the user' }, + wait: { kind: 'optionalNumber', placeholder: '[sec]', description: `Block until the user decides (default ${DEFAULT_WAIT_SECONDS}s)` }, + }, + help: { + usage: 'propose [--reason text] [--wait [sec]]', + summary: 'submit a write for the user to approve in the app', + notes: [ + 'The CLI never executes writes. Without --wait it prints the proposal id and exits 7.', + 'Exit codes: 0 executed · 3 failed during execution · 7 still pending · 8 rejected.', + ], + }, + async run(ctx) { + const [scopePath, sql, ...extra] = ctx.args.positionals + if (!scopePath || !sql) { + throw usageError('propose requires a connection path and SQL', 'Example: dotaz propose prod "UPDATE orders SET status=\'paid\' WHERE id=42"') + } + if (extra.length > 0) throw usageError('propose takes exactly one SQL string — quote it') + + const scope = await resolveScope(scopePath, ctx.app) + const proposalId = decodeProposalId( + await ctx.client.call('agent.proposeWrite', { + connectionId: scope.connection.id, + database: scope.database, + sql, + reason: flagString(ctx.args, 'reason'), + }), + ) + + const wait = flagOptionalNumber(ctx.args, 'wait') + if (!wait.present) { + const proposal = decodeProposal(await ctx.client.call('agent.proposals.get', { proposalId })) + const output = proposalOutput(proposal) + output.notes = [ + ...(output.notes ?? []), + `Waiting for the user: dotaz approvals wait ${proposalId}`, + ] + return { output, exitCode: EXIT.pending } + } + + const seconds = wait.value ?? DEFAULT_WAIT_SECONDS + if (seconds <= 0) throw usageError('--wait needs a positive number of seconds') + const proposal = await waitForProposal(ctx.client, proposalId, seconds * 1000) + return { output: proposalOutput(proposal), exitCode: exitCodeForProposal(proposal.status) } + }, +} diff --git a/src/cli-agent/commands/query.ts b/src/cli-agent/commands/query.ts new file mode 100644 index 00000000..9d87460d --- /dev/null +++ b/src/cli-agent/commands/query.ts @@ -0,0 +1,88 @@ +import { flagNumber, flagStrings, requirePositiveInt } from '../args' +import { executeQuery } from '../client' +import { decodeQueryResults } from '../decode' +import { databaseError, usageError } from '../errors' +import { resolveScope } from '../paths' +import { firstResultError, queryNotes, queryResultJson, queryResultSections } from '../query-output' +import { applySqlLimit, type SqlLimit } from '../sql' +import type { Command } from './types' + +/** Backstop for the statements we could not rewrite — the rows arrived, we just do not print them all. */ +function trimRows(rows: Record[], limit: number | undefined): Record[] { + return limit === undefined ? rows : rows.slice(0, limit) +} + +/** The agent has to be able to tell a truncated view from a truncated query. */ +function limitNote(limit: number, applied: SqlLimit): string { + if (applied.mode === 'sql') return `--limit ${limit} was pushed into the SQL — the database returned at most ${limit} row(s).` + return `--limit ${limit} trimmed the printed rows only (${applied.reason}) — the database still ran the full query.` +} + +export const queryCommand: Command = { + name: 'query', + flags: { + param: { kind: 'strings', placeholder: '', description: 'Bind a positional parameter (repeatable, values are sent as strings)' }, + limit: { kind: 'number', placeholder: '', description: 'Return at most n rows (appended to the SQL when that is safe — see below)' }, + }, + help: { + usage: 'query [--param value]… [--limit n]', + summary: 'run a read-only query in a read-only session', + notes: [ + 'Writes are rejected by the database engine and exit with code 4 — use `dotaz propose`.', + '', + '--limit n has two outcomes, and the CLI always says which one you got:', + ' · pushed into the SQL — `LIMIT n` is appended to the statement and the database returns', + ' at most n rows. This happens only for a single read-only SELECT (or WITH … SELECT)', + ' that does not already limit itself.', + ' · printed rows trimmed — the SQL is sent unchanged, the database executes the whole', + ' query and streams every row back; only the output is cut to n. This is what you get', + ' for multi-statement input, a non-SELECT, an existing LIMIT/FETCH/TOP or OFFSET, and', + ' for locking or INTO clauses.', + 'The outcome is stated on stderr, and under --json as `limit.appliedTo` ("sql" or "rows").', + ], + }, + async run(ctx) { + const [scopePath, sql, ...extra] = ctx.args.positionals + if (!scopePath || !sql) { + throw usageError('query requires a connection path and SQL', 'Example: dotaz query prod "SELECT count(*) FROM orders"') + } + if (extra.length > 0) throw usageError('query takes exactly one SQL string — quote it') + + const limit = requirePositiveInt('limit', flagNumber(ctx.args, 'limit')) + const params = flagStrings(ctx.args, 'param') + const scope = await resolveScope(scopePath, ctx.app) + await ctx.app.ensureConnected(scope.connection.id) + const applied = limit === undefined ? undefined : applySqlLimit(sql, limit, scope.connection.type) + const effectiveSql = applied?.sql ?? sql + + const results = decodeQueryResults( + await executeQuery(ctx.client, { + connectionId: scope.connection.id, + database: scope.database, + sql: effectiveSql, + params: params.length > 0 ? params : undefined, + }), + ) + + const failure = firstResultError(results) + if (failure) throw databaseError(failure) + + const trimmed = results.map((result) => ({ ...result, rows: trimRows(result.rows, limit) })) + const notes = queryNotes(results) + if (limit !== undefined && applied) { + notes.push(limitNote(limit, applied)) + if (applied.mode === 'sql') notes.push(effectiveSql) + } + + return { + output: { + sections: queryResultSections(trimmed), + json: { + ...queryResultJson(trimmed), + ...(applied ? { limit: { requested: limit, appliedTo: applied.mode, sql: effectiveSql, reason: applied.reason ?? null } } : {}), + }, + notes, + }, + } + }, +} diff --git a/src/cli-agent/commands/rows.ts b/src/cli-agent/commands/rows.ts new file mode 100644 index 00000000..d696f2ea --- /dev/null +++ b/src/cli-agent/commands/rows.ts @@ -0,0 +1,69 @@ +import { flagNumber, flagString, requireNonNegativeInt, requirePositiveInt } from '../args' +import { executeQuery } from '../client' +import { decodeQueryResults } from '../decode' +import { databaseError, usageError } from '../errors' +import { requireTable, resolvePath } from '../paths' +import { firstResultError, queryNotes, queryResultJson, queryResultSections } from '../query-output' +import { buildRowsQuery, dialectFor, parseColumnList, parseOrderBy } from '../sql' +import type { Command } from './types' + +export const DEFAULT_ROW_LIMIT = 50 + +export const rowsCommand: Command = { + name: 'rows', + flags: { + where: { kind: 'string', placeholder: '', description: 'Raw SQL boolean expression, inserted verbatim into WHERE (…)' }, + order: { kind: 'string', placeholder: '', description: 'Sort spec, e.g. "created_at:desc,id" (identifiers are quoted)' }, + limit: { kind: 'number', placeholder: '', description: `Maximum rows to read (default ${DEFAULT_ROW_LIMIT})` }, + offset: { kind: 'number', placeholder: '', description: 'Rows to skip (default 0)' }, + columns: { kind: 'string', placeholder: '', description: 'Comma-separated column list (default all columns)' }, + }, + help: { + usage: 'rows [options]', + summary: 'read rows from a table through a read-only session', + notes: [ + '--where is passed to the database verbatim — it is the one place the CLI does not quote', + 'for you. Everything else (table, schema, columns, sort keys) uses dialect quoting.', + ], + }, + async run(ctx) { + const [path, ...extra] = ctx.args.positionals + if (!path) throw usageError('rows requires a table path', 'Example: dotaz rows prod/app/public/orders --limit 20') + if (extra.length > 0) throw usageError('rows takes exactly one path') + + const resolved = requireTable(await resolvePath(path, ctx.app), path) + const limit = requirePositiveInt('limit', flagNumber(ctx.args, 'limit')) ?? DEFAULT_ROW_LIMIT + const offset = requireNonNegativeInt('offset', flagNumber(ctx.args, 'offset')) ?? 0 + + const { sql, params } = buildRowsQuery({ + schema: resolved.schema, + table: resolved.table, + dialect: dialectFor(resolved.connection.type), + columns: parseColumnList(flagString(ctx.args, 'columns')), + where: flagString(ctx.args, 'where'), + sort: parseOrderBy(flagString(ctx.args, 'order')), + limit, + offset, + }) + + const results = decodeQueryResults( + await executeQuery(ctx.client, { + connectionId: resolved.connection.id, + database: resolved.database, + sql, + params, + }), + ) + + const failure = firstResultError(results) + if (failure) throw databaseError(failure) + + return { + output: { + sections: queryResultSections(results), + json: { sql, ...queryResultJson(results) }, + notes: [sql, ...queryNotes(results)], + }, + } + }, +} diff --git a/src/cli-agent/commands/search.ts b/src/cli-agent/commands/search.ts new file mode 100644 index 00000000..ed920013 --- /dev/null +++ b/src/cli-agent/commands/search.ts @@ -0,0 +1,91 @@ +import type { SearchScope } from '@dotaz/shared/types/rpc' +import { flagNumber, flagString, flagStrings, requirePositiveInt } from '../args' +import { decodeSearchResult } from '../decode' +import { usageError } from '../errors' +import { formatCell } from '../format' +import { resolveScope } from '../paths' +import type { Command } from './types' + +/** The CLI spells the table scope in the singular; the RPC surface uses `tables`. */ +function parseScope(raw: string | undefined): SearchScope { + switch (raw ?? 'database') { + case 'database': + return 'database' + case 'schema': + return 'schema' + case 'table': + case 'tables': + return 'tables' + default: + throw usageError(`Unknown --scope "${raw}" (expected database, schema or table)`) + } +} + +export const searchCommand: Command = { + name: 'search', + flags: { + scope: { kind: 'string', placeholder: '', description: 'database (default), schema or table' }, + schema: { kind: 'string', placeholder: '', description: 'Schema to search when --scope schema' }, + table: { kind: 'strings', placeholder: '', description: 'Table to search when --scope table (repeatable)' }, + 'per-table': { kind: 'number', placeholder: '', description: 'Maximum matches per table' }, + }, + help: { + usage: 'search [--scope database|schema|table]', + summary: 'search for a value across tables', + }, + async run(ctx) { + const [scopePath, term, ...extra] = ctx.args.positionals + if (!scopePath || !term) throw usageError('search requires a connection path and a term', 'Example: dotaz search prod "acme"') + if (extra.length > 0) throw usageError('search takes exactly one term — quote it') + + const scope = parseScope(flagString(ctx.args, 'scope')) + const schemaName = flagString(ctx.args, 'schema') + const tableNames = flagStrings(ctx.args, 'table') + if (scope === 'schema' && !schemaName) throw usageError('--scope schema requires --schema ') + if (scope === 'tables' && tableNames.length === 0) throw usageError('--scope table requires at least one --table ') + + const resolved = await resolveScope(scopePath, ctx.app) + await ctx.app.ensureConnected(resolved.connection.id) + const result = decodeSearchResult( + await ctx.client.call('agent.search', { + connectionId: resolved.connection.id, + database: resolved.database, + searchTerm: term, + scope, + schemaName, + tableNames: tableNames.length > 0 ? tableNames : undefined, + resultsPerTable: requirePositiveInt('per-table', flagNumber(ctx.args, 'per-table')), + }), + ) + + const rows = result.matches.map((match) => ({ + schema: match.schema, + table: match.table, + column: match.column, + value: formatCell(match.row[match.column]), + row: formatCell(match.row), + })) + + const notes = [`${result.totalMatches} match(es) across ${result.searchedTables} table(s) in ${result.elapsedMs}ms`] + if (result.cancelled) notes.push('search was cancelled before it finished') + + return { + output: { + sections: [{ + columns: [{ name: 'schema' }, { name: 'table' }, { name: 'column' }, { name: 'value' }, { name: 'row' }], + rows, + empty: '(no matches)', + }], + json: { + // Named `rows` so the --max-bytes cap knows which array it may shorten + rows: result.matches, + searchedTables: result.searchedTables, + totalMatches: result.totalMatches, + cancelled: result.cancelled, + elapsedMs: result.elapsedMs, + }, + notes, + }, + } + }, +} diff --git a/src/cli-agent/commands/status.ts b/src/cli-agent/commands/status.ts new file mode 100644 index 00000000..b4d22ec6 --- /dev/null +++ b/src/cli-agent/commands/status.ts @@ -0,0 +1,41 @@ +import { decodeAgentHello } from '../decode' +import type { Command } from './types' + +export const statusCommand: Command = { + name: 'status', + flags: {}, + help: { + usage: 'status', + summary: 'report whether Dotaz is running with CLI access enabled', + notes: ['Exit code 5 means Dotaz is not running or CLI access is off.'], + }, + async run(ctx) { + const health = await ctx.client.health() + const hello = decodeAgentHello(await ctx.client.call('agent.hello')) + const connections = await ctx.app.listConnections() + const connected = connections.filter((c) => c.state === 'connected').length + const { endpoint, file, instances } = ctx.endpoint + // Only worth a row when it changes what the user should do — pass --instance + const others = instances.filter((i) => i.pid !== endpoint.pid).map((i) => i.pid) + + const row = { + status: 'running', + version: hello.version || health.version, + mode: hello.mode, + pid: hello.pid || health.pid, + protocol: hello.protocol, + transport: endpoint.transport === 'unix' ? `unix ${endpoint.socket}` : `tcp 127.0.0.1:${endpoint.port}`, + endpoint: file, + connections: connections.length, + connected, + ...(others.length > 0 ? { otherInstances: others.join(', ') } : {}), + } + + return { + output: { + sections: [{ kind: 'kv', columns: Object.keys(row).map((name) => ({ name })), rows: [row] }], + json: row, + }, + } + }, +} diff --git a/src/cli-agent/commands/types.ts b/src/cli-agent/commands/types.ts new file mode 100644 index 00000000..6252f795 --- /dev/null +++ b/src/cli-agent/commands/types.ts @@ -0,0 +1,28 @@ +import type { FlagSpecs, ParsedArgs } from '../args' +import type { DotazClient } from '../client' +import type { AppContext } from '../context' +import type { EndpointSource } from '../endpoint' +import type { ExitCode } from '../errors' +import type { CommandHelp } from '../help' +import type { CommandOutput, OutputOptions } from '../output' + +export interface CommandContext { + args: ParsedArgs + output: OutputOptions + client: DotazClient + app: AppContext + endpoint: EndpointSource +} + +export interface CommandResult { + output: CommandOutput + /** Defaults to 0 — set by commands that report state through the exit code. */ + exitCode?: ExitCode +} + +export interface Command { + name: string + help: CommandHelp + flags: FlagSpecs + run(ctx: CommandContext): Promise +} diff --git a/src/cli-agent/commands/ui.ts b/src/cli-agent/commands/ui.ts new file mode 100644 index 00000000..a1a614b1 --- /dev/null +++ b/src/cli-agent/commands/ui.ts @@ -0,0 +1,106 @@ +import { flagBool, flagNumber, flagString, requirePositiveInt } from '../args' +import { decodeUiSnapshot } from '../decode' +import { usageError } from '../errors' +import { requireTable, resolvePath, resolveScope } from '../paths' +import type { Command, CommandContext, CommandResult } from './types' + +const SUBCOMMANDS = ['state', 'open', 'console'] as const + +async function state(ctx: CommandContext): Promise { + const snapshot = decodeUiSnapshot(await ctx.client.call('ui.state')) + const names = new Map((await ctx.app.listConnections()).map((c) => [c.id, c.name])) + const rows = snapshot.tabs.map((tab) => ({ + active: tab.id === snapshot.activeTabId, + type: tab.type, + title: tab.title, + connection: names.get(tab.connectionId) ?? tab.connectionId, + database: tab.database ?? null, + schema: tab.schema ?? null, + table: tab.table ?? null, + sql: tab.sql ?? null, + })) + + return { + output: { + sections: [{ + columns: [{ name: 'active' }, { name: 'type' }, { name: 'title' }, { name: 'connection' }, { name: 'database' }, { name: 'schema' }, { + name: 'table', + }, { name: 'sql' }], + rows, + empty: '(no open tabs)', + }], + json: snapshot, + notes: [ + `active connection: ${snapshot.activeConnectionId ? names.get(snapshot.activeConnectionId) ?? snapshot.activeConnectionId : 'none'}`, + ], + }, + } +} + +function ok(action: string, detail: Record): CommandResult { + const row = { action, ...detail } + return { + output: { + sections: [{ kind: 'kv', columns: Object.keys(row).map((name) => ({ name })), rows: [row] }], + json: { ok: true, ...row }, + }, + } +} + +export const uiCommand: Command = { + name: 'ui', + flags: { + where: { kind: 'string', placeholder: '', description: 'Filter applied to the opened grid (raw SQL fragment)' }, + limit: { kind: 'number', placeholder: '', description: 'Page size for the opened grid' }, + sql: { kind: 'string', placeholder: '', description: 'Prefill the SQL console with this statement' }, + run: { kind: 'boolean', description: 'Also run the prefilled SQL — read-only statements only' }, + }, + help: { + usage: 'ui state | open [--where sql] | console [--sql text] [--run]', + summary: 'read and drive what the user has open in the app', + notes: ['`ui console --run` refuses non-read-only SQL and exits 4 — use `dotaz propose` for writes.'], + }, + async run(ctx) { + const [sub, target, ...extra] = ctx.args.positionals + if (!sub) throw usageError(`ui requires a subcommand (${SUBCOMMANDS.join(', ')})`) + if (extra.length > 0) throw usageError(`ui ${sub} takes at most one argument`) + + switch (sub) { + case 'state': + if (target) throw usageError('ui state takes no arguments') + return state(ctx) + + case 'open': { + if (!target) throw usageError('ui open requires a table path', 'Example: dotaz ui open prod/app/public/orders') + const resolved = requireTable(await resolvePath(target, ctx.app), target) + await ctx.client.call('ui.openTable', { + connectionId: resolved.connection.id, + database: resolved.database, + schema: resolved.schema, + table: resolved.table, + where: flagString(ctx.args, 'where'), + limit: requirePositiveInt('limit', flagNumber(ctx.args, 'limit')), + }) + return ok('open-table', { + connection: resolved.connection.name, + database: resolved.database ?? null, + schema: resolved.schema, + table: resolved.table, + }) + } + + case 'console': { + if (!target) throw usageError('ui console requires a connection path', 'Example: dotaz ui console prod --sql "SELECT 1"') + const scope = await resolveScope(target, ctx.app) + const sql = flagString(ctx.args, 'sql') + const run = flagBool(ctx.args, 'run') + if (run && !sql) throw usageError('--run needs --sql') + await ctx.client.call('ui.openConsole', { connectionId: scope.connection.id, database: scope.database, sql, run }) + return ok('open-console', { connection: scope.connection.name, database: scope.database ?? null, run }) + } + + default: + throw usageError(`Unknown ui subcommand "${sub}"`, `Expected one of: ${SUBCOMMANDS.join(', ')}`) + } + }, +} diff --git a/src/cli-agent/context.ts b/src/cli-agent/context.ts new file mode 100644 index 00000000..44e5c6dc Binary files /dev/null and b/src/cli-agent/context.ts differ diff --git a/src/cli-agent/decode.ts b/src/cli-agent/decode.ts new file mode 100644 index 00000000..e1736462 --- /dev/null +++ b/src/cli-agent/decode.ts @@ -0,0 +1,348 @@ +// RPC payloads arrive as untyped JSON. Everything the CLI consumes is narrowed here so no +// command has to guess at a shape (and so nothing needs an `as` cast). + +import type { ConnectionState, ConnectionType } from '@dotaz/shared/types/connection' +import type { + ColumnInfo, + DatabaseInfo, + ForeignKeyInfo, + IndexInfo, + ReferencingForeignKeyInfo, + SchemaData, + SchemaInfo, + TableInfo, +} from '@dotaz/shared/types/database' +import { DatabaseDataType } from '@dotaz/shared/types/database' +import type { QueryHistoryEntry, QueryHistoryStatus, QueryResult, QueryResultColumn } from '@dotaz/shared/types/query' +import type { + AgentHelloResult, + Proposal, + ProposalStatus, + SearchDatabaseResult, + SearchMatch, + UiSnapshot, + UiTabSnapshot, +} from '@dotaz/shared/types/rpc' +import { CliError, EXIT } from './errors' + +// ── primitives ───────────────────────────────────────────── + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function rec(value: unknown): Record { + return isRecord(value) ? value : {} +} + +function arr(value: unknown): unknown[] { + return Array.isArray(value) ? value : [] +} + +function str(value: unknown, fallback = ''): string { + return typeof value === 'string' ? value : fallback +} + +function optStr(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function num(value: unknown, fallback = 0): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback +} + +function optNum(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function bool(value: unknown, fallback = false): boolean { + return typeof value === 'boolean' ? value : fallback +} + +function strings(value: unknown): string[] { + return arr(value).filter((v): v is string => typeof v === 'string') +} + +/** Narrow a wire string to a union member without asserting. */ +function oneOf(value: unknown, allowed: readonly T[], fallback: T): T { + for (const candidate of allowed) { + if (candidate === value) return candidate + } + return fallback +} + +function decodeError(what: string): CliError { + return new CliError(EXIT.database, `Dotaz returned an unexpected payload for ${what}`) +} + +// ── connections ──────────────────────────────────────────── + +const CONNECTION_TYPES: readonly ConnectionType[] = ['postgresql', 'sqlite', 'mysql'] +const CONNECTION_STATES: readonly ConnectionState[] = ['disconnected', 'connecting', 'connected', 'reconnecting', 'error'] + +/** Only the parts of `ConnectionInfo` the CLI needs — credentials never leave the app. */ +export interface CliConnection { + id: string + name: string + type: ConnectionType + state: ConnectionState + readOnly: boolean + groupName?: string + error?: string + /** SQLite file path / server database name, used for display only. */ + defaultDatabase?: string +} + +export function decodeConnections(payload: unknown): CliConnection[] { + if (!Array.isArray(payload)) throw decodeError('connections.list') + return payload.map((entry) => { + const obj = rec(entry) + const config = rec(obj.config) + return { + id: str(obj.id), + name: str(obj.name), + type: oneOf(config.type, CONNECTION_TYPES, 'postgresql'), + state: oneOf(obj.state, CONNECTION_STATES, 'disconnected'), + readOnly: bool(obj.readOnly), + groupName: optStr(obj.groupName), + error: optStr(obj.error), + defaultDatabase: optStr(config.database) ?? optStr(config.path), + } + }).filter((c) => c.id.length > 0) +} + +export function decodeDatabases(payload: unknown): DatabaseInfo[] { + if (!Array.isArray(payload)) throw decodeError('databases.list') + return payload.map((entry) => { + const obj = rec(entry) + return { name: str(obj.name), isDefault: bool(obj.isDefault), isActive: bool(obj.isActive) } + }).filter((d) => d.name.length > 0) +} + +// ── schema ───────────────────────────────────────────────── + +const TABLE_TYPES: readonly TableInfo['type'][] = ['table', 'view', 'materialized-view'] + +function decodeDataType(value: unknown): DatabaseDataType { + for (const candidate of Object.values(DatabaseDataType)) { + if (candidate === value) return candidate + } + return DatabaseDataType.Unknown +} + +function decodeTable(value: unknown): TableInfo { + const obj = rec(value) + return { + schema: str(obj.schema), + name: str(obj.name), + type: oneOf(obj.type, TABLE_TYPES, 'table'), + rowCount: optNum(obj.rowCount), + } +} + +function decodeColumn(value: unknown): ColumnInfo { + const obj = rec(value) + return { + name: str(obj.name), + dataType: decodeDataType(obj.dataType), + nullable: bool(obj.nullable, true), + defaultValue: typeof obj.defaultValue === 'string' ? obj.defaultValue : null, + isPrimaryKey: bool(obj.isPrimaryKey), + isAutoIncrement: bool(obj.isAutoIncrement), + maxLength: optNum(obj.maxLength), + } +} + +function decodeIndex(value: unknown): IndexInfo { + const obj = rec(value) + return { name: str(obj.name), columns: strings(obj.columns), isUnique: bool(obj.isUnique), isPrimary: bool(obj.isPrimary) } +} + +function decodeForeignKey(value: unknown): ForeignKeyInfo { + const obj = rec(value) + return { + name: str(obj.name), + columns: strings(obj.columns), + referencedSchema: str(obj.referencedSchema), + referencedTable: str(obj.referencedTable), + referencedColumns: strings(obj.referencedColumns), + onUpdate: str(obj.onUpdate), + onDelete: str(obj.onDelete), + } +} + +function decodeReferencingForeignKey(value: unknown): ReferencingForeignKeyInfo { + const obj = rec(value) + return { + constraintName: str(obj.constraintName), + referencingSchema: str(obj.referencingSchema), + referencingTable: str(obj.referencingTable), + referencingColumns: strings(obj.referencingColumns), + referencedColumns: strings(obj.referencedColumns), + } +} + +function decodeMap(value: unknown, decode: (v: unknown) => T): Record { + const out: Record = {} + for (const [key, entries] of Object.entries(rec(value))) { + out[key] = arr(entries).map(decode) + } + return out +} + +export function decodeSchemaData(payload: unknown): SchemaData { + if (!isRecord(payload)) throw decodeError('agent.schema') + const schemas: SchemaInfo[] = arr(payload.schemas) + .map((entry) => ({ name: str(rec(entry).name) })) + .filter((s) => s.name.length > 0) + return { + schemas, + tables: decodeMap(payload.tables, decodeTable), + columns: decodeMap(payload.columns, decodeColumn), + indexes: decodeMap(payload.indexes, decodeIndex), + foreignKeys: decodeMap(payload.foreignKeys, decodeForeignKey), + referencingForeignKeys: decodeMap(payload.referencingForeignKeys, decodeReferencingForeignKey), + } +} + +// ── queries ──────────────────────────────────────────────── + +function decodeResultColumn(value: unknown): QueryResultColumn { + const obj = rec(value) + return { name: str(obj.name), dataType: decodeDataType(obj.dataType) } +} + +function decodeQueryResult(value: unknown): QueryResult { + const obj = rec(value) + return { + columns: arr(obj.columns).map(decodeResultColumn), + rows: arr(obj.rows).map((row) => rec(row)), + rowCount: num(obj.rowCount), + affectedRows: optNum(obj.affectedRows), + durationMs: num(obj.durationMs), + error: optStr(obj.error), + } +} + +export function decodeQueryResults(payload: unknown): QueryResult[] { + if (Array.isArray(payload)) return payload.map(decodeQueryResult) + if (isRecord(payload)) return [decodeQueryResult(payload)] + throw decodeError('agent.query') +} + +// ── history & search ─────────────────────────────────────── + +const HISTORY_STATUSES: readonly QueryHistoryStatus[] = ['success', 'error'] + +export function decodeHistory(payload: unknown): QueryHistoryEntry[] { + if (!Array.isArray(payload)) throw decodeError('history.list') + return payload.map((entry) => { + const obj = rec(entry) + return { + id: num(obj.id), + connectionId: str(obj.connectionId), + database: optStr(obj.database), + sql: str(obj.sql), + status: oneOf(obj.status, HISTORY_STATUSES, 'success'), + durationMs: optNum(obj.durationMs), + rowCount: optNum(obj.rowCount), + errorMessage: optStr(obj.errorMessage), + executedAt: str(obj.executedAt), + } + }) +} + +function decodeSearchMatch(value: unknown): SearchMatch { + const obj = rec(value) + return { schema: str(obj.schema), table: str(obj.table), column: str(obj.column), row: rec(obj.row) } +} + +export function decodeSearchResult(payload: unknown): SearchDatabaseResult { + if (!isRecord(payload)) throw decodeError('agent.search') + return { + matches: arr(payload.matches).map(decodeSearchMatch), + searchedTables: num(payload.searchedTables), + totalMatches: num(payload.totalMatches), + cancelled: bool(payload.cancelled), + elapsedMs: num(payload.elapsedMs), + } +} + +// ── agent / proposals ────────────────────────────────────── + +export const PROPOSAL_STATUSES: readonly ProposalStatus[] = [ + 'pending', + 'approved', + 'rejected', + 'executed', + 'failed', + 'cancelled', + 'expired', +] + +export function decodeProposal(payload: unknown): Proposal { + if (!isRecord(payload)) throw decodeError('agent.proposals') + const result = isRecord(payload.result) + ? { affectedRows: optNum(payload.result.affectedRows), statements: optNum(payload.result.statements) } + : undefined + return { + id: str(payload.id), + connectionId: str(payload.connectionId), + database: optStr(payload.database), + sql: str(payload.sql), + reason: optStr(payload.reason), + status: oneOf(payload.status, PROPOSAL_STATUSES, 'pending'), + createdAt: num(payload.createdAt), + resolvedAt: optNum(payload.resolvedAt), + result, + error: optStr(payload.error), + } +} + +export function decodeProposals(payload: unknown): Proposal[] { + if (!Array.isArray(payload)) throw decodeError('agent.proposals.list') + return payload.map(decodeProposal) +} + +export function decodeProposalId(payload: unknown): string { + if (!isRecord(payload)) throw decodeError('agent.proposeWrite') + const id = str(payload.proposalId) + if (!id) throw decodeError('agent.proposeWrite') + return id +} + +export function decodeAgentHello(payload: unknown): AgentHelloResult { + if (!isRecord(payload)) throw decodeError('agent.hello') + return { + version: str(payload.version, 'unknown'), + mode: oneOf(payload.mode, ['desktop', 'web', 'demo'] as const, 'desktop'), + pid: num(payload.pid), + protocol: num(payload.protocol), + } +} + +// ── ui ───────────────────────────────────────────────────── + +function decodeUiTab(value: unknown): UiTabSnapshot { + const obj = rec(value) + return { + id: str(obj.id), + type: str(obj.type), + title: str(obj.title), + connectionId: str(obj.connectionId), + database: optStr(obj.database), + schema: optStr(obj.schema), + table: optStr(obj.table), + sql: optStr(obj.sql), + } +} + +export function decodeUiSnapshot(payload: unknown): UiSnapshot { + if (!isRecord(payload)) throw decodeError('ui.state') + return { + tabs: arr(payload.tabs).map(decodeUiTab), + activeTabId: optStr(payload.activeTabId) ?? null, + activeConnectionId: optStr(payload.activeConnectionId) ?? null, + updatedAt: num(payload.updatedAt), + } +} diff --git a/src/cli-agent/endpoint.ts b/src/cli-agent/endpoint.ts new file mode 100644 index 00000000..eccba9f4 --- /dev/null +++ b/src/cli-agent/endpoint.ts @@ -0,0 +1,231 @@ +// Endpoint discovery — find the control server the running desktop app published. +// See docs/agent-cli.md § Endpoint discovery. + +import { readdirSync, readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { notRunningError, usageError } from './errors' + +/** Must match `app.identifier` in electrobun.config.ts — it is part of the userData path. */ +export const APP_IDENTIFIER = 'dotaz.electrobun.dev' + +// Layout must match src/backend-desktop/control-server.ts — one file per running instance. +export const CLI_DIR_NAME = 'cli' +const ENDPOINT_FILE_PATTERN = /^endpoint-(\d+)\.json$/ + +export interface EndpointInfo { + pid: number + transport: 'unix' | 'tcp' + socket: string | null + port: number | null + token: string + version: string + protocol: number + startedAt: number +} + +export interface EndpointRef { + file: string + endpoint: EndpointInfo +} + +export interface EndpointSource extends EndpointRef { + /** Every live instance, newest first — `status` reports when more than one is running. */ + instances: EndpointInfo[] +} + +/** Mirrors Electrobun's `Utils.paths.appData` so we land in the same userData directory. */ +export function appDataDir(platform: string, env: Record, home: string): string { + switch (platform) { + case 'darwin': + return join(home, 'Library', 'Application Support') + case 'win32': + return env.LOCALAPPDATA || join(home, 'AppData', 'Local') + default: + return env.XDG_DATA_HOME || join(home, '.local', 'share') + } +} + +/** `Utils.paths.userData` is `//`, and the CLI does not know the channel. */ +export function userDataRoot(platform: string, env: Record, home: string): string { + return join(appDataDir(platform, env, home), APP_IDENTIFIER) +} + +export function parseEndpointFile(raw: string): EndpointInfo | null { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return null + } + if (typeof parsed !== 'object' || parsed === null) return null + const obj: Record = { ...parsed } + + const pid = obj.pid + const token = obj.token + const transport = obj.transport + if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return null + if (typeof token !== 'string' || token.length === 0) return null + if (transport !== 'unix' && transport !== 'tcp') return null + + const socket = typeof obj.socket === 'string' ? obj.socket : null + const port = typeof obj.port === 'number' ? obj.port : null + if (transport === 'unix' && !socket) return null + if (transport === 'tcp' && (port === null || port <= 0)) return null + + return { + pid, + transport, + socket, + port, + token, + version: typeof obj.version === 'string' ? obj.version : 'unknown', + protocol: typeof obj.protocol === 'number' ? obj.protocol : 0, + startedAt: typeof obj.startedAt === 'number' ? obj.startedAt : 0, + } +} + +export function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (err) { + // EPERM means the process exists but belongs to another user — still alive + return err instanceof Error && 'code' in err && err.code === 'EPERM' + } +} + +function readDirSafely(dir: string): string[] { + try { + return readdirSync(dir) + } catch { + return [] + } +} + +/** Every `/cli/endpoint-.json` under the userData root — one file per instance. */ +export function candidateEndpointFiles(root: string): string[] { + const files: string[] = [] + for (const channel of readDirSafely(root)) { + const dir = join(root, channel, CLI_DIR_NAME) + for (const entry of readDirSafely(dir)) { + if (ENDPOINT_FILE_PATTERN.test(entry)) files.push(join(dir, entry)) + } + } + return files +} + +export interface DiscoverOptions { + /** `--endpoint `, then `DOTAZ_ENDPOINT`. */ + explicitFile?: string + /** `--instance ` — pick one specific running instance. */ + instancePid?: number + platform?: string + env?: Record + home?: string + pidAlive?: (pid: number) => boolean + readFile?: (file: string) => string + listCandidates?: (root: string) => string[] +} + +interface Scan { + /** Live instances, newest first. */ + live: EndpointRef[] + /** Human-readable reason per rejected file, for the exit-5 message. */ + stale: string[] +} + +function scanEndpointFiles(files: string[], read: (file: string) => string, pidAlive: (pid: number) => boolean): Scan { + const live: EndpointRef[] = [] + const stale: string[] = [] + + for (const file of files) { + let raw: string + try { + raw = read(file) + } catch { + stale.push(`${file} (unreadable)`) + continue + } + const endpoint = parseEndpointFile(raw) + if (!endpoint) { + stale.push(`${file} (malformed)`) + continue + } + if (!pidAlive(endpoint.pid)) { + stale.push(`${file} (pid ${endpoint.pid} is not running)`) + continue + } + live.push({ file, endpoint }) + } + + live.sort((a, b) => b.endpoint.startedAt - a.endpoint.startedAt) + return { live, stale } +} + +interface Resolved extends DiscoverOptions { + platform: string + env: Record + home: string + pidAlive: (pid: number) => boolean + readFile: (file: string) => string + listCandidates: (root: string) => string[] +} + +function resolveOptions(opts: DiscoverOptions): Resolved { + return { + ...opts, + platform: opts.platform ?? process.platform, + env: opts.env ?? process.env, + home: opts.home ?? homedir(), + pidAlive: opts.pidAlive ?? isPidAlive, + readFile: opts.readFile ?? ((file: string) => readFileSync(file, 'utf8')), + listCandidates: opts.listCandidates ?? candidateEndpointFiles, + } +} + +/** Every instance currently serving a control endpoint, newest first. */ +export function listLiveInstances(opts: DiscoverOptions = {}): EndpointRef[] { + const resolved = resolveOptions(opts) + const root = userDataRoot(resolved.platform, resolved.env, resolved.home) + return scanEndpointFiles(resolved.listCandidates(root), resolved.readFile, resolved.pidAlive).live +} + +/** + * Resolve the endpoint to talk to. Throws exit-5 for every "cannot reach the app" case, + * so callers never have to distinguish missing file from dead pid. + */ +export function discoverEndpoint(opts: DiscoverOptions = {}): EndpointSource { + const resolved = resolveOptions(opts) + const instancePid = resolved.instancePid + + if (instancePid !== undefined) { + if (opts.explicitFile !== undefined) throw usageError('--instance and --endpoint cannot be combined') + if (!Number.isInteger(instancePid) || instancePid <= 0) throw usageError('--instance must be a process id') + } + + // A flag beats the environment, so --instance ignores DOTAZ_ENDPOINT + const explicit = opts.explicitFile ?? (instancePid === undefined ? resolved.env.DOTAZ_ENDPOINT : undefined) + if (explicit) { + const { live, stale } = scanEndpointFiles([explicit], resolved.readFile, resolved.pidAlive) + const chosen = live[0] + if (!chosen) throw notRunningError(`Dotaz is not running — no live control endpoint. Checked: ${stale.join(', ')}`) + return { ...chosen, instances: [chosen.endpoint] } + } + + const root = userDataRoot(resolved.platform, resolved.env, resolved.home) + const files = resolved.listCandidates(root) + if (files.length === 0) throw notRunningError(`No Dotaz control endpoint found under ${root}`) + + const { live, stale } = scanEndpointFiles(files, resolved.readFile, resolved.pidAlive) + if (live.length === 0) throw notRunningError(`Dotaz is not running — no live control endpoint. Checked: ${stale.join(', ')}`) + + const instances = live.map((ref) => ref.endpoint) + if (instancePid === undefined) return { ...live[0], instances } + + const match = live.find((ref) => ref.endpoint.pid === instancePid) + if (!match) { + throw usageError(`No live Dotaz instance with pid ${instancePid}`, `Live instances: ${instances.map((i) => i.pid).join(', ')}`) + } + return { ...match, instances } +} diff --git a/src/cli-agent/errors.ts b/src/cli-agent/errors.ts new file mode 100644 index 00000000..9ed5abcc --- /dev/null +++ b/src/cli-agent/errors.ts @@ -0,0 +1,56 @@ +// Exit codes are part of the CLI contract — see docs/agent-cli.md. + +export const EXIT = { + ok: 0, + /** Unexpected internal failure — not part of the documented contract. */ + internal: 1, + usage: 2, + database: 3, + readOnly: 4, + notRunning: 5, + timeout: 6, + pending: 7, + rejected: 8, +} as const + +export type ExitCode = (typeof EXIT)[keyof typeof EXIT] + +export class CliError extends Error { + readonly exitCode: ExitCode + readonly hint?: string + + constructor(exitCode: ExitCode, message: string, hint?: string) { + super(message) + this.name = 'CliError' + this.exitCode = exitCode + this.hint = hint + } +} + +export function usageError(message: string, hint?: string): CliError { + return new CliError(EXIT.usage, message, hint) +} + +export const START_DOTAZ_HINT = 'Start Dotaz and enable Settings → Allow CLI access (or set DOTAZ_CLI=1 before launching).' + +export function notRunningError(message: string): CliError { + return new CliError(EXIT.notRunning, message, START_DOTAZ_HINT) +} + +export function databaseError(message: string, hint?: string): CliError { + return new CliError(EXIT.database, message, hint) +} + +export function timeoutError(message: string): CliError { + return new CliError(EXIT.timeout, message) +} + +/** Read-only violations always point at the one supported way to write. */ +export function readOnlyError(message: string): CliError { + return new CliError(EXIT.readOnly, message, 'The CLI session is read-only. Submit the statement with `dotaz propose ""` instead.') +} + +export function messageOf(err: unknown): string { + if (err instanceof Error) return err.message + return String(err) +} diff --git a/src/cli-agent/format.ts b/src/cli-agent/format.ts new file mode 100644 index 00000000..9e26148d --- /dev/null +++ b/src/cli-agent/format.ts @@ -0,0 +1,258 @@ +// Output rendering. Pure — main.ts owns the writing, so every rule here is testable. + +import { DatabaseDataType } from '@dotaz/shared/types/database' +import { usageError } from './errors' + +export const OUTPUT_FORMATS = ['table', 'json', 'jsonl', 'csv', 'md'] as const +export type OutputFormat = (typeof OUTPUT_FORMATS)[number] + +export const DEFAULT_MAX_BYTES = 65536 + +/** Wide cells are elided in the human formats only — csv/json/jsonl always carry the full value. */ +export const MAX_CELL_WIDTH = 200 + +export function parseFormat(value: string): OutputFormat { + for (const format of OUTPUT_FORMATS) { + if (format === value) return format + } + throw usageError(`Unknown --format "${value}" (expected ${OUTPUT_FORMATS.join(', ')})`) +} + +export interface RenderColumn { + name: string + dataType?: DatabaseDataType +} + +export interface Section { + title?: string + columns: RenderColumn[] + rows: Record[] + /** `kv` renders `key: value` lines instead of a grid — used for single-record output. */ + kind?: 'grid' | 'kv' + /** Printed instead of the grid when there are no rows. */ + empty?: string +} + +export interface RenderResult { + stdout: string + /** Diagnostics — truncation notices for the machine formats, which must not carry a footer. */ + stderr: string + truncated: boolean + shown: number + total: number +} + +/** The exact wording is part of the CLI contract (docs/agent-cli.md § Output rules). */ +export function truncationLine(shown: number, total: number): string { + return `rows: ${shown}/${total} (truncated, use --limit)` +} + +function byteLength(text: string): number { + return Buffer.byteLength(text, 'utf8') +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function formatByteSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +/** Byte length of a value that is (or decodes to) binary data, or null when it is not binary. */ +export function binaryByteLength(value: unknown, dataType?: DatabaseDataType): number | null { + if (value instanceof Uint8Array) return value.byteLength + if (value instanceof ArrayBuffer) return value.byteLength + // Bun/Node Buffers cross JSON as { type: 'Buffer', data: number[] } + if (isRecord(value) && value.type === 'Buffer' && Array.isArray(value.data)) return value.data.length + if (dataType === DatabaseDataType.Binary) { + // PostgreSQL bytea arrives as a `\x…` hex string over JSON + if (typeof value === 'string' && value.startsWith('\\x')) return Math.floor((value.length - 2) / 2) + if (Array.isArray(value) && value.every((v) => typeof v === 'number')) return value.length + } + return null +} + +function escapeControlChars(text: string): string { + return text.replace(/\r\n|\n|\r/g, '\\n').replace(/\t/g, '\\t') +} + +function elide(text: string): string { + if (text.length <= MAX_CELL_WIDTH) return text + return `${text.slice(0, MAX_CELL_WIDTH - 1)}…` +} + +/** + * Human-readable rendering of one cell. + * SQL NULL prints as bare `NULL`; the *string* "NULL" prints quoted, so the two never collide. + */ +export function formatCell(value: unknown, dataType?: DatabaseDataType): string { + if (value === null || value === undefined) return 'NULL' + + const bytes = binaryByteLength(value, dataType) + if (bytes !== null) return `` + + if (typeof value === 'string') { + const text = escapeControlChars(value) + return elide(text.toUpperCase() === 'NULL' ? `"${text}"` : text) + } + if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean') return String(value) + if (value instanceof Date) return value.toISOString() + return elide(escapeControlChars(JSON.stringify(value) ?? String(value))) +} + +/** CSV keeps raw values; NULL becomes an empty field, per the usual CSV convention. */ +function csvCell(value: unknown, dataType?: DatabaseDataType): string { + if (value === null || value === undefined) return '' + const bytes = binaryByteLength(value, dataType) + if (bytes !== null) return `` + const text = typeof value === 'string' ? value : typeof value === 'object' ? JSON.stringify(value) ?? '' : String(value) + if (/[",\r\n]/.test(text)) return `"${text.replace(/"/g, '""')}"` + return text +} + +function mdCell(value: unknown, dataType?: DatabaseDataType): string { + return formatCell(value, dataType).replace(/\|/g, '\\|') +} + +function padTo(text: string, width: number): string { + return text.length >= width ? text : text + ' '.repeat(width - text.length) +} + +function gridLines(section: Section): { head: string[]; rows: string[] } { + const cells = section.rows.map((row) => section.columns.map((col) => formatCell(row[col.name], col.dataType))) + const widths = section.columns.map((col, i) => { + let width = col.name.length + for (const rowCells of cells) width = Math.max(width, rowCells[i].length) + return width + }) + + const head: string[] = [] + if (section.title) head.push(section.title) + head.push(section.columns.map((col, i) => padTo(col.name, widths[i])).join(' ').trimEnd()) + head.push(widths.map((w) => '-'.repeat(w)).join(' ')) + + if (section.rows.length === 0 && section.empty) return { head: section.title ? [section.title, section.empty] : [section.empty], rows: [] } + + return { head, rows: cells.map((rowCells) => rowCells.map((cell, i) => padTo(cell, widths[i])).join(' ').trimEnd()) } +} + +function kvLines(section: Section): { head: string[]; rows: string[] } { + const head: string[] = section.title ? [section.title] : [] + const rows: string[] = [] + const width = Math.max(0, ...section.columns.map((c) => c.name.length)) + for (const row of section.rows) { + for (const col of section.columns) { + rows.push(`${padTo(`${col.name}:`, width + 1)} ${formatCell(row[col.name], col.dataType)}`) + } + } + if (rows.length === 0 && section.empty) head.push(section.empty) + return { head, rows } +} + +function mdLines(section: Section): { head: string[]; rows: string[] } { + const head: string[] = [] + if (section.title) { + head.push(`### ${section.title}`) + head.push('') + } + head.push(`| ${section.columns.map((c) => c.name).join(' | ')} |`) + head.push(`| ${section.columns.map(() => '---').join(' | ')} |`) + return { + head, + rows: section.rows.map((row) => `| ${section.columns.map((col) => mdCell(row[col.name], col.dataType)).join(' | ')} |`), + } +} + +function csvLines(section: Section): { head: string[]; rows: string[] } { + return { + head: [section.columns.map((c) => csvCell(c.name)).join(',')], + rows: section.rows.map((row) => section.columns.map((col) => csvCell(row[col.name], col.dataType)).join(',')), + } +} + +function jsonlLines(section: Section): { head: string[]; rows: string[] } { + return { head: [], rows: section.rows.map((row) => JSON.stringify(row)) } +} + +function linesFor(section: Section, format: OutputFormat): { head: string[]; rows: string[] } { + switch (format) { + case 'md': + return mdLines(section) + case 'csv': + return csvLines(section) + case 'jsonl': + return jsonlLines(section) + default: + return section.kind === 'kv' ? kvLines(section) : gridLines(section) + } +} + +/** + * Render sections under a hard byte budget. Rows are dropped from the end only, and the caller + * is always told how many were dropped — output is never silently short. + */ +export function renderSections(sections: Section[], format: OutputFormat, maxBytes: number): RenderResult { + const machineFormat = format === 'csv' || format === 'jsonl' + // csv/jsonl are single-stream formats — a second table would corrupt them + const emitted = (machineFormat ? sections.slice(0, 1) : sections).map((section) => linesFor(section, format)) + // Counted in emitted lines, not source rows: a kv section turns one record into several lines + const total = emitted.reduce((sum, section) => sum + section.rows.length, 0) + + const out: string[] = [] + let budget = maxBytes + let shown = 0 + let truncated = false + + for (const [index, { head, rows }] of emitted.entries()) { + if (truncated) break + if (index > 0 && !machineFormat) out.push('') + // Headers are structural: they are charged to the budget but never dropped + for (const line of head) { + out.push(line) + budget -= byteLength(line) + 1 + } + for (const line of rows) { + const cost = byteLength(line) + 1 + if (budget - cost < 0) { + truncated = true + break + } + budget -= cost + out.push(line) + shown++ + } + } + + const skippedSections = machineFormat && sections.length > 1 + const notices: string[] = [] + if (skippedSections) notices.push(`${sections.length - 1} additional section(s) omitted — --format ${format} emits a single table`) + + let stdout = out.length > 0 ? `${out.join('\n')}\n` : '' + if (truncated) { + const line = truncationLine(shown, total) + // The footer must be the last line of the human formats; machine formats keep stdout clean + if (machineFormat) notices.push(line) + else stdout += `${line}\n` + } + + return { stdout, stderr: notices.length > 0 ? `${notices.join('\n')}\n` : '', truncated, shown, total } +} + +/** + * `--format json` emits one object, so truncation is expressed inside it rather than as a footer. + * Rows are dropped from the end until the serialized document fits the budget. + */ +export function capJsonRows(rows: T[], maxBytes: number, overheadBytes: number): { kept: T[]; truncated: boolean } { + let used = overheadBytes + const kept: T[] = [] + for (const row of rows) { + const cost = byteLength(JSON.stringify(row) ?? 'null') + 1 + if (used + cost > maxBytes) return { kept, truncated: true } + used += cost + kept.push(row) + } + return { kept, truncated: false } +} diff --git a/src/cli-agent/help.ts b/src/cli-agent/help.ts new file mode 100644 index 00000000..f1b035c1 --- /dev/null +++ b/src/cli-agent/help.ts @@ -0,0 +1,82 @@ +// Help text. Kept in one place so `--help` and the per-command help stay in sync. + +import type { FlagSpecs } from './args' + +export const GLOBAL_FLAGS: FlagSpecs = { + json: { kind: 'boolean', description: 'Shorthand for --format json' }, + format: { kind: 'string', placeholder: '', description: 'Output format: table (default), json, jsonl, csv, md' }, + 'max-bytes': { kind: 'number', placeholder: '', description: 'Cap row output at n bytes (default 65536)' }, + timeout: { kind: 'number', placeholder: '', description: 'RPC timeout in milliseconds (default 30000)' }, + endpoint: { kind: 'string', placeholder: '', description: 'Path to an endpoint file (or set DOTAZ_ENDPOINT)' }, + instance: { kind: 'number', placeholder: '', description: 'Talk to a specific running Dotaz instance' }, + quiet: { kind: 'boolean', alias: 'q', description: 'Suppress diagnostics on stderr' }, + help: { kind: 'boolean', alias: 'h', description: 'Show help for this command' }, + version: { kind: 'boolean', alias: 'V', description: 'Print the CLI version' }, +} + +export interface CommandHelp { + usage: string + summary: string + flags?: FlagSpecs + notes?: string[] +} + +function renderFlags(flags: FlagSpecs): string[] { + const entries = Object.entries(flags).map(([name, spec]) => { + const alias = spec.alias ? `-${spec.alias}, ` : ' ' + const value = spec.placeholder ? ` ${spec.placeholder}` : '' + return [` ${alias}--${name}${value}`, spec.description] + }) + const width = Math.max(...entries.map(([left]) => left.length)) + return entries.map(([left, right]) => `${left.padEnd(width)} ${right}`) +} + +export function renderCommandHelp(name: string, help: CommandHelp): string { + const lines = [`dotaz ${name} — ${help.summary}`, '', `Usage: dotaz ${help.usage}`] + if (help.flags && Object.keys(help.flags).length > 0) { + lines.push('', 'Options:', ...renderFlags(help.flags)) + } + lines.push('', 'Global options:', ...renderFlags(GLOBAL_FLAGS)) + if (help.notes && help.notes.length > 0) lines.push('', ...help.notes) + return `${lines.join('\n')}\n` +} + +const COMMAND_SUMMARY: [string, string][] = [ + ['status', 'Is the app running, is CLI access enabled'], + ['ls [path]', 'Connections → databases → schemas → tables'], + ['describe ', 'Columns, primary key, indexes, foreign keys both ways'], + ['rows ', 'Read rows from a table'], + ['query ', 'Run a read-only query'], + ['explain ', 'Show the query plan'], + ['search ', 'Search values across tables'], + ['history', 'Recent queries executed in the app'], + ['bookmarks list', 'Queries the user saved in the app'], + ['propose ', 'Submit a write for the user to approve'], + ['approvals ', 'list | status | wait | cancel '], + ['ui ', 'state | open | console | command '], +] + +export function renderRootHelp(version: string): string { + const width = Math.max(...COMMAND_SUMMARY.map(([usage]) => usage.length)) + return [ + `dotaz ${version} — command-line client for the running Dotaz desktop app`, + '', + 'Usage: dotaz [options]', + '', + 'Commands:', + ...COMMAND_SUMMARY.map(([usage, summary]) => ` ${usage.padEnd(width)} ${summary}`), + '', + 'Global options:', + ...renderFlags(GLOBAL_FLAGS), + '', + 'Paths address objects as connection/database/schema/table. A connection matches by id,', + 'by exact name, or by a unique case-insensitive prefix. Drivers without databases or', + 'schemas (SQLite) accept the shortened form connection/table.', + '', + 'Exit codes: 0 ok · 2 usage · 3 database · 4 read-only (use `dotaz propose`) ·', + '5 Dotaz not running or CLI access disabled · 6 timeout · 7 proposal pending · 8 rejected', + '', + 'Run `dotaz --help` for command-specific options.', + '', + ].join('\n') +} diff --git a/src/cli-agent/main.ts b/src/cli-agent/main.ts new file mode 100755 index 00000000..310a6729 --- /dev/null +++ b/src/cli-agent/main.ts @@ -0,0 +1,80 @@ +#!/usr/bin/env bun +// `dotaz` — command-line client for the running Dotaz desktop app. See docs/agent-cli.md. + +import { flagBool, flagNumber, flagString } from './args' +import { outputOptions, parseInvocation, timeoutMs } from './cli' +import { DotazClient } from './client' +import { findCommand } from './commands' +import { AppContext } from './context' +import { discoverEndpoint } from './endpoint' +import { CliError, EXIT, type ExitCode, messageOf } from './errors' +import { renderCommandHelp, renderRootHelp } from './help' +import { renderOutput } from './output' +import pkg from './package.json' + +declare const __DOTAZ_CLI_VERSION__: string + +const cliVersion = typeof __DOTAZ_CLI_VERSION__ === 'string' ? __DOTAZ_CLI_VERSION__ : pkg.version + +async function write(stream: typeof Bun.stdout | typeof Bun.stderr, text: string): Promise { + if (!text) return + try { + await Bun.write(stream, text) + } catch { + // Downstream closed the pipe (`| head`) — nothing useful left to do + } +} + +async function run(argv: string[]): Promise { + const invocation = parseInvocation(argv, (name) => findCommand(name)?.flags) + + if (flagBool(invocation.args, 'version')) { + await write(Bun.stdout, `dotaz ${cliVersion}\n`) + return EXIT.ok + } + + if (!invocation.command) { + await write(Bun.stdout, renderRootHelp(cliVersion)) + // No command at all is a usage error unless help was asked for explicitly + return flagBool(invocation.args, 'help') ? EXIT.ok : EXIT.usage + } + + const command = findCommand(invocation.command) + if (!command) throw new CliError(EXIT.usage, `Unknown command "${invocation.command}"`) + + if (flagBool(invocation.args, 'help')) { + await write(Bun.stdout, renderCommandHelp(command.name, { ...command.help, flags: command.flags })) + return EXIT.ok + } + + const output = outputOptions(invocation.args) + const endpoint = discoverEndpoint({ + explicitFile: flagString(invocation.args, 'endpoint'), + instancePid: flagNumber(invocation.args, 'instance'), + }) + const client = new DotazClient(endpoint.endpoint, timeoutMs(invocation.args)) + const app = new AppContext(client) + + const result = await command.run({ args: invocation.args, output, client, app, endpoint }) + const rendered = renderOutput(result.output, output) + await write(Bun.stdout, rendered.stdout) + await write(Bun.stderr, rendered.stderr) + return result.exitCode ?? EXIT.ok +} + +let exitCode: ExitCode = EXIT.ok +try { + exitCode = await run(process.argv.slice(2)) +} catch (err) { + if (err instanceof CliError) { + await write(Bun.stderr, `dotaz: ${err.message}\n`) + if (err.hint) await write(Bun.stderr, `${err.hint}\n`) + else if (err.exitCode === EXIT.usage) await write(Bun.stderr, 'Run `dotaz --help` for usage.\n') + exitCode = err.exitCode + } else { + await write(Bun.stderr, `dotaz: unexpected error: ${messageOf(err)}\n`) + exitCode = EXIT.internal + } +} + +process.exit(exitCode) diff --git a/src/cli-agent/output.ts b/src/cli-agent/output.ts new file mode 100644 index 00000000..cd3947bc --- /dev/null +++ b/src/cli-agent/output.ts @@ -0,0 +1,56 @@ +// Bridges a command's result to the selected output format. + +import { capJsonRows, type OutputFormat, renderSections, type Section, truncationLine } from './format' + +export interface CommandOutput { + /** Human-readable rendering (also feeds csv/jsonl/md). */ + sections: Section[] + /** Emitted verbatim by `--json` / `--format json`. */ + json: unknown + /** Human-only notes — always stderr, suppressed by `--quiet`. */ + notes?: string[] +} + +export interface OutputOptions { + format: OutputFormat + maxBytes: number + quiet: boolean +} + +export interface Rendered { + stdout: string + stderr: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function renderJson(json: unknown, maxBytes: number): Rendered { + const full = JSON.stringify(json, null, 2) ?? 'null' + if (Buffer.byteLength(full, 'utf8') <= maxBytes) return { stdout: `${full}\n`, stderr: '' } + + // Only row arrays are safe to shorten — dropping anything else would misrepresent the result + if (isRecord(json) && Array.isArray(json.rows)) { + const total = json.rows.length + const overhead = Buffer.byteLength(JSON.stringify({ ...json, rows: [] }, null, 2) ?? '', 'utf8') + const { kept, truncated } = capJsonRows(json.rows, maxBytes, overhead) + const capped = { ...json, rows: kept, truncated, shown: kept.length, total } + return { stdout: `${JSON.stringify(capped, null, 2)}\n`, stderr: truncated ? `${truncationLine(kept.length, total)}\n` : '' } + } + + return { stdout: `${full}\n`, stderr: `output exceeds --max-bytes (${maxBytes}) and cannot be truncated safely\n` } +} + +export function renderOutput(output: CommandOutput, opts: OutputOptions): Rendered { + const notes = opts.quiet ? [] : (output.notes ?? []) + const noteText = notes.length > 0 ? `${notes.join('\n')}\n` : '' + + // `--quiet` silences diagnostics, never truncation. For csv/jsonl the stderr line is the + // only signal that rows are missing, so dropping it would let a consumer read a short + // result as the whole table. + const rendered = opts.format === 'json' + ? renderJson(output.json, opts.maxBytes) + : renderSections(output.sections, opts.format, opts.maxBytes) + return { stdout: rendered.stdout, stderr: noteText + rendered.stderr } +} diff --git a/src/cli-agent/package.json b/src/cli-agent/package.json new file mode 100644 index 00000000..a823e2a1 --- /dev/null +++ b/src/cli-agent/package.json @@ -0,0 +1,11 @@ +{ + "name": "@dotaz/cli", + "private": true, + "version": "0.0.0", + "bin": { + "dotaz": "./main.ts" + }, + "dependencies": { + "@dotaz/shared": "workspace:*" + } +} diff --git a/src/cli-agent/paths.ts b/src/cli-agent/paths.ts new file mode 100644 index 00000000..a9753e66 --- /dev/null +++ b/src/cli-agent/paths.ts @@ -0,0 +1,205 @@ +// `connection/database/schema/table` path parsing and resolution. +// Resolution takes an injectable context so it can be tested without a running app. + +import type { DatabaseInfo, SchemaData } from '@dotaz/shared/types/database' +import type { CliConnection } from './decode' +import { usageError } from './errors' + +export type PathLevel = 'connection' | 'database' | 'schema' | 'table' + +export interface ResolvedPath { + connection: CliConnection + database?: string + schema?: string + table?: string + level: PathLevel + /** Loaded only when the path reached the schema/table level. */ + schemaData?: SchemaData +} + +export interface ResolverContext { + listConnections(): Promise + listDatabases(connectionId: string): Promise + loadSchema(connectionId: string, database?: string): Promise +} + +export function splitPath(raw: string): string[] { + const segments = raw.split('/') + // A trailing slash is a typo, not an empty segment + if (segments.length > 1 && segments[segments.length - 1] === '') segments.pop() + if (segments.some((s) => s.length === 0)) throw usageError(`Invalid path "${raw}" — empty segment`) + return segments +} + +function listNames(values: string[], limit = 12): string { + const shown = values.slice(0, limit) + const suffix = values.length > limit ? `, … (${values.length} total)` : '' + return shown.join(', ') + suffix +} + +/** A connection is matched by id, by exact name, or by a unique case-insensitive prefix. */ +export function resolveConnectionRef(connections: CliConnection[], ref: string): CliConnection { + if (connections.length === 0) { + throw usageError('No connections are configured in Dotaz', 'Add a connection in the app first.') + } + + const byId = connections.find((c) => c.id === ref) + if (byId) return byId + + const exactName = connections.filter((c) => c.name === ref) + if (exactName.length === 1) return exactName[0] + if (exactName.length > 1) { + throw usageError(`Connection name "${ref}" is ambiguous`, `Use the connection id instead: ${listNames(exactName.map((c) => c.id))}`) + } + + const lower = ref.toLowerCase() + const caseInsensitive = connections.filter((c) => c.name.toLowerCase() === lower) + if (caseInsensitive.length === 1) return caseInsensitive[0] + if (caseInsensitive.length > 1) { + throw usageError(`Connection name "${ref}" is ambiguous`, `Use the connection id instead: ${listNames(caseInsensitive.map((c) => c.id))}`) + } + + const prefix = connections.filter((c) => c.name.toLowerCase().startsWith(lower)) + if (prefix.length === 1) return prefix[0] + if (prefix.length > 1) { + throw usageError(`Connection prefix "${ref}" is ambiguous`, `Matches: ${listNames(prefix.map((c) => c.name))}`) + } + + throw usageError(`Unknown connection "${ref}"`, `Available: ${listNames(connections.map((c) => c.name))}`) +} + +export function resolveDatabaseRef(databases: DatabaseInfo[], ref: string): string { + // Some drivers cannot enumerate databases — trust the caller rather than block them + if (databases.length === 0) return ref + const exact = databases.find((d) => d.name === ref) + if (exact) return exact.name + const lower = ref.toLowerCase() + const matches = databases.filter((d) => d.name.toLowerCase() === lower) + if (matches.length === 1) return matches[0].name + throw usageError(`Unknown database "${ref}"`, `Available: ${listNames(databases.map((d) => d.name))}`) +} + +function findSchema(schemaData: SchemaData, ref: string): string | null { + const exact = schemaData.schemas.find((s) => s.name === ref) + if (exact) return exact.name + const lower = ref.toLowerCase() + const matches = schemaData.schemas.filter((s) => s.name.toLowerCase() === lower) + return matches.length === 1 ? matches[0].name : null +} + +function findTable(schemaData: SchemaData, schema: string, ref: string): string | null { + const tables = schemaData.tables[schema] ?? [] + const exact = tables.find((t) => t.name === ref) + if (exact) return exact.name + const lower = ref.toLowerCase() + const matches = tables.filter((t) => t.name.toLowerCase() === lower) + return matches.length === 1 ? matches[0].name : null +} + +/** Resolve the `[schema/]table` tail. The schema is optional on single-schema drivers (SQLite, MySQL). */ +export function resolveSchemaTable(schemaData: SchemaData, segments: string[], pathLabel: string): { schema?: string; table?: string } { + if (segments.length === 0) return {} + + if (segments.length > 2) { + throw usageError(`Path "${pathLabel}" has too many segments`, 'Expected connection[/database][/schema]/table') + } + + if (segments.length === 2) { + const schema = findSchema(schemaData, segments[0]) + if (!schema) { + throw usageError(`Unknown schema "${segments[0]}"`, `Available: ${listNames(schemaData.schemas.map((s) => s.name))}`) + } + const table = findTable(schemaData, schema, segments[1]) + if (!table) { + throw usageError( + `Unknown table "${segments[1]}" in schema "${schema}"`, + `Available: ${listNames((schemaData.tables[schema] ?? []).map((t) => t.name))}`, + ) + } + return { schema, table } + } + + const asSchema = findSchema(schemaData, segments[0]) + if (asSchema) return { schema: asSchema } + + // Shortened form: `connection/table` on drivers without a schema layer + const hits: { schema: string; table: string }[] = [] + for (const schema of schemaData.schemas) { + const table = findTable(schemaData, schema.name, segments[0]) + if (table) hits.push({ schema: schema.name, table }) + } + if (hits.length === 1) return hits[0] + if (hits.length > 1) { + throw usageError( + `Table "${segments[0]}" exists in several schemas`, + `Qualify it: ${listNames(hits.map((h) => `${h.schema}/${h.table}`))}`, + ) + } + throw usageError( + `No schema or table named "${segments[0]}"`, + `Schemas: ${listNames(schemaData.schemas.map((s) => s.name))}`, + ) +} + +function levelFor(resolved: { schema?: string; table?: string }, base: PathLevel): PathLevel { + if (resolved.table) return 'table' + if (resolved.schema) return 'schema' + return base +} + +/** Resolve a full object path. `raw === undefined` means "the connection list". */ +export async function resolvePath(raw: string | undefined, ctx: ResolverContext): Promise { + if (raw === undefined) return null + + const segments = splitPath(raw) + const connections = await ctx.listConnections() + const connection = resolveConnectionRef(connections, segments[0]) + const rest = segments.slice(1) + + if (connection.type === 'sqlite') { + if (rest.length === 0) return { connection, level: 'connection' } + const schemaData = await ctx.loadSchema(connection.id) + const tail = resolveSchemaTable(schemaData, rest, raw) + return { connection, ...tail, level: levelFor(tail, 'connection'), schemaData } + } + + if (rest.length === 0) return { connection, level: 'connection' } + + const database = resolveDatabaseRef(await ctx.listDatabases(connection.id), rest[0]) + const tail = rest.slice(1) + if (tail.length === 0) return { connection, database, level: 'database' } + + const schemaData = await ctx.loadSchema(connection.id, database) + const resolved = resolveSchemaTable(schemaData, tail, raw) + return { connection, database, ...resolved, level: levelFor(resolved, 'database'), schemaData } +} + +export interface ResolvedScope { + connection: CliConnection + database?: string +} + +/** Resolve the `connection[/database]` form used by query/explain/search/propose/ui console. */ +export async function resolveScope(raw: string, ctx: ResolverContext): Promise { + const segments = splitPath(raw) + const connections = await ctx.listConnections() + const connection = resolveConnectionRef(connections, segments[0]) + + if (segments.length === 1) return { connection } + if (segments.length > 2) { + throw usageError(`Path "${raw}" has too many segments`, 'Expected connection[/database]') + } + if (connection.type === 'sqlite') { + throw usageError(`SQLite connection "${connection.name}" has no database segment`, `Use "${segments[0]}" on its own.`) + } + return { connection, database: resolveDatabaseRef(await ctx.listDatabases(connection.id), segments[1]) } +} + +/** Narrow a resolved path to a table, failing with an actionable message when it is not one. */ +export function requireTable(resolved: ResolvedPath | null, pathLabel: string): ResolvedPath & { schema: string; table: string } { + if (!resolved?.table || resolved.schema === undefined) { + throw usageError(`"${pathLabel}" does not point at a table`, 'Expected connection[/database][/schema]/table') + } + const { schema, table } = resolved + return { ...resolved, schema, table } +} diff --git a/src/cli-agent/proposals.ts b/src/cli-agent/proposals.ts new file mode 100644 index 00000000..ba05889c --- /dev/null +++ b/src/cli-agent/proposals.ts @@ -0,0 +1,114 @@ +// Proposal helpers shared by `propose` and `approvals` — including the status → exit code map. + +import type { Proposal, ProposalStatus } from '@dotaz/shared/types/rpc' +import type { CallOptions } from './client' +import { ConnectionLostError } from './client' +import { decodeProposal } from './decode' +import { CliError, EXIT, type ExitCode } from './errors' +import type { CommandOutput } from './output' + +export const DEFAULT_WAIT_SECONDS = 300 + +/** One long-poll slice. The backend clamps its own wait, so we re-issue until the deadline. */ +export const WAIT_SLICE_MS = 30_000 + +/** Extra HTTP budget on top of the long-poll window, so the socket never aborts first. */ +const WAIT_SLACK_MS = 5_000 + +export function exitCodeForProposal(status: ProposalStatus): ExitCode { + switch (status) { + case 'executed': + return EXIT.ok + case 'failed': + return EXIT.database + case 'rejected': + case 'cancelled': + return EXIT.rejected + case 'pending': + case 'approved': + case 'expired': + return EXIT.pending + } +} + +export function proposalNote(proposal: Proposal): string { + switch (proposal.status) { + case 'pending': + return `Proposal ${proposal.id} is waiting for approval in the Dotaz window.` + case 'approved': + return `Proposal ${proposal.id} was approved but has not finished executing yet.` + case 'executed': + return `Proposal ${proposal.id} was executed (${proposal.result?.affectedRows ?? 0} row(s) affected).` + case 'failed': + return `Proposal ${proposal.id} failed: ${proposal.error ?? 'unknown error'}` + case 'rejected': + return `Proposal ${proposal.id} was rejected by the user.` + case 'cancelled': + return `Proposal ${proposal.id} was cancelled.` + case 'expired': + return `Proposal ${proposal.id} expired without a decision.` + } +} + +export function proposalOutput(proposal: Proposal): CommandOutput { + const row = { + id: proposal.id, + status: proposal.status, + connectionId: proposal.connectionId, + database: proposal.database ?? null, + reason: proposal.reason ?? null, + createdAt: new Date(proposal.createdAt).toISOString(), + resolvedAt: proposal.resolvedAt ? new Date(proposal.resolvedAt).toISOString() : null, + affectedRows: proposal.result?.affectedRows ?? null, + error: proposal.error ?? null, + sql: proposal.sql, + } + return { + sections: [{ kind: 'kv', columns: Object.keys(row).map((name) => ({ name })), rows: [row] }], + json: proposal, + notes: [proposalNote(proposal)], + } +} + +/** Just enough of `DotazClient` to poll with — a narrow surface keeps the wait testable. */ +export interface ProposalPoller { + call(method: string, params?: unknown, opts?: CallOptions): Promise +} + +/** + * Proposals only ever live in the app's memory, so an app that quits takes them with it. + * That is a different answer from "the user has not decided yet" and deserves its own message. + */ +function appClosedError(proposalId: string): CliError { + return new CliError( + EXIT.notRunning, + `Dotaz closed while proposal ${proposalId} was still pending — the proposal is gone.`, + 'Proposals are never persisted. Start Dotaz again and submit the write with `dotaz propose`.', + ) +} + +/** + * Long-poll until the proposal leaves `pending` or the deadline passes. + * Returns the last known proposal either way — the caller maps status to an exit code. + */ +export async function waitForProposal(client: ProposalPoller, proposalId: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + let proposal = decodeProposal(await client.call('agent.proposals.get', { proposalId })) + + while (proposal.status === 'pending') { + const remaining = deadline - Date.now() + if (remaining <= 0) break + const slice = Math.min(remaining, WAIT_SLICE_MS) + try { + proposal = decodeProposal( + await client.call('agent.proposals.wait', { proposalId, timeoutMs: slice }, { timeoutMs: slice + WAIT_SLACK_MS }), + ) + } catch (err) { + // The app answered a moment ago, so a dead socket now means it quit under us + if (err instanceof ConnectionLostError) throw appClosedError(proposalId) + throw err + } + } + + return proposal +} diff --git a/src/cli-agent/query-output.ts b/src/cli-agent/query-output.ts new file mode 100644 index 00000000..482296ac --- /dev/null +++ b/src/cli-agent/query-output.ts @@ -0,0 +1,58 @@ +// Shared rendering for anything that comes back as one or more QueryResult sets. + +import type { QueryResult } from '@dotaz/shared/types/query' +import type { RenderColumn, Section } from './format' + +function columnsOf(result: QueryResult): RenderColumn[] { + return result.columns.map((c) => ({ name: c.name, dataType: c.dataType })) +} + +export function queryResultSections(results: QueryResult[]): Section[] { + return results.map((result, index) => { + const title = results.length > 1 ? `-- statement ${index + 1} (${result.durationMs}ms)` : undefined + if (result.columns.length === 0) { + return { + title, + kind: 'kv', + columns: [{ name: 'affectedRows' }, { name: 'durationMs' }], + rows: [{ affectedRows: result.affectedRows ?? 0, durationMs: result.durationMs }], + } + } + return { title, columns: columnsOf(result), rows: result.rows, empty: '(no rows)' } + }) +} + +export function queryResultJson(results: QueryResult[]): Record { + if (results.length === 1) { + const [result] = results + return { + columns: result.columns, + rows: result.rows, + rowCount: result.rowCount, + affectedRows: result.affectedRows, + durationMs: result.durationMs, + } + } + return { + results: results.map((result) => ({ + columns: result.columns, + rows: result.rows, + rowCount: result.rowCount, + affectedRows: result.affectedRows, + durationMs: result.durationMs, + })), + } +} + +/** A driver can report a failure inside the result set instead of throwing. */ +export function firstResultError(results: QueryResult[]): string | undefined { + return results.find((result) => result.error)?.error +} + +export function queryNotes(results: QueryResult[]): string[] { + return results + .map((result, index) => { + const prefix = results.length > 1 ? `statement ${index + 1}: ` : '' + return `${prefix}${result.rowCount} row(s) in ${result.durationMs}ms` + }) +} diff --git a/src/cli-agent/sql.ts b/src/cli-agent/sql.ts new file mode 100644 index 00000000..2d808fbb --- /dev/null +++ b/src/cli-agent/sql.ts @@ -0,0 +1,156 @@ +// SQL the CLI builds itself. Identifiers always go through the shared dialect quoting — +// the only text ever interpolated raw is the documented `--where` fragment. + +import { buildOrderByClause } from '@dotaz/shared/sql/builders' +import type { SqlDialect } from '@dotaz/shared/sql/dialect' +import { MysqlDialect, PostgresDialect, SqliteDialect } from '@dotaz/shared/sql/dialects' +import { classifyStatement, isReadOnlySql, isUnlimitedSelect, splitStatements, stripLiteralsAndComments } from '@dotaz/shared/sql/statements' +import type { ConnectionType } from '@dotaz/shared/types/connection' +import type { SortColumn } from '@dotaz/shared/types/grid' +import { usageError } from './errors' + +export function dialectFor(type: ConnectionType): SqlDialect { + switch (type) { + case 'postgresql': + return new PostgresDialect() + case 'mysql': + return new MysqlDialect() + case 'sqlite': + return new SqliteDialect() + } +} + +/** `--order "created_at:desc,id"` → SortColumn[] */ +export function parseOrderBy(raw: string | undefined): SortColumn[] | undefined { + if (!raw) return undefined + const sort: SortColumn[] = [] + for (const part of raw.split(',')) { + const spec = part.trim() + if (!spec) continue + const [column, direction] = spec.split(':') + const col = column.trim() + if (!col) throw usageError(`Invalid --order entry "${spec}"`) + const dir = (direction ?? 'asc').trim().toLowerCase() + if (dir !== 'asc' && dir !== 'desc') throw usageError(`Invalid sort direction "${direction}" in --order (expected asc or desc)`) + sort.push({ column: col, direction: dir }) + } + return sort.length > 0 ? sort : undefined +} + +/** `--columns "id,name"` → column names */ +export function parseColumnList(raw: string | undefined): string[] | undefined { + if (!raw) return undefined + const columns = raw.split(',').map((c) => c.trim()).filter((c) => c.length > 0) + if (columns.length === 0) throw usageError('--columns needs at least one column name') + return columns +} + +export interface RowsQueryOptions { + schema: string + table: string + dialect: SqlDialect + columns?: string[] + /** Raw SQL fragment supplied with `--where`; interpolated as-is, wrapped in parentheses. */ + where?: string + sort?: SortColumn[] + limit: number + offset: number +} + +export function buildRowsQuery(opts: RowsQueryOptions): { sql: string; params: unknown[] } { + const { dialect } = opts + const selectList = opts.columns && opts.columns.length > 0 + ? opts.columns.map((c) => dialect.quoteIdentifier(c)).join(', ') + : '*' + + const parts = [`SELECT ${selectList} FROM ${dialect.qualifyTable(opts.schema, opts.table)}`] + if (opts.where) parts.push(`WHERE (${opts.where})`) + const orderBy = buildOrderByClause(opts.sort, dialect) + if (orderBy) parts.push(orderBy) + parts.push(`LIMIT ${dialect.placeholder(1)} OFFSET ${dialect.placeholder(2)}`) + + return { sql: parts.join(' '), params: [opts.limit, opts.offset] } +} + +/** Where `--limit` took effect: in the statement the database ran, or only in what we printed. */ +export type SqlLimitMode = 'sql' | 'rows' + +export interface SqlLimit { + /** The statement to execute — unchanged unless `mode` is `'sql'`. */ + sql: string + mode: SqlLimitMode + /** Why the limit stayed out of the SQL (`mode: 'rows'` only). */ + reason?: string +} + +/** Only a plain SELECT (or a CTE ending in one) can take a trailing LIMIT without changing meaning. */ +const LIMITABLE_START = /^(SELECT|WITH)\b/ +/** A locking clause must stay last, so LIMIT cannot simply be appended after it. */ +const LOCKING_CLAUSE = /\bFOR\s+(UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b|\bLOCK\s+IN\s+SHARE\s+MODE\b/ +/** `SELECT … INTO …` (PostgreSQL table, MySQL OUTFILE/variable) is not a plain result set. */ +const INTO_CLAUSE = /\bINTO\b/ + +function normalizeForLimit(sql: string): string { + return stripLiteralsAndComments(sql).replace(/\s+/g, ' ').trim().toUpperCase() +} + +/** + * Every engine Dotaz speaks spells row limiting the same way — the switch keeps that a decision. + * The count is inlined rather than bound: it is a checked positive integer, and a placeholder + * would have to guess the numbering of whatever `--param` bindings the caller already wrote. + */ +function limitClause(type: ConnectionType, limit: number): string { + switch (type) { + case 'postgresql': + case 'mysql': + case 'sqlite': + return `LIMIT ${limit}` + } +} + +/** + * Push `--limit` into the statement itself, so the database stops producing rows nobody reads. + * Deliberately conservative: anything we cannot rewrite with certainty is returned untouched + * with a reason, and the caller trims the printed rows instead. + */ +export function applySqlLimit(sql: string, limit: number, type: ConnectionType): SqlLimit { + const keep = (reason: string): SqlLimit => ({ sql, mode: 'rows', reason }) + if (!Number.isSafeInteger(limit) || limit <= 0) return keep('the limit is not a positive integer') + + // Fails closed on anything the classifier cannot prove is a read + if (!isReadOnlySql(sql)) return keep('the statement is not provably read-only') + + const statements = splitStatements(sql) + if (statements.length === 0) return keep('there is no statement to limit') + if (statements.length > 1) return keep('the input holds more than one statement') + + const [statement] = statements + if (classifyStatement(statement) !== 'read') return keep('the statement is not a read-only SELECT') + + const normalized = normalizeForLimit(statement) + if (!LIMITABLE_START.test(normalized)) return keep('only SELECT and WITH … SELECT can take a trailing LIMIT') + if (!isUnlimitedSelect(statement)) return keep('the statement already limits its own rows') + if (/\bOFFSET\b/.test(normalized)) return keep('OFFSET without LIMIT is dialect-specific') + if (LOCKING_CLAUSE.test(normalized)) return keep('a locking clause has to stay last') + if (INTO_CLAUSE.test(normalized)) return keep('SELECT … INTO does not return a result set') + + // Newline, not a space: the statement may well end in a line comment + return { sql: `${statement}\n${limitClause(type, limit)}`, mode: 'sql' } +} + +/** + * EXPLAIN runs through `agent.query` in a backend-owned read-only session. The app's own explain + * path is fire-and-forget over a message channel the one-shot CLI transport cannot receive. + */ +export function buildExplainSql(type: ConnectionType, sql: string, analyze: boolean): string { + const statement = sql.trim().replace(/;\s*$/, '') + switch (type) { + case 'sqlite': + if (analyze) throw usageError('SQLite has no EXPLAIN ANALYZE', 'Drop --analyze to get the query plan.') + return `EXPLAIN QUERY PLAN ${statement}` + case 'mysql': + return analyze ? `EXPLAIN ANALYZE ${statement}` : `EXPLAIN ${statement}` + case 'postgresql': + return analyze ? `EXPLAIN (ANALYZE, BUFFERS) ${statement}` : `EXPLAIN ${statement}` + } +} diff --git a/src/cli-agent/tsconfig.json b/src/cli-agent/tsconfig.json new file mode 100644 index 00000000..b0b55242 --- /dev/null +++ b/src/cli-agent/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["."] +} diff --git a/src/cli/package.json b/src/cli/package.json index 4e262158..5a531ff0 100644 --- a/src/cli/package.json +++ b/src/cli/package.json @@ -1,5 +1,5 @@ { - "name": "@dotaz/cli", + "name": "@dotaz/server-cli", "private": true, "version": "0.0.0", "dependencies": { diff --git a/src/frontend-demo/demo-adapter.ts b/src/frontend-demo/demo-adapter.ts index d3856aaf..9f7ca16c 100644 --- a/src/frontend-demo/demo-adapter.ts +++ b/src/frontend-demo/demo-adapter.ts @@ -4,6 +4,7 @@ import { buildSchemaContext, generateSql } from '@dotaz/backend-shared/services/ import { buildExportSelectQuery, exportPreview as generateExportPreview, exportToStream } from '@dotaz/backend-shared/services/export-service' import type { ExportWriter } from '@dotaz/backend-shared/services/export-service' import { importFromStream, importPreviewFromStream } from '@dotaz/backend-shared/services/import-service' +import { ProposalStore } from '@dotaz/backend-shared/services/proposal-store' import { searchDatabase } from '@dotaz/backend-shared/services/search-service' import { formatSql } from '@dotaz/backend-shared/services/sql-formatter' import { splitStatements } from '@dotaz/shared/sql/statements' @@ -13,10 +14,15 @@ import type { ExportOptions, ExportPreviewRequest, ExportRawPreviewRequest, Expo import type { ImportOptions, ImportPreviewRequest, ImportPreviewResult, ImportResult } from '@dotaz/shared/types/import' import type { ExplainNode, ExplainResult, QueryHistoryEntry, QueryHistoryStatus, QueryResult } from '@dotaz/shared/types/query' import type { + AgentHelloResult, AiGenerateSqlParams, AiGenerateSqlResult, ConnectionHandleInfo, HistoryListParams, + Proposal, + ProposalListParams, + ProposalResolveParams, + ProposeWriteParams, QueryBookmark, SavedView, SavedViewConfig, @@ -26,6 +32,8 @@ import type { TransactionLogEntry, TransactionLogParams, TransactionLogResult, + UiCommandPayload, + UiSnapshot, } from '@dotaz/shared/types/rpc' import { settingsToAiConfig } from '@dotaz/shared/types/settings' import type { DemoAppState } from './demo-state' @@ -35,6 +43,8 @@ export class DemoAdapter implements RpcAdapter { private connectedSet = new Set() private sessionLogEntries: TransactionLogEntry[] = [] private pendingCount = 0 + private proposals = new ProposalStore() + private uiSnapshot: UiSnapshot | null = null constructor( private driver: DatabaseDriver, @@ -148,7 +158,7 @@ export class DemoAdapter implements RpcAdapter { // ── Sessions (no-op in demo — single WASM connection) ── - async createSession(connectionId: string, _database?: string): Promise { + async createSession(connectionId: string, _database?: string, _opts?: { readOnly?: boolean; label?: string }): Promise { return { sessionId: crypto.randomUUID(), connectionId, @@ -434,6 +444,7 @@ export class DemoAdapter implements RpcAdapter { schemaName: params.schemaName, tableNames: params.tableNames, resultsPerTable: params.resultsPerTable ?? 50, + sessionId: params.sessionId, }, () => {}, () => false, @@ -619,6 +630,52 @@ export class DemoAdapter implements RpcAdapter { return null } + // ── Agent CLI (no CLI transport in demo — state is local only) ── + + agentHello(): AgentHelloResult { + return { version: '0.0.0', mode: 'demo', pid: 0, protocol: 1 } + } + + proposeWrite(params: ProposeWriteParams): Proposal { + const proposal = this.proposals.create(params) + this.emitMessage('cli.proposal', proposal) + return proposal + } + + listProposals(filter?: ProposalListParams): Proposal[] { + return this.proposals.list(filter) + } + + getProposal(proposalId: string): Proposal | null { + return this.proposals.get(proposalId) + } + + async waitForProposal(proposalId: string, timeoutMs: number): Promise { + return this.proposals.wait(proposalId, timeoutMs) + } + + cancelProposal(proposalId: string): Proposal { + return this.proposals.cancel(proposalId) + } + + resolveProposal(params: ProposalResolveParams): Proposal { + return this.proposals.resolve(params) + } + + // ── UI control ──────────────────────────────────────── + + getUiSnapshot(): UiSnapshot { + return this.uiSnapshot ?? { tabs: [], activeTabId: null, activeConnectionId: null, updatedAt: 0 } + } + + setUiSnapshot(snapshot: UiSnapshot): void { + this.uiSnapshot = snapshot + } + + sendUiCommand(payload: UiCommandPayload): void { + this.emitMessage('cli.command', payload) + } + // ── Demo ────────────────────────────────────────────── async initializeDemo(): Promise { diff --git a/src/frontend-demo/wasm-sqlite-driver.ts b/src/frontend-demo/wasm-sqlite-driver.ts index e9e6f68b..728ba90a 100644 --- a/src/frontend-demo/wasm-sqlite-driver.ts +++ b/src/frontend-demo/wasm-sqlite-driver.ts @@ -74,6 +74,11 @@ export class WasmSqliteDriver implements DatabaseDriver { return [...this.sessionIds] } + isSessionReadOnly(_sessionId: string): boolean { + // Demo mode never opens read-only sessions — the WASM database is a scratch copy + return false + } + async execute(sql: string, params?: unknown[], _sessionId?: string): Promise { this.ensureConnected() this.markUsed() diff --git a/src/frontend-shared/components/agent/ProposalBanner.css b/src/frontend-shared/components/agent/ProposalBanner.css new file mode 100644 index 00000000..c533c07a --- /dev/null +++ b/src/frontend-shared/components/agent/ProposalBanner.css @@ -0,0 +1,94 @@ +.proposal-banner { + display: flex; + align-items: flex-start; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + background: var(--surface-raised); + border-bottom: 1px solid var(--edge); + border-left: 3px solid var(--warning); + flex-shrink: 0; +} + +.proposal-banner--resolved, +.proposal-banner--invalidated { + border-left-color: var(--ink-muted); +} + +.proposal-banner__badge { + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + margin-top: 1px; + padding: 2px var(--spacing-xs); + border-radius: var(--radius-sm); + background: var(--warning); + color: var(--ink-inverse); + font-size: var(--font-size-xs); + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.proposal-banner__body { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.proposal-banner__headline { + margin: 0; + font-size: var(--font-size-sm); + color: var(--ink); +} + +.proposal-banner__reason { + margin: 0; + font-size: var(--font-size-sm); + color: var(--ink-secondary); + font-style: italic; +} + +.proposal-banner__blocked, +.proposal-banner__invalid, +.proposal-banner__outcome { + display: flex; + align-items: center; + gap: 4px; + margin: 0; + font-size: var(--font-size-xs); +} + +.proposal-banner__blocked { + color: var(--warning); +} + +.proposal-banner__invalid { + color: var(--ink-secondary); +} + +.proposal-banner__outcome { + color: var(--success); +} + +.proposal-banner__outcome--failed { + color: var(--error); +} + +.proposal-banner__actions { + display: flex; + align-items: center; + gap: var(--spacing-xs); + flex-shrink: 0; +} + +.proposal-banner__actions .btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: var(--spacing-xs) var(--spacing-sm); + font-size: var(--font-size-xs); + white-space: nowrap; +} diff --git a/src/frontend-shared/components/agent/ProposalBanner.tsx b/src/frontend-shared/components/agent/ProposalBanner.tsx new file mode 100644 index 00000000..211cc62e --- /dev/null +++ b/src/frontend-shared/components/agent/ProposalBanner.tsx @@ -0,0 +1,145 @@ +import { Show } from 'solid-js' +import type { ProposalPhase } from '../../lib/proposal-state' +import { connectionsStore } from '../../stores/connections' +import { proposalsStore } from '../../stores/proposals' +import Icon from '../common/Icon' +import './ProposalBanner.css' + +interface ProposalBannerProps { + tabId: string +} + +/** The proposal is settled — the tab keeps the SQL, the banner only offers Dismiss. */ +function isTerminal(phase: ProposalPhase): boolean { + return phase === 'resolved' || phase === 'invalidated' +} + +function runLabel(phase: ProposalPhase): string { + if (phase === 'checking') return 'Checking…' + if (phase === 'running') return 'Running…' + return 'Run' +} + +/** Approval surface for a write an agent proposed through the CLI. Never runs on its own. */ +export default function ProposalBanner(props: ProposalBannerProps) { + const entry = () => proposalsStore.entryForTab(props.tabId) + + function target(): string { + const e = entry() + if (!e) return '' + const conn = connectionsStore.connections.find((c) => c.id === e.proposal.connectionId) + const name = conn?.name ?? e.proposal.connectionId + return e.proposal.database ? `${name} / ${e.proposal.database}` : name + } + + function blocked(): string | null { + const e = entry() + // A settled proposal cannot be run, so why it is blocked no longer matters. + return e && !isTerminal(e.phase) ? proposalsStore.blockedReason(e) : null + } + + function disconnected(): boolean { + const e = entry() + if (!e) return false + const conn = connectionsStore.connections.find((c) => c.id === e.proposal.connectionId) + return conn != null && conn.state !== 'connected' + } + + return ( + + {(e) => ( +
+
+ + Agent +
+ +
+

+ + An agent wants to run this SQL on {target()}. It has not been executed. + + } + > + An agent proposed this SQL on {target()}. + +

+ + {(reason) =>

“{reason()}”

} +
+ + {(reason) => ( +

+ + {reason()} +

+ )} +
+ + {(reason) => ( +

+ + {reason()} +

+ )} +
+ + {(outcome) => ( +

+ + {outcome().status === 'failed' ? 'Failed' : 'Executed'} — {outcome().message} +

+ )} +
+
+ +
+ + + + + + + + + + +
+
+ )} +
+ ) +} diff --git a/src/frontend-shared/components/common/SettingsCli.tsx b/src/frontend-shared/components/common/SettingsCli.tsx new file mode 100644 index 00000000..bd930fa1 --- /dev/null +++ b/src/frontend-shared/components/common/SettingsCli.tsx @@ -0,0 +1,26 @@ +export default function SettingsCli(props: { + enabled: boolean + setEnabled: (v: boolean) => void +}) { + return ( +
+
+

Command-line access

+
+ + props.setEnabled(e.currentTarget.checked)} + /> +
+
+ Off by default. When enabled, Dotaz opens a local control endpoint so the dotaz{' '} + command — and any AI agent or other process running as you on this machine — can read every database this app is connected to: schemas, rows, + query history. Writes are never executed directly: an agent can only propose SQL, which you approve or reject in a console tab. Turn this off + when you are not using it. +
+
+
+ ) +} diff --git a/src/frontend-shared/components/common/SettingsDialog.tsx b/src/frontend-shared/components/common/SettingsDialog.tsx index 1714a4f7..39bea78f 100644 --- a/src/frontend-shared/components/common/SettingsDialog.tsx +++ b/src/frontend-shared/components/common/SettingsDialog.tsx @@ -1,18 +1,20 @@ import type { AiProvider, ColorTheme, FormatProfile } from '@dotaz/shared/types/settings' import { createEffect, createMemo, createSignal, Show } from 'solid-js' import { createStore, reconcile, unwrap } from 'solid-js/store' +import { getCapabilities } from '../../lib/capabilities' import { formatDateWithProfile, formatNumberWithProfile } from '../../lib/cell-formatters' import type { SessionConfig } from '../../stores/settings' import { settingsStore } from '../../stores/settings' import Dialog from './Dialog' import SettingsAI from './SettingsAI' import SettingsAppearance from './SettingsAppearance' +import SettingsCli from './SettingsCli' import SettingsDataFormat from './SettingsDataFormat' import './SettingsDialog.css' import SettingsGrid from './SettingsGrid' import SettingsSession from './SettingsSession' -export type SettingsSection = 'appearance' | 'data-format' | 'ai' | 'session' | 'grid' +export type SettingsSection = 'appearance' | 'data-format' | 'ai' | 'session' | 'grid' | 'cli' interface SettingsDialogProps { open: boolean @@ -35,6 +37,10 @@ export default function SettingsDialog(props: SettingsDialogProps) { const [ai, setAi] = createStore({ provider: 'anthropic' as AiProvider, apiKey: '', model: '', endpoint: '' }) const [session, setSession] = createStore({ defaultConnectionMode: 'pool', autoPin: 'on-begin', autoUnpin: 'never' }) const [grid, setGrid] = createStore({ autoCount: false }) + const [cli, setCli] = createStore({ enabled: false }) + + // The CLI control endpoint only exists in the desktop app. + const cliAvailable = () => getCapabilities().isDesktop // Load all values when dialog opens createEffect(() => { @@ -46,6 +52,7 @@ export default function SettingsDialog(props: SettingsDialogProps) { setAi(reconcile({ ...unwrap(settingsStore.aiConfig) })) setSession(reconcile({ ...unwrap(settingsStore.sessionConfig) })) setGrid(reconcile({ autoCount: settingsStore.gridConfig.autoCount })) + setCli(reconcile({ enabled: settingsStore.cliConfig.enabled })) } }) @@ -85,6 +92,9 @@ export default function SettingsDialog(props: SettingsDialogProps) { settingsStore.saveAiConfig({ ...unwrap(ai) }) settingsStore.saveSessionConfig({ ...unwrap(session) }) settingsStore.saveGridConfig({ autoCount: grid.autoCount }) + if (cliAvailable()) { + settingsStore.saveCliConfig({ enabled: cli.enabled }) + } props.onClose() } @@ -127,6 +137,15 @@ export default function SettingsDialog(props: SettingsDialogProps) { > Grid + + +
@@ -188,6 +207,12 @@ export default function SettingsDialog(props: SettingsDialogProps) { setAutoCount={(v) => setGrid('autoCount', v)} /> + + setCli('enabled', v)} + /> +
diff --git a/src/frontend-shared/components/layout/AppShell.tsx b/src/frontend-shared/components/layout/AppShell.tsx index bc830e72..8ef6a966 100644 --- a/src/frontend-shared/components/layout/AppShell.tsx +++ b/src/frontend-shared/components/layout/AppShell.tsx @@ -7,22 +7,26 @@ import appIcon from '../../../../assets/icon.png' import { registerAppCommands } from '../../lib/app-commands' import { registerAppShortcuts } from '../../lib/app-shortcuts' import { getCapabilities } from '../../lib/capabilities' +import { handleUiCommand } from '../../lib/cli-commands' import { commandRegistry } from '../../lib/commands' import type { ShortcutContext } from '../../lib/keyboard' import { keyboardManager } from '../../lib/keyboard' import { applyUpdate, friendlyErrorMessage, messages, setWindowTitle } from '../../lib/rpc' import { formatWindowTitle } from '../../lib/tab-context' +import { initUiSnapshotPublisher } from '../../lib/ui-snapshot-publisher' import { loadWorkspace, saveWorkspaceNow, scheduleWorkspaceSave, setWorkspaceStateCollector } from '../../lib/workspace' import { getComparisonParams, setComparisonParams } from '../../stores/comparison' import { connectionsStore } from '../../stores/connections' import { editorStore } from '../../stores/editor' import { gridStore } from '../../stores/grid' import { navigationStore } from '../../stores/navigation' +import { proposalsStore } from '../../stores/proposals' import { sessionStore } from '../../stores/session' import { settingsStore } from '../../stores/settings' import { tabsStore } from '../../stores/tabs' import { uiStore } from '../../stores/ui' import { viewsStore } from '../../stores/views' +import ProposalBanner from '../agent/ProposalBanner' import BookmarksDialog from '../bookmarks/BookmarksDialog' import CommandPalette from '../common/CommandPalette' import ConfirmDialog from '../common/ConfirmDialog' @@ -68,6 +72,7 @@ tabsStore.onTabClosed((tabId) => { editorStore.removeTab(tabId) sessionStore.handleTabClosed(tabId) navigationStore.handleTabClosed(tabId) + proposalsStore.handleTabClosed(tabId) }) const MIN_WIDTH = 150 @@ -171,6 +176,8 @@ export default function AppShell() { let removeStatusListener: (() => void) | undefined let removeUpdateListener: (() => void) | undefined let removeResizeListener: (() => void) | undefined + let removeProposalListener: (() => void) | undefined + let removeCliCommandListener: (() => void) | undefined // ── Global error handlers ───────────────────────────── function handleUnhandledError(event: ErrorEvent) { @@ -206,6 +213,9 @@ export default function AppShell() { } }) + // Publish what the user has open for the CLI's `ui.state` (desktop only, debounced). + initUiSnapshotPublisher() + onMount(async () => { await connectionsStore.loadConnections() settingsStore.loadSettings() @@ -261,6 +271,14 @@ export default function AppShell() { setUpdateVersion(version) }) + // Listen for the CLI — write proposals to approve and UI commands to perform + removeProposalListener = messages.onCliProposal((proposal) => { + proposalsStore.handleProposal(proposal) + }) + removeCliCommandListener = messages.onCliCommand((command) => { + handleUiCommand(command) + }) + // Global error catching — prevents app crash on unhandled errors window.addEventListener('error', handleUnhandledError) window.addEventListener('unhandledrejection', handleUnhandledRejection) @@ -322,6 +340,8 @@ export default function AppShell() { removeStatusListener?.() removeUpdateListener?.() removeResizeListener?.() + removeProposalListener?.() + removeCliCommandListener?.() tabsStore.setBeforeCloseHook(null) connectionsStore.setBeforeDisconnectHook(null) window.removeEventListener('error', handleUnhandledError) @@ -596,6 +616,7 @@ export default function AppShell() { onToggleTransactionLog={() => setTxLogOpen((v) => !v)} transactionLogOpen={txLogOpen()} /> + diff --git a/src/frontend-shared/lib/agent-proposals.ts b/src/frontend-shared/lib/agent-proposals.ts new file mode 100644 index 00000000..f5738ce4 --- /dev/null +++ b/src/frontend-shared/lib/agent-proposals.ts @@ -0,0 +1,32 @@ +// Pure helpers for CLI write proposals (see docs/agent-cli.md). + +import type { QueryResult } from '@dotaz/shared/types/query' + +export interface ProposalRunOutcome { + status: 'executed' | 'failed' + result: { affectedRows: number; statements: number } + error?: string +} + +/** + * Reduce the result sets of an approved proposal run into the shape the CLI expects. + * A per-statement error fails the whole proposal — the agent must not read a partial + * failure as success. + */ +export function summarizeProposalRun(results: QueryResult[]): ProposalRunOutcome { + let affectedRows = 0 + for (const result of results) { + affectedRows += result.affectedRows ?? 0 + } + const failed = results.find((r) => r.error) + return { + status: failed ? 'failed' : 'executed', + result: { affectedRows, statements: results.length }, + ...(failed ? { error: failed.error } : {}), + } +} + +/** Tab title for a proposal console — must read as "an agent wrote this". */ +export function proposalTabTitle(label: string): string { + return `Agent SQL — ${label}` +} diff --git a/src/frontend-shared/lib/cli-commands.ts b/src/frontend-shared/lib/cli-commands.ts new file mode 100644 index 00000000..05783488 --- /dev/null +++ b/src/frontend-shared/lib/cli-commands.ts @@ -0,0 +1,88 @@ +// Handles `cli.command` — UI actions the CLI asks the app to perform (see docs/agent-cli.md). + +import type { UiCommandPayload } from '@dotaz/shared/types/rpc' +import { connectionsStore } from '../stores/connections' +import { editorStore } from '../stores/editor' +import { gridStore } from '../stores/grid' +import { tabsStore } from '../stores/tabs' +import { uiStore } from '../stores/ui' + +function connectionName(connectionId: string): string | null { + return connectionsStore.connections.find((c) => c.id === connectionId)?.name ?? null +} + +async function openTable(command: Extract) { + const conn = connectionsStore.connections.find((c) => c.id === command.connectionId) + if (!conn) { + uiStore.addToast('warning', 'The CLI asked to open a table on a connection that no longer exists.') + return + } + + const existing = tabsStore.findDefaultTab(command.connectionId, command.schema, command.table, command.database) + const tabId = existing ?? tabsStore.openTab({ + type: 'data-grid', + title: command.table, + connectionId: command.connectionId, + schema: command.schema, + table: command.table, + database: command.database, + }) + + // DataGrid connects and loads on its own, and racing that load would drop the filter. + if (conn.state !== 'connected') { + if (command.where || command.limit) { + uiStore.addToast('warning', `${conn.name} is not connected yet — the requested filter was not applied.`) + } + connectionsStore.connectTo(conn.id) + return + } + + if (!existing) { + await gridStore.loadTableData(tabId, command.connectionId, command.schema, command.table, command.database) + } + if (command.limit) { + await gridStore.setPageSize(tabId, command.limit) + } + if (command.where) { + await gridStore.setCustomFilter(tabId, command.where) + } +} + +function openConsole(command: Extract) { + const name = connectionName(command.connectionId) + if (!name) { + uiStore.addToast('warning', 'The CLI asked to open a console on a connection that no longer exists.') + return + } + + const label = command.database ?? name + const tabId = tabsStore.openTab({ + type: 'sql-console', + title: `SQL — ${label}`, + connectionId: command.connectionId, + database: command.database, + }) + editorStore.initTab(tabId, command.connectionId, command.database) + if (command.sql) { + editorStore.setContent(tabId, command.sql) + } + // Only an explicit `run` executes — a prefilled console never runs by itself. + if (command.run && command.sql) { + editorStore.executeQuery(tabId).catch((err) => { + uiStore.addToast('error', `Failed to run the requested SQL: ${err instanceof Error ? err.message : String(err)}`) + }) + } +} + +export function handleUiCommand(command: UiCommandPayload): void { + switch (command.kind) { + case 'open-table': + openTable(command).catch((err) => { + uiStore.addToast('error', `Failed to open ${command.table}: ${err instanceof Error ? err.message : String(err)}`) + }) + return + case 'open-console': + openConsole(command) + return + } +} diff --git a/src/frontend-shared/lib/proposal-state.ts b/src/frontend-shared/lib/proposal-state.ts new file mode 100644 index 00000000..12452657 --- /dev/null +++ b/src/frontend-shared/lib/proposal-state.ts @@ -0,0 +1,86 @@ +// Pure decision logic for CLI write proposals (see docs/agent-cli.md). +// +// Deliberately free of store imports so it stays unit-testable — and so importing it does not +// drag the tab store into a test that only wants these functions. + +import type { Proposal, ProposalStatus } from '@dotaz/shared/types/rpc' + +/** + * Where a proposal is in the approve/reject flow. `resolved` keeps the outcome on screen; + * `invalidated` marks a proposal that left `pending` without this window resolving it. + */ +export type ProposalPhase = 'pending' | 'checking' | 'running' | 'resolved' | 'invalidated' + +/** The backend no longer knows this proposal — cancelled and swept, or from a previous run. */ +export const PROPOSAL_GONE_REASON = 'This proposal no longer exists — the agent may have cancelled it. Nothing was run.' + +/** The proposal was settled somewhere else while this banner was still offering it. */ +export const PROPOSAL_ALREADY_RESOLVED_REASON = 'This proposal was already resolved outside this window.' + +/** Banner copy for a proposal that left `pending` without this window doing it. */ +export function invalidationReason(status: Exclude): string { + switch (status) { + case 'cancelled': + return 'The agent cancelled this proposal. Nothing was run.' + case 'expired': + return 'This proposal expired after an hour without approval. Nothing was run.' + case 'rejected': + return 'This proposal was rejected in another window.' + case 'approved': + return 'This proposal was already approved in another window.' + case 'executed': + return 'This proposal was already executed in another window.' + case 'failed': + return 'This proposal was already run in another window, and it failed.' + } +} + +/** What an incoming `cli.proposal` message means for this window. */ +export type ProposalMessageAction = + | { kind: 'ignore' } + | { kind: 'open' } + | { kind: 'focus' } + | { kind: 'invalidate'; reason: string } + +/** + * Decide what a proposal state change does to the app's own state. The backend reports every + * transition — creation, resolution, cancellation and expiry — so most of them are not ours. + */ +export function decideProposalMessage(entry: { phase: ProposalPhase } | undefined, proposal: Proposal): ProposalMessageAction { + if (proposal.status === 'pending') return entry ? { kind: 'focus' } : { kind: 'open' } + // A proposal this window never opened is not its business — no tab, no banner. + if (!entry) return { kind: 'ignore' } + switch (entry.phase) { + case 'running': + // The statement is already in flight; its real outcome settles the proposal. + return { kind: 'ignore' } + case 'resolved': + case 'invalidated': + // Already terminal — never overwrite an outcome this window produced. + return { kind: 'ignore' } + case 'pending': + case 'checking': + return { kind: 'invalidate', reason: invalidationReason(proposal.status) } + } +} + +/** The backend refuses to touch a proposal that already left `pending` — expected, not a failure. */ +const STALE_PROPOSAL_ERROR = /Proposal (?:.+ is already (?:approved|rejected|executed|failed|cancelled|expired)|not found)/i + +export function isStaleProposalError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err) + return STALE_PROPOSAL_ERROR.test(message) +} + +/** + * Whether what ran is the SQL the agent proposed and the user approved. + * + * The run listener matches on the tab, so without this an edited buffer — or one statement of + * a multi-statement proposal run on its own — would be reported back to the agent as the whole + * proposal `executed`. Whitespace and a trailing semicolon are ignored because the editor may + * reformat those; any other difference is a different statement reaching the database. + */ +export function sqlMatchesProposal(ran: string, proposed: string): boolean { + const normalize = (sql: string) => sql.trim().replace(/\s+/g, ' ').replace(/;$/, '').trim() + return normalize(ran) === normalize(proposed) +} diff --git a/src/frontend-shared/lib/rpc.ts b/src/frontend-shared/lib/rpc.ts index d424c775..ac147bed 100644 --- a/src/frontend-shared/lib/rpc.ts +++ b/src/frontend-shared/lib/rpc.ts @@ -5,7 +5,7 @@ import type { NamespacedRpcClient } from '@dotaz/backend-types' import type { ConnectionState } from '@dotaz/shared/types/connection' import type { DatabaseErrorCode } from '@dotaz/shared/types/errors' import type { QueryCompletedEvent } from '@dotaz/shared/types/query' -import type { SessionInfo } from '@dotaz/shared/types/rpc' +import type { Proposal, SessionInfo, UiCommandPayload } from '@dotaz/shared/types/rpc' import { transport } from './transport' export { friendlyErrorMessage, RpcError } from './rpc-errors' @@ -60,6 +60,18 @@ export const messages = { ) => { return transport.addMessageListener('update.ready', handler) }, + /** A write the CLI wants the user to approve (see docs/agent-cli.md). */ + onCliProposal: ( + handler: (proposal: Proposal) => void, + ) => { + return transport.addMessageListener('cli.proposal', handler) + }, + /** A UI action requested by the CLI — open a tab, run a command. */ + onCliCommand: ( + handler: (payload: UiCommandPayload) => void, + ) => { + return transport.addMessageListener('cli.command', handler) + }, } export function applyUpdate(): Promise { diff --git a/src/frontend-shared/lib/ui-snapshot-publisher.ts b/src/frontend-shared/lib/ui-snapshot-publisher.ts new file mode 100644 index 00000000..e69690f3 --- /dev/null +++ b/src/frontend-shared/lib/ui-snapshot-publisher.ts @@ -0,0 +1,40 @@ +// Publishes the UI snapshot the CLI reads through `ui.state` (see docs/agent-cli.md). + +import { createEffect, onCleanup } from 'solid-js' +import { connectionsStore } from '../stores/connections' +import { editorStore } from '../stores/editor' +import { tabsStore } from '../stores/tabs' +import { getCapabilities } from './capabilities' +import { rpc } from './rpc' +import { buildUiSnapshot } from './ui-snapshot' + +/** Wait this long after the last change before publishing — typing must not spam RPC. */ +const PUBLISH_DEBOUNCE_MS = 400 + +/** + * Publish the snapshot whenever tabs, the active tab or the active connection change. + * Only desktop has a control endpoint, so other modes would publish into the void. + */ +export function initUiSnapshotPublisher(): void { + if (!getCapabilities().isDesktop) return + + let timer: ReturnType | undefined + + createEffect(() => { + const snapshot = buildUiSnapshot({ + tabs: tabsStore.openTabs, + activeTabId: tabsStore.activeTabId, + activeConnectionId: connectionsStore.activeConnectionId, + getSql: (tabId) => editorStore.getTab(tabId)?.content, + now: Date.now(), + }) + clearTimeout(timer) + timer = setTimeout(() => { + rpc.ui['snapshot.set']({ snapshot }).catch((err) => { + console.debug('Failed to publish UI snapshot:', err) + }) + }, PUBLISH_DEBOUNCE_MS) + }) + + onCleanup(() => clearTimeout(timer)) +} diff --git a/src/frontend-shared/lib/ui-snapshot.ts b/src/frontend-shared/lib/ui-snapshot.ts new file mode 100644 index 00000000..372652f6 --- /dev/null +++ b/src/frontend-shared/lib/ui-snapshot.ts @@ -0,0 +1,41 @@ +// What the user currently has open, published for the CLI's `ui.state` (see docs/agent-cli.md). +// +// Kept free of store imports so it stays a pure, independently testable mapping — the +// publisher that reads the live stores lives in ui-snapshot-publisher.ts. + +import type { UiSnapshot, UiTabSnapshot } from '@dotaz/shared/types/rpc' +import type { TabInfo } from '@dotaz/shared/types/tab' + +export interface UiSnapshotSource { + tabs: readonly TabInfo[] + activeTabId: string | null + activeConnectionId: string | null + /** Current editor contents of a SQL console tab. */ + getSql: (tabId: string) => string | undefined + now: number +} + +export function buildUiSnapshot(source: UiSnapshotSource): UiSnapshot { + const tabs: UiTabSnapshot[] = source.tabs.map((tab) => { + const snapshot: UiTabSnapshot = { + id: tab.id, + type: tab.type, + title: tab.title, + connectionId: tab.connectionId, + } + if (tab.database) snapshot.database = tab.database + if (tab.schema) snapshot.schema = tab.schema + if (tab.table) snapshot.table = tab.table + if (tab.type === 'sql-console') { + const sql = source.getSql(tab.id) + if (sql) snapshot.sql = sql + } + return snapshot + }) + return { + tabs, + activeTabId: source.activeTabId, + activeConnectionId: source.activeConnectionId, + updatedAt: source.now, + } +} diff --git a/src/frontend-shared/stores/editor.ts b/src/frontend-shared/stores/editor.ts index d3abc5b6..c1b27287 100644 --- a/src/frontend-shared/stores/editor.ts +++ b/src/frontend-shared/stores/editor.ts @@ -186,6 +186,35 @@ function findDestructiveStatements(sql: string): string[] { return statements.filter(detectDestructiveWithoutWhere) } +// ── Run completion notifications ────────────────────────── + +/** Outcome of one console run, reported to observers such as the CLI proposal banner. */ +export interface RunFinishedEvent { + tabId: string + /** The SQL that was submitted. */ + sql: string + /** Result sets, when the run reached the database. */ + results?: QueryResult[] + /** Execution error, when the run failed as a whole. */ + error?: string + /** The run never reached the database (read-only connection, destructive warning cancelled). */ + skipped?: boolean +} + +const runListeners = new Set<(event: RunFinishedEvent) => void>() + +/** Subscribe to run outcomes. Returns an unsubscribe function. */ +function onRunFinished(listener: (event: RunFinishedEvent) => void): () => void { + runListeners.add(listener) + return () => runListeners.delete(listener) +} + +function notifyRunFinished(event: RunFinishedEvent) { + for (const listener of [...runListeners]) { + listener(event) + } +} + /** Track active query message listeners so they can be cleaned up on cancel/new-query. */ const activeQueryUnsubs = new Map void>() /** Track active query reject functions so connection-loss can fail pending queries. */ @@ -439,6 +468,8 @@ async function runQuery(tabId: string, sql: string, baseOffset = 0, applyLimit = // Auto-unpin after commit/rollback if configured sessionStore.checkAutoUnpin(tabId, sql).catch(() => {}) + + notifyRunFinished({ tabId, sql, results }) } catch (err) { // Discard if tab was removed or a newer query was started if (!getTab(tabId) || state.tabs[tabId]?.queryId !== queryId) return @@ -473,6 +504,8 @@ async function runQuery(tabId: string, sql: string, baseOffset = 0, applyLimit = } else { setTxLogVersion((v) => v + 1) } + + notifyRunFinished({ tabId, sql, error: errorMessage }) } finally { clearTimeout(responseTimer) activeQueryUnsubs.delete(queryId) @@ -484,6 +517,7 @@ function checkAndRunQuery(tabId: string, sql: string, baseOffset = 0) { const tab = getTab(tabId) if (tab && connectionsStore.isReadOnly(tab.connectionId) && containsDmlStatements(sql)) { uiStore.addToast('warning', 'This connection is read-only. DML/DDL statements are not allowed.') + notifyRunFinished({ tabId, sql, skipped: true }) return } if (!suppressDestructiveWarning) { @@ -539,7 +573,11 @@ function confirmDestructiveQuery(suppressForSession = false) { } function cancelDestructiveQuery() { + const pending = pendingDestructiveQuery() setPendingDestructiveQuery(null) + if (pending) { + notifyRunFinished({ tabId: pending.tabId, sql: pending.sql, skipped: true }) + } } async function cancelQuery(tabId: string) { @@ -1046,6 +1084,7 @@ export const editorStore = { executeQuery, executeSelected, executeStatement, + onRunFinished, cancelQuery, explainQuery, formatSql, diff --git a/src/frontend-shared/stores/proposals.ts b/src/frontend-shared/stores/proposals.ts new file mode 100644 index 00000000..09c4bcb2 --- /dev/null +++ b/src/frontend-shared/stores/proposals.ts @@ -0,0 +1,278 @@ +import type { Proposal } from '@dotaz/shared/types/rpc' +import { createStore } from 'solid-js/store' +import { proposalTabTitle, summarizeProposalRun } from '../lib/agent-proposals' +import { + decideProposalMessage, + invalidationReason, + isStaleProposalError, + PROPOSAL_ALREADY_RESOLVED_REASON, + PROPOSAL_GONE_REASON, + type ProposalPhase, + sqlMatchesProposal, +} from '../lib/proposal-state' +import { friendlyErrorMessage, rpc } from '../lib/rpc' +import { connectionsStore } from './connections' +import type { RunFinishedEvent } from './editor' +import { editorStore } from './editor' +import { tabsStore } from './tabs' +import { uiStore } from './ui' + +export interface ProposalEntry { + proposal: Proposal + /** SQL console tab hosting the banner. */ + tabId: string + phase: ProposalPhase + /** Outcome text shown after the proposal was resolved. */ + outcome: { status: 'executed' | 'failed'; message: string } | null + /** Why the banner went terminal without this window resolving it. */ + invalidReason: string | null +} + +interface ProposalsState { + /** Keyed by proposal id — several proposals can be waiting at once. */ + entries: Record +} + +const [state, setState] = createStore({ entries: {} }) + +function entryForTab(tabId: string): ProposalEntry | null { + for (const entry of Object.values(state.entries)) { + if (entry.tabId === tabId) return entry + } + return null +} + +/** Why the app cannot run this proposal right now, or null when it can. */ +function blockedReason(entry: ProposalEntry): string | null { + const conn = connectionsStore.connections.find((c) => c.id === entry.proposal.connectionId) + if (!conn) return 'The connection this proposal targets no longer exists.' + if (conn.state !== 'connected') return `Connection "${conn.name}" is not connected.` + if (connectionsStore.isReadOnly(entry.proposal.connectionId)) { + return `Connection "${conn.name}" is marked read-only in Dotaz.` + } + return null +} + +/** Terminal banner state for a proposal that is no longer the app's to act on. */ +function invalidate(proposalId: string, reason: string) { + const entry = state.entries[proposalId] + // Never rip away a banner mid-run or one already showing this window's own outcome. + if (!entry || entry.phase === 'running' || entry.phase === 'resolved' || entry.phase === 'invalidated') return + setState('entries', proposalId, { phase: 'invalidated', invalidReason: reason }) +} + +async function resolve( + proposalId: string, + status: 'executed' | 'failed' | 'rejected', + payload?: { result?: { affectedRows?: number; statements?: number }; error?: string }, +) { + try { + await rpc.agent['proposals.resolve']({ proposalId, status, result: payload?.result, error: payload?.error }) + } catch (err) { + if (isStaleProposalError(err)) { + // It left `pending` behind our back — the banner explains that, a toast would only confuse. + console.debug(`Proposal ${proposalId} was already resolved:`, err) + invalidate(proposalId, PROPOSAL_ALREADY_RESOLVED_REASON) + return + } + uiStore.addToast('error', `Failed to report the proposal outcome: ${friendlyErrorMessage(err)}`) + } +} + +/** + * Subscribed while proposals are open. Any run in a proposal's tab settles it — the user may + * hit the toolbar Run or Ctrl+Enter instead of the banner button, and it must not run twice. + */ +let unsubscribeRuns: (() => void) | null = null + +function handleRunFinished(event: RunFinishedEvent) { + const found = Object.entries(state.entries).find(([, entry]) => entry.tabId === event.tabId) + if (!found) return + const [proposalId, entry] = found + // A terminal banner no longer speaks for the proposal, whatever the user runs in the tab. + if (entry.phase === 'resolved' || entry.phase === 'invalidated') return + + if (event.skipped) { + // Nothing reached the database (read-only connection, destructive warning cancelled). + if (entry.phase === 'running') setState('entries', proposalId, 'phase', 'pending') + return + } + + // Matching on the tab alone would report "executed" for whatever the user actually ran — + // an edited buffer, or one statement of a multi-statement proposal run on its own. + if (!sqlMatchesProposal(event.sql, entry.proposal.sql)) { + const message = 'The SQL in the console was changed before it ran — this proposal was not executed.' + setState('entries', proposalId, { phase: 'resolved', outcome: { status: 'failed', message } }) + resolve(proposalId, 'failed', { error: message }) + return + } + + if (event.error) { + setState('entries', proposalId, { phase: 'resolved', outcome: { status: 'failed', message: event.error } }) + resolve(proposalId, 'failed', { error: event.error }) + return + } + + const outcome = summarizeProposalRun(event.results ?? []) + setState('entries', proposalId, { + phase: 'resolved', + outcome: { + status: outcome.status, + message: outcome.error + ?? `${outcome.result.affectedRows} row(s) affected in ${outcome.result.statements} statement(s).`, + }, + }) + resolve(proposalId, outcome.status, { result: outcome.result, error: outcome.error }) +} + +function removeEntry(proposalId: string) { + setState('entries', proposalId, undefined!) + if (unsubscribeRuns && Object.keys(state.entries).length === 0) { + unsubscribeRuns() + unsubscribeRuns = null + } +} + +/** Open a console tab for a new proposal. Never runs anything. */ +function openProposalTab(proposal: Proposal) { + const conn = connectionsStore.connections.find((c) => c.id === proposal.connectionId) + if (!conn) { + uiStore.addToast('error', 'An agent proposed a write for a connection that no longer exists.') + resolve(proposal.id, 'failed', { error: 'Connection not found in the app' }) + return + } + + const label = proposal.database ?? conn.name + const tabId = tabsStore.openTab({ + type: 'sql-console', + title: proposalTabTitle(label), + connectionId: proposal.connectionId, + database: proposal.database, + }) + editorStore.initTab(tabId, proposal.connectionId, proposal.database) + editorStore.setContent(tabId, proposal.sql) + + setState('entries', proposal.id, { proposal, tabId, phase: 'pending', outcome: null, invalidReason: null }) + if (!unsubscribeRuns) { + unsubscribeRuns = editorStore.onRunFinished(handleRunFinished) + } + uiStore.addToast('info', 'An agent proposed a write. Review it before running.') +} + +/** Apply a proposal state change pushed by the backend. */ +function handleProposal(proposal: Proposal) { + const entry = state.entries[proposal.id] + const action = decideProposalMessage(entry, proposal) + switch (action.kind) { + case 'ignore': + return + case 'focus': + if (entry) tabsStore.setActiveTab(entry.tabId) + return + case 'invalidate': + invalidate(proposal.id, action.reason) + return + case 'open': + openProposalTab(proposal) + return + } +} + +/** + * Confirm server-side that the proposal is still runnable. The `cli.proposal` message + * announcing a cancellation or expiry may not have arrived yet, and Run must not beat it. + */ +async function confirmStillPending(proposalId: string): Promise { + try { + const proposal = await rpc.agent['proposals.get']({ proposalId }) + if (proposal.status === 'pending') return true + invalidate(proposalId, invalidationReason(proposal.status)) + return false + } catch (err) { + if (isStaleProposalError(err)) { + invalidate(proposalId, PROPOSAL_GONE_REASON) + return false + } + // Could not reach the backend — say so and leave the banner actionable. + uiStore.addToast('error', `Could not check the proposal: ${friendlyErrorMessage(err)}`) + if (state.entries[proposalId]?.phase === 'checking') setState('entries', proposalId, 'phase', 'pending') + return false + } +} + +/** Run the proposal through the console's normal execution path. The outcome arrives via handleRunFinished. */ +async function run(proposalId: string) { + const entry = state.entries[proposalId] + if (!entry || entry.phase !== 'pending') return + if (blockedReason(entry)) return + + const sql = editorStore.getTab(entry.tabId)?.content.trim() + if (!sql) { + uiStore.addToast('warning', 'Nothing to run — the console is empty.') + return + } + + setState('entries', proposalId, 'phase', 'checking') + if (!await confirmStillPending(proposalId)) return + // The check was async — the entry may have been invalidated or closed meanwhile. + if (state.entries[proposalId]?.phase !== 'checking') return + + setState('entries', proposalId, 'phase', 'running') + editorStore.executeQuery(entry.tabId).catch((err) => { + // The run never started, so no run outcome will arrive — report it here. + if (state.entries[proposalId]?.phase !== 'running') return + const message = friendlyErrorMessage(err) + setState('entries', proposalId, { phase: 'resolved', outcome: { status: 'failed', message } }) + resolve(proposalId, 'failed', { error: message }) + }) +} + +/** Reject the proposal and dismiss its banner. */ +function reject(proposalId: string) { + const entry = state.entries[proposalId] + if (!entry || entry.phase !== 'pending') return + removeEntry(proposalId) + resolve(proposalId, 'rejected') +} + +/** Dismiss a banner that already reported its outcome, or one that went stale. */ +function dismiss(proposalId: string) { + const entry = state.entries[proposalId] + if (!entry || (entry.phase !== 'resolved' && entry.phase !== 'invalidated')) return + removeEntry(proposalId) +} + +/** Closing the tab means the user did not approve — tell the CLI instead of leaving it hanging. */ +function handleTabClosed(tabId: string) { + for (const [proposalId, entry] of Object.entries(state.entries)) { + if (entry.tabId !== tabId) continue + const phase = entry.phase + removeEntry(proposalId) + if (phase === 'pending' || phase === 'checking') { + resolve(proposalId, 'rejected', { error: 'The approval tab was closed' }) + } else if (phase === 'running') { + // The statement was already submitted, so its outcome is genuinely unknown. + resolve(proposalId, 'failed', { error: 'The approval tab was closed while the statement was running' }) + } + } +} + +function connect(proposalId: string) { + const entry = state.entries[proposalId] + if (!entry) return + connectionsStore.connectTo(entry.proposal.connectionId) +} + +export const proposalsStore = { + get entries() { + return state.entries + }, + entryForTab, + blockedReason, + handleProposal, + run, + reject, + dismiss, + handleTabClosed, + connect, +} diff --git a/src/frontend-shared/stores/settings.ts b/src/frontend-shared/stores/settings.ts index 583d0b7c..5d3c8474 100644 --- a/src/frontend-shared/stores/settings.ts +++ b/src/frontend-shared/stores/settings.ts @@ -87,6 +87,27 @@ function gridConfigToSettings(config: GridConfig): Record { } } +// ── CLI config ──────────────────────────────────────────── + +export interface CliConfig { + /** Whether local processes may drive this app through the CLI (see docs/agent-cli.md). */ + enabled: boolean +} + +const DEFAULT_CLI_CONFIG: CliConfig = { enabled: false } + +function settingsToCliConfig(settings: Record): CliConfig { + return { + enabled: settings['cli.enabled'] === 'true', + } +} + +function cliConfigToSettings(config: CliConfig): Record { + return { + 'cli.enabled': String(config.enabled), + } +} + // ── Connections list config ─────────────────────────────── export type ConnectionSortMode = 'manual' | 'name' | 'type' | 'status' @@ -144,6 +165,7 @@ interface SettingsState { consoleConfig: ConsoleConfig appearanceConfig: AppearanceConfig gridConfig: GridConfig + cliConfig: CliConfig connectionsConfig: ConnectionsConfig loaded: boolean } @@ -155,6 +177,7 @@ const [state, setState] = createStore({ consoleConfig: { ...DEFAULT_CONSOLE_CONFIG }, appearanceConfig: { ...DEFAULT_APPEARANCE_CONFIG }, gridConfig: { ...DEFAULT_GRID_CONFIG }, + cliConfig: { ...DEFAULT_CLI_CONFIG }, connectionsConfig: { ...DEFAULT_CONNECTIONS_CONFIG }, loaded: false, }) @@ -167,6 +190,7 @@ async function loadSettings() { setState('sessionConfig', settingsToSessionConfig(all)) setState('consoleConfig', settingsToConsoleConfig(all)) setState('gridConfig', settingsToGridConfig(all)) + setState('cliConfig', settingsToCliConfig(all)) setState('connectionsConfig', settingsToConnectionsConfig(all)) const appearance = settingsToAppearanceConfig(all) setState('appearanceConfig', appearance) @@ -238,6 +262,18 @@ async function saveGridConfig(config: GridConfig) { } } +async function saveCliConfig(config: CliConfig) { + setState('cliConfig', config) + const entries = cliConfigToSettings(config) + for (const [key, value] of Object.entries(entries)) { + try { + await rpc.settings.set({ key, value }) + } catch (err) { + uiStore.addToast('error', `Failed to save setting "${key}": ${err instanceof Error ? err.message : String(err)}`) + } + } +} + async function saveAppearanceConfig(config: AppearanceConfig) { setState('appearanceConfig', config) applyTheme(config.colorTheme) @@ -288,6 +324,9 @@ export const settingsStore = { get gridConfig() { return state.gridConfig }, + get cliConfig() { + return state.cliConfig + }, get connectionsConfig() { return state.connectionsConfig }, @@ -301,6 +340,7 @@ export const settingsStore = { saveConsoleConfig, saveAppearanceConfig, saveGridConfig, + saveCliConfig, setConnectionSort, setConnectionOrder, applyTheme, diff --git a/src/shared/sql/statements.ts b/src/shared/sql/statements.ts index 09dabbd4..00489c3b 100644 --- a/src/shared/sql/statements.ts +++ b/src/shared/sql/statements.ts @@ -232,6 +232,206 @@ export function stripLiteralsAndComments(sql: string): string { return result } +// ── Statement classification ──────────────────────────────── + +/** What a statement does to the database. `unknown` is anything we cannot prove. */ +export type StatementKind = 'read' | 'write' | 'ddl' | 'unknown' + +const READ_KEYWORDS = new Set(['SELECT', 'SHOW', 'DESCRIBE', 'DESC', 'VALUES', 'TABLE']) +const WRITE_KEYWORDS = new Set(['INSERT', 'UPDATE', 'DELETE', 'MERGE', 'REPLACE', 'TRUNCATE', 'CALL', 'DO']) +const DDL_KEYWORDS = new Set(['CREATE', 'ALTER', 'DROP', 'GRANT', 'REVOKE', 'VACUUM', 'REINDEX', 'ATTACH', 'DETACH']) + +/** Keywords that can follow a CTE list and decide what the statement really does. */ +const CTE_TAIL_KEYWORDS = new Set(['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'MERGE', 'REPLACE', 'VALUES', 'TABLE']) + +/** Keywords that make a CTE body itself data-modifying, whatever the tail does. */ +const CTE_BODY_WRITE_KEYWORDS = new Set(['INSERT', 'UPDATE', 'DELETE', 'MERGE', 'REPLACE']) + +/** + * SQLite introspection pragmas that read even when given an argument. + * Anything else carrying an argument changes state (`query_only(0)`, `user_version(42)`). + */ +const READ_ONLY_PRAGMAS = new Set([ + 'TABLE_INFO', + 'TABLE_XINFO', + 'TABLE_LIST', + 'INDEX_LIST', + 'INDEX_INFO', + 'INDEX_XINFO', + 'FOREIGN_KEY_LIST', + 'FOREIGN_KEY_CHECK', + 'DATABASE_LIST', + 'COLLATION_LIST', + 'FUNCTION_LIST', + 'MODULE_LIST', + 'PRAGMA_LIST', + 'COMPILE_OPTIONS', + 'INTEGRITY_CHECK', + 'QUICK_CHECK', +]) + +/** + * Classify a single SQL statement by what it does to the database. + * Keyword-based — literals and comments are stripped first so a keyword inside + * a string never counts. Anything unrecognised is `unknown` so callers fail closed. + */ +export function classifyStatement(sql: string): StatementKind { + return classifyNormalized(normalizeForClassification(sql)) +} + +/** + * Whether every statement in `sql` is provably a read. + * Fails closed: `unknown`, empty input, and multi-statement input with a single + * non-read statement all return false. + */ +export function isReadOnlySql(sql: string): boolean { + const statements = splitStatements(sql) + if (statements.length === 0) return false + return statements.every((statement) => classifyStatement(statement) === 'read') +} + +function normalizeForClassification(sql: string): string { + return stripLiteralsAndComments(sql).replace(/\s+/g, ' ').trim().toUpperCase() +} + +function classifyNormalized(sql: string): StatementKind { + // Leading parens (e.g. `(SELECT …) UNION (SELECT …)`) don't change the operation + const stmt = sql.replace(/^[(\s]+/, '') + const keyword = stmt.match(/^[A-Z_]+/)?.[0] + if (!keyword) return 'unknown' + + // `set_config`/`SET` can revoke the session's own read-only mode from inside a SELECT + if (/\bSET_CONFIG\s*\(/.test(stmt)) return 'unknown' + + if (keyword === 'WITH') { + const tailStart = findCteTailIndex(stmt) + if (tailStart === null) return 'unknown' + // A data-modifying CTE writes even when the tail is a plain SELECT (PostgreSQL) + const bodies = classifyCteBodies(stmt.slice(0, tailStart)) + if (bodies !== 'read') return bodies + return classifyNormalized(stmt.slice(tailStart)) + } + if (keyword === 'EXPLAIN') return classifyExplain(stmt) + if (keyword === 'PRAGMA') return classifyPragma(stmt) + // COPY … TO writes a server-side file rather than the database — not provably a read + if (keyword === 'COPY') return /\bFROM\b/.test(stmt) ? 'write' : 'unknown' + // `SELECT … INTO` creates a table (PG) or writes a file (MySQL `INTO OUTFILE`) + if (keyword === 'SELECT' && /\bINTO\b/.test(stmt)) return 'unknown' + + if (READ_KEYWORDS.has(keyword)) return 'read' + if (WRITE_KEYWORDS.has(keyword)) return 'write' + if (DDL_KEYWORDS.has(keyword)) return 'ddl' + return 'unknown' +} + +/** + * Index of the statement that follows a CTE list, so data-modifying CTEs + * (`WITH x AS (…) INSERT …`) are classified by their tail, not by `WITH`. + * CTE bodies are always parenthesised, so the tail is the first matching + * keyword at paren depth 0. + */ +function findCteTailIndex(sql: string): number | null { + let depth = 0 + let wordStart = -1 + + for (let i = 0; i <= sql.length; i++) { + const ch = i < sql.length ? sql[i] : ' ' + if (ch === '(' || ch === ')') { + depth += ch === '(' ? 1 : -1 + wordStart = -1 + continue + } + if (/[A-Z0-9_]/.test(ch)) { + if (wordStart === -1) wordStart = i + continue + } + if (wordStart !== -1 && depth === 0 && CTE_TAIL_KEYWORDS.has(sql.slice(wordStart, i))) { + return wordStart + } + wordStart = -1 + } + return null +} + +/** + * Classify the CTE bodies in a `WITH` prefix. PostgreSQL executes a data-modifying CTE + * even when the tail only selects from it, so `WITH x AS (INSERT …) SELECT * FROM x` + * is a write — the tail alone does not decide. + * A nested `WITH` inside a body is not worth parsing; it fails closed as `unknown`. + */ +function classifyCteBodies(prefix: string): StatementKind { + let depth = 0 + + for (let i = 0; i < prefix.length; i++) { + const ch = prefix[i] + if (ch === ')') { + depth-- + continue + } + if (ch !== '(') continue + depth++ + if (depth !== 1) continue + + const head = prefix.slice(i + 1).match(/^\s*([A-Z_]+)/)?.[1] + if (!head) continue + if (CTE_BODY_WRITE_KEYWORDS.has(head)) return 'write' + if (DDL_KEYWORDS.has(head)) return 'ddl' + if (head === 'WITH') return 'unknown' + } + return 'read' +} + +/** + * A PRAGMA that assigns (`= x`) or takes an argument (`query_only(0)`) changes state. + * Introspection pragmas take an argument too, so those are named explicitly. + */ +function classifyPragma(stmt: string): StatementKind { + if (stmt.includes('=')) return 'unknown' + const match = stmt.match(/^PRAGMA\s+(?:[A-Z0-9_]+\s*\.\s*)?([A-Z0-9_]+)\s*(\()?/) + if (!match) return 'unknown' + if (!match[2]) return 'read' + return READ_ONLY_PRAGMAS.has(match[1]) ? 'read' : 'unknown' +} + +/** + * EXPLAIN alone never executes its statement, so it reads. + * EXPLAIN ANALYZE does execute it — classify by the statement being explained. + */ +function classifyExplain(sql: string): StatementKind { + let rest = sql.slice('EXPLAIN'.length).trim() + let analyze = false + + if (rest.startsWith('(')) { + const close = findMatchingParen(rest) + if (close === -1) return 'unknown' + const options = rest.slice(1, close) + analyze = /\bANALYZE\b/.test(options) && !/\bANALYZE\s+(FALSE|OFF|0)\b/.test(options) + rest = rest.slice(close + 1).trim() + } else { + // Vendor modifiers: PG `ANALYZE`/`VERBOSE`, SQLite `QUERY PLAN`, MySQL `FORMAT=JSON` + while (true) { + const match = rest.match(/^(ANALYZE|VERBOSE|QUERY PLAN|EXTENDED|PARTITIONS|FORMAT\s*=\s*[A-Z]+)\b\s*/) + if (!match) break + if (match[1] === 'ANALYZE') analyze = true + rest = rest.slice(match[0].length) + } + } + + if (!analyze) return 'read' + if (rest.length === 0) return 'unknown' + return classifyNormalized(rest) +} + +/** Index of the `)` matching the `(` at position 0, or -1 when unbalanced. */ +function findMatchingParen(sql: string): number { + let depth = 0 + for (let i = 0; i < sql.length; i++) { + if (sql[i] === '(') depth++ + else if (sql[i] === ')' && --depth === 0) return i + } + return -1 +} + /** * Detect if a SQL statement is a DELETE or UPDATE without a WHERE clause. * Returns true if the statement would affect all rows in the table. diff --git a/src/shared/types/errors.ts b/src/shared/types/errors.ts index 26d7751c..d92729d0 100644 --- a/src/shared/types/errors.ts +++ b/src/shared/types/errors.ts @@ -23,6 +23,8 @@ export type DatabaseErrorCode = | 'TRANSACTION_ABORTED' | 'COMMIT_UNCERTAIN' | 'STATEMENT_UNCERTAIN' + | 'QUERY_CANCELED' + | 'READ_ONLY_SESSION' | 'UNKNOWN' /** Base domain error with a typed code for programmatic handling */ @@ -135,6 +137,10 @@ export function friendlyMessageForCode(code: DatabaseErrorCode, rawMessage: stri return 'Commit status unknown — the connection was lost before confirmation. Your data may have been saved. Please verify before retrying.' case 'STATEMENT_UNCERTAIN': return 'Statement may have completed — the timeout fired but the server may have already executed the statement. Verify your data before retrying.' + case 'QUERY_CANCELED': + return 'Query cancelled — it hit the statement timeout or was cancelled explicitly' + case 'READ_ONLY_SESSION': + return 'This session is read-only — writes must be proposed for approval' case 'UNKNOWN': return rawMessage || 'An unexpected error occurred' } diff --git a/src/shared/types/rpc.ts b/src/shared/types/rpc.ts index f03bc4b9..8bd1e993 100644 --- a/src/shared/types/rpc.ts +++ b/src/shared/types/rpc.ts @@ -11,8 +11,104 @@ export interface SessionInfo { inTransaction: boolean txAborted: boolean createdAt: number + /** Session is read-only at the engine level — set for agent/CLI sessions. */ + readOnly?: boolean } +// ---- Agent CLI types (see docs/agent-cli.md) ---- + +export type ProposalStatus = + | 'pending' + | 'approved' + | 'rejected' + | 'executed' + | 'failed' + | 'cancelled' + | 'expired' + +/** + * A write submitted by the CLI for the user to approve in the app. + * The CLI never executes writes itself. + */ +export interface Proposal { + id: string + connectionId: string + database?: string + sql: string + reason?: string + status: ProposalStatus + createdAt: number + resolvedAt?: number + result?: { affectedRows?: number; statements?: number } + error?: string +} + +export interface ProposeWriteParams { + connectionId: string + database?: string + sql: string + reason?: string +} + +export interface ProposalListParams { + status?: ProposalStatus + connectionId?: string +} + +export interface ProposalResolveParams { + proposalId: string + status: ProposalStatus + result?: { affectedRows?: number; statements?: number } + error?: string +} + +export interface AgentHelloResult { + version: string + mode: 'desktop' | 'web' | 'demo' + pid: number + protocol: number +} + +export interface AgentQueryParams { + connectionId: string + database?: string + sql: string + queryId: string + params?: unknown[] + searchPath?: string +} + +export interface AgentSchemaParams { + connectionId: string + database?: string +} + +export type AgentSearchParams = Omit + +/** One open tab, as reported to the CLI by `ui.state`. */ +export interface UiTabSnapshot { + id: string + type: string + title: string + connectionId: string + database?: string + schema?: string + table?: string + /** Current editor contents — SQL console tabs only. */ + sql?: string +} + +export interface UiSnapshot { + tabs: UiTabSnapshot[] + activeTabId: string | null + activeConnectionId: string | null + updatedAt: number +} + +export type UiCommandPayload = + | { kind: 'open-table'; connectionId: string; database?: string; schema: string; table: string; where?: string; limit?: number } + | { kind: 'open-console'; connectionId: string; database?: string; sql?: string; run?: boolean } + // ---- Open connection handle diagnostics ---- export type ConnectionHandleRole = diff --git a/tests/agent-cli-surface.test.ts b/tests/agent-cli-surface.test.ts new file mode 100644 index 00000000..2d65c895 --- /dev/null +++ b/tests/agent-cli-surface.test.ts @@ -0,0 +1,104 @@ +import { CLI_ALLOWED_METHODS, createCliHandlerLookup } from '@dotaz/backend-shared/rpc/cli-surface' +import type { RpcHandler } from '@dotaz/backend-shared/rpc/dispatch' +import type { ConnectionInfo } from '@dotaz/shared/types/connection' +import { describe, expect, test } from 'bun:test' + +function connection(overrides?: Partial): ConnectionInfo { + return { + id: 'c1', + name: 'prod', + config: { + type: 'postgresql', + host: 'db.example.com', + port: 5432, + database: 'app', + user: 'app', + password: 'hunter2', + sshTunnel: { + enabled: true, + host: 'bastion.example.com', + port: 22, + username: 'deploy', + authMethod: 'password', + password: 'bastion-secret', + }, + }, + state: 'disconnected', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + } +} + +describe('CLI surface', () => { + test('forbidden methods are not reachable', () => { + const handlers: Record = { + 'connections.delete': () => 'deleted', + 'storage.decryptConfig': () => 'secret', + 'settings.set': () => undefined, + 'import.importData': () => undefined, + } + const lookup = createCliHandlerLookup(handlers) + + for (const method of Object.keys(handlers)) { + expect(lookup(method)).toBeUndefined() + } + }) + + test('an allowlisted method that does not exist looks the same as a forbidden one', () => { + const lookup = createCliHandlerLookup({}) + expect(lookup('agent.query')).toBeUndefined() + expect(lookup('connections.delete')).toBeUndefined() + }) + + test('allowlisted methods pass params and results through', async () => { + const lookup = createCliHandlerLookup({ 'agent.query': (params: { sql: string }) => ({ echoed: params.sql }) }) + const handler = lookup('agent.query') + + expect(handler).toBeDefined() + expect(await handler!({ sql: 'SELECT 1' })).toEqual({ echoed: 'SELECT 1' }) + }) + + test('session ids and generic data methods never cross the CLI boundary', () => { + for (const method of ['session.create', 'session.destroy', 'session.list', 'query.execute', 'schema.load', 'search.searchDatabase']) { + expect(CLI_ALLOWED_METHODS.has(method)).toBe(false) + } + for (const method of ['agent.query', 'agent.schema', 'agent.search']) { + expect(CLI_ALLOWED_METHODS.has(method)).toBe(true) + } + }) + + test('ui.runCommand is unreachable — it could run any registered command in a writable session', () => { + expect(CLI_ALLOWED_METHODS.has('ui.runCommand')).toBe(false) + }) + + test('connections.list never leaks a password', async () => { + const lookup = createCliHandlerLookup({ 'connections.list': () => [connection()] }) + const result = await lookup('connections.list')!({}) + + expect(Array.isArray(result)).toBe(true) + const [listed] = Array.isArray(result) ? result : [] + expect(listed).toMatchObject({ config: { type: 'postgresql', host: 'db.example.com', password: '' } }) + expect(JSON.stringify(result)).not.toContain('hunter2') + expect(JSON.stringify(result)).not.toContain('bastion-secret') + }) + + test('the write surface stays off the allowlist', () => { + for ( + const method of [ + 'connections.create', + 'connections.update', + 'connections.delete', + 'settings.set', + 'import.importData', + 'export.exportData', + 'storage.decryptConfig', + 'storage.encryptSecrets', + 'ui.snapshot.set', + 'agent.proposals.resolve', + ] + ) { + expect(CLI_ALLOWED_METHODS.has(method)).toBe(false) + } + }) +}) diff --git a/tests/agent-handlers.test.ts b/tests/agent-handlers.test.ts new file mode 100644 index 00000000..3bd751fc --- /dev/null +++ b/tests/agent-handlers.test.ts @@ -0,0 +1,318 @@ +import { createHandlers } from '@dotaz/backend-shared/rpc/rpc-handlers' +import { ConnectionManager } from '@dotaz/backend-shared/services/connection-manager' +import { AppDatabase } from '@dotaz/backend-shared/storage/app-db' +import type { SqliteConnectionConfig } from '@dotaz/shared/types/connection' +import type { UiSnapshot } from '@dotaz/shared/types/rpc' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const sqliteConfig: SqliteConnectionConfig = { type: 'sqlite', path: ':memory:' } + +interface Emitted { + channel: string + payload: unknown +} + +function setup() { + AppDatabase.resetInstance() + const appDb = AppDatabase.getInstance(':memory:') + const cm = new ConnectionManager(appDb) + const tempDir = mkdtempSync(join(tmpdir(), 'dotaz-agent-handlers-')) + const emitted: Emitted[] = [] + const { handlers, adapter, sessionManager } = createHandlers(cm, undefined, appDb, undefined, { + emitMessage: (channel, payload) => emitted.push({ channel, payload }), + appVersion: '1.2.3', + mode: 'desktop', + }) + const connection = handlers['connections.create']({ name: 'Test SQLite', config: { type: 'sqlite', path: join(tempDir, 'test.db') } }) + return { adapter, appDb, cm, connectionId: connection.id, emitted, handlers, sessionManager, tempDir } +} + +function payloadsOn(emitted: Emitted[], channel: string): unknown[] { + return emitted.filter((m) => m.channel === channel).map((m) => m.payload) +} + +describe('Agent CLI handlers', () => { + let ctx: ReturnType + + beforeEach(() => { + ctx = setup() + }) + + afterEach(async () => { + ctx.adapter.dispose() + await ctx.cm.disconnectAll() + AppDatabase.resetInstance() + rmSync(ctx.tempDir, { recursive: true, force: true }) + }) + + // ── agent.hello ────────────────────────────────────── + + test('agent.hello reports version, mode, pid and protocol', () => { + const hello = ctx.handlers['agent.hello']() + + expect(hello).toEqual({ version: '1.2.3', mode: 'desktop', pid: process.pid, protocol: 1 }) + }) + + test('agent.hello falls back to 0.0.0/web when the entry point supplied nothing', () => { + AppDatabase.resetInstance() + const appDb = AppDatabase.getInstance(':memory:') + const cm = new ConnectionManager(appDb) + const { handlers } = createHandlers(cm, undefined, appDb) + + const hello = handlers['agent.hello']() + + expect(hello.version).toBe('0.0.0') + expect(hello.mode).toBe('web') + }) + + // ── backend-owned read sessions ───────────────────── + + test('agent data methods own and release their read-only sessions', async () => { + await ctx.handlers['connections.connect']({ connectionId: ctx.connectionId }) + await ctx.handlers['query.execute']({ + connectionId: ctx.connectionId, + queryId: 'seed-schema', + sql: 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)', + }) + await ctx.handlers['query.execute']({ + connectionId: ctx.connectionId, + queryId: 'seed-row', + sql: "INSERT INTO items (name) VALUES ('Widget')", + }) + + const schema = await ctx.handlers['agent.schema']({ connectionId: ctx.connectionId }) + expect(Object.values(schema.tables).flat().some((table) => table.name === 'items')).toBe(true) + expect(ctx.sessionManager.listSessions(ctx.connectionId)).toEqual([]) + + const results = await ctx.handlers['agent.query']({ + connectionId: ctx.connectionId, + queryId: 'agent-read', + sql: 'SELECT name FROM items', + }) + expect(results[0].rows).toEqual([{ name: 'Widget' }]) + expect(ctx.sessionManager.listSessions(ctx.connectionId)).toEqual([]) + + const search = await ctx.handlers['agent.search']({ + connectionId: ctx.connectionId, + searchTerm: 'Widget', + scope: 'database', + resultsPerTable: 10, + }) + expect(search.totalMatches).toBe(1) + expect(ctx.sessionManager.listSessions(ctx.connectionId)).toEqual([]) + + await expect(ctx.handlers['agent.query']({ + connectionId: ctx.connectionId, + queryId: 'agent-write', + sql: "UPDATE items SET name = 'Changed'", + })).rejects.toThrow(/read-only/) + expect(ctx.sessionManager.listSessions(ctx.connectionId)).toEqual([]) + }) + + // ── agent.proposeWrite ─────────────────────────────── + + test('agent.proposeWrite emits cli.proposal and stores a pending proposal', () => { + const { proposalId } = ctx.handlers['agent.proposeWrite']({ + connectionId: ctx.connectionId, + sql: ' DELETE FROM users WHERE id = 1 ', + reason: 'cleanup', + }) + + const emitted = payloadsOn(ctx.emitted, 'cli.proposal') + expect(emitted).toHaveLength(1) + expect(emitted[0]).toMatchObject({ + id: proposalId, + status: 'pending', + connectionId: ctx.connectionId, + sql: 'DELETE FROM users WHERE id = 1', + reason: 'cleanup', + }) + + const stored = ctx.handlers['agent.proposals.get']({ proposalId }) + expect(stored.id).toBe(proposalId) + expect(stored.connectionId).toBe(ctx.connectionId) + expect(stored.status).toBe('pending') + }) + + test('agent.proposeWrite rejects an empty sql', () => { + expect(() => ctx.handlers['agent.proposeWrite']({ connectionId: ctx.connectionId, sql: ' ' })).toThrow(/sql is required/) + expect(ctx.emitted).toHaveLength(0) + }) + + test('agent.proposeWrite rejects an unknown connectionId', () => { + expect(() => ctx.handlers['agent.proposeWrite']({ connectionId: 'missing', sql: 'DELETE FROM users' })).toThrow(/Unknown connection/) + expect(() => ctx.handlers['agent.proposeWrite']({ connectionId: '', sql: 'DELETE FROM users' })).toThrow(/connectionId is required/) + }) + + // ── agent.proposals.* ──────────────────────────────── + + test('agent.proposals.list filters by status and connectionId', () => { + const other = ctx.handlers['connections.create']({ name: 'Other', config: sqliteConfig }) + const first = ctx.handlers['agent.proposeWrite']({ connectionId: ctx.connectionId, sql: 'DELETE FROM a' }) + ctx.handlers['agent.proposeWrite']({ connectionId: other.id, sql: 'DELETE FROM b' }) + ctx.handlers['agent.proposals.resolve']({ proposalId: first.proposalId, status: 'executed', result: { affectedRows: 2 } }) + + expect(ctx.handlers['agent.proposals.list']()).toHaveLength(2) + expect(ctx.handlers['agent.proposals.list']({ status: 'pending' })).toHaveLength(1) + expect(ctx.handlers['agent.proposals.list']({ status: 'executed' })[0].id).toBe(first.proposalId) + expect(ctx.handlers['agent.proposals.list']({ connectionId: other.id })).toHaveLength(1) + }) + + test('agent.proposals.get throws for an unknown id', () => { + expect(() => ctx.handlers['agent.proposals.get']({ proposalId: 'nope' })).toThrow(/not found/) + }) + + test('agent.proposals.resolve records the outcome and refuses a second resolve', () => { + const { proposalId } = ctx.handlers['agent.proposeWrite']({ connectionId: ctx.connectionId, sql: 'DELETE FROM a' }) + + const resolved = ctx.handlers['agent.proposals.resolve']({ proposalId, status: 'failed', error: 'boom' }) + expect(resolved.status).toBe('failed') + expect(resolved.error).toBe('boom') + + expect(() => ctx.handlers['agent.proposals.resolve']({ proposalId, status: 'executed' })).toThrow(/already failed/) + }) + + test('agent.proposals.resolve refuses an illegal target status', () => { + const { proposalId } = ctx.handlers['agent.proposeWrite']({ connectionId: ctx.connectionId, sql: 'DELETE FROM a' }) + + expect(() => ctx.handlers['agent.proposals.resolve']({ proposalId, status: 'pending' })).toThrow(/Cannot resolve/) + expect(ctx.handlers['agent.proposals.get']({ proposalId }).status).toBe('pending') + }) + + test('agent.proposals.cancel moves the proposal to cancelled', () => { + const { proposalId } = ctx.handlers['agent.proposeWrite']({ connectionId: ctx.connectionId, sql: 'DELETE FROM a' }) + + ctx.handlers['agent.proposals.cancel']({ proposalId }) + + expect(ctx.handlers['agent.proposals.get']({ proposalId }).status).toBe('cancelled') + }) + + test('agent.proposals.wait resolves once the frontend resolves the proposal', async () => { + const { proposalId } = ctx.handlers['agent.proposeWrite']({ connectionId: ctx.connectionId, sql: 'DELETE FROM a' }) + + const waiting = ctx.handlers['agent.proposals.wait']({ proposalId, timeoutMs: 5_000 }) + ctx.handlers['agent.proposals.resolve']({ proposalId, status: 'executed', result: { statements: 1 } }) + + const result = await waiting + expect(result.status).toBe('executed') + expect(result.result).toEqual({ statements: 1 }) + }) + + test('agent.proposals.wait returns the pending proposal on timeout', async () => { + const { proposalId } = ctx.handlers['agent.proposeWrite']({ connectionId: ctx.connectionId, sql: 'DELETE FROM a' }) + + expect((await ctx.handlers['agent.proposals.wait']({ proposalId, timeoutMs: 20 })).status).toBe('pending') + }) + + test('agent.proposals.wait rejects a negative timeout', async () => { + const { proposalId } = ctx.handlers['agent.proposeWrite']({ connectionId: ctx.connectionId, sql: 'DELETE FROM a' }) + + await expect(ctx.handlers['agent.proposals.wait']({ proposalId, timeoutMs: -1 })).rejects.toThrow(/non-negative/) + }) + + // ── ui.* ───────────────────────────────────────────── + + test('ui.openConsole emits cli.command', () => { + const result = ctx.handlers['ui.openConsole']({ connectionId: ctx.connectionId, sql: 'SELECT 1', run: true }) + + expect(result).toEqual({ ok: true }) + const commands = payloadsOn(ctx.emitted, 'cli.command') + expect(commands).toHaveLength(1) + expect(commands[0]).toMatchObject({ kind: 'open-console', connectionId: ctx.connectionId, sql: 'SELECT 1', run: true }) + }) + + test('ui.openConsole rejects run without sql and unknown connections', () => { + expect(() => ctx.handlers['ui.openConsole']({ connectionId: ctx.connectionId, run: true })).toThrow(/run requires sql/) + expect(() => ctx.handlers['ui.openConsole']({ connectionId: 'missing' })).toThrow(/Unknown connection/) + expect(ctx.emitted).toHaveLength(0) + }) + + test('ui.openTable emits cli.command with the table coordinates', () => { + ctx.handlers['ui.openTable']({ connectionId: ctx.connectionId, schema: 'public', table: 'users', where: 'id > 1', limit: 10 }) + + expect(payloadsOn(ctx.emitted, 'cli.command')[0]).toMatchObject({ + kind: 'open-table', + connectionId: ctx.connectionId, + schema: 'public', + table: 'users', + where: 'id > 1', + limit: 10, + }) + }) + + test('ui.openTable defaults the schema — SQLite paths omit it', () => { + ctx.handlers['ui.openTable']({ connectionId: ctx.connectionId, table: 'users' }) + + expect(payloadsOn(ctx.emitted, 'cli.command')[0]).toMatchObject({ kind: 'open-table', schema: '', table: 'users' }) + }) + + test('ui.openTable validates table and limit', () => { + expect(() => ctx.handlers['ui.openTable']({ connectionId: ctx.connectionId, table: ' ' })).toThrow(/table is required/) + expect(() => ctx.handlers['ui.openTable']({ connectionId: ctx.connectionId, table: 'users', limit: 0 })).toThrow(/positive integer/) + expect(ctx.emitted).toHaveLength(0) + }) + + // This guard is the whole of invariant I1 on the auto-run path — the frontend does not + // re-check. Every case here reached the database before the review that added them. + test.each([ + ['a plain write', 'DELETE FROM orders'], + ['a data-modifying CTE', "WITH x AS (INSERT INTO users(name) VALUES ('p') RETURNING id) SELECT * FROM x"], + ['a deleting CTE', 'WITH x AS (DELETE FROM orders RETURNING id) SELECT * FROM x'], + ['SELECT … INTO', 'SELECT * INTO stolen FROM users'], + ['INTO OUTFILE', "SELECT * FROM users INTO OUTFILE '/tmp/users'"], + ['a GUC rewrite', "SELECT set_config('default_transaction_read_only','off',false)"], + ['a pragma with an argument', 'PRAGMA query_only(0)'], + ['a trailing statement', 'SELECT 1; DELETE FROM orders'], + ])('ui.openConsole refuses to auto-run %s', (_label, sql) => { + expect(() => ctx.handlers['ui.openConsole']({ connectionId: ctx.connectionId, sql, run: true })) + .toThrow(/Only read-only SQL can be auto-run/) + expect(ctx.emitted).toHaveLength(0) + }) + + test('ui.openConsole still prefills a write when it is not asked to run it', () => { + ctx.handlers['ui.openConsole']({ connectionId: ctx.connectionId, sql: 'DELETE FROM orders' }) + + expect(payloadsOn(ctx.emitted, 'cli.command')[0]).toMatchObject({ kind: 'open-console', sql: 'DELETE FROM orders' }) + }) + + test('ui.openTable rejects a where fragment that is not a boolean expression', () => { + const bad = [ + '1=1); DELETE FROM orders; --', + 'id > 1; DROP TABLE users', + 'id > 1 -- ', + 'id > 1 /* x */', + 'id > 1)', + ] + for (const where of bad) { + expect(() => ctx.handlers['ui.openTable']({ connectionId: ctx.connectionId, table: 'users', where })) + .toThrow(/where /) + } + expect(ctx.emitted).toHaveLength(0) + }) + + test('ui.openTable accepts ordinary filters, including quoted literals', () => { + for (const where of ["status='new'", "name = 'a;b'", 'id > 1 AND (a = 2 OR b = 3)', "note = 'it''s fine'"]) { + expect(() => ctx.handlers['ui.openTable']({ connectionId: ctx.connectionId, table: 'users', where })).not.toThrow() + } + }) + + test('ui.state returns an empty snapshot until the frontend publishes one', () => { + expect(ctx.handlers['ui.state']()).toEqual({ tabs: [], activeTabId: null, activeConnectionId: null, updatedAt: 0 }) + }) + + test('ui.state round-trips a snapshot set via ui.snapshot.set', () => { + const snapshot: UiSnapshot = { + tabs: [{ id: 'tab-1', type: 'sql', title: 'Console', connectionId: ctx.connectionId, sql: 'SELECT 1' }], + activeTabId: 'tab-1', + activeConnectionId: ctx.connectionId, + updatedAt: 1_700_000_000_000, + } + + ctx.handlers['ui.snapshot.set']({ snapshot }) + + expect(ctx.handlers['ui.state']()).toEqual(snapshot) + }) +}) diff --git a/tests/agent-proposals-store.test.ts b/tests/agent-proposals-store.test.ts new file mode 100644 index 00000000..cf2da637 --- /dev/null +++ b/tests/agent-proposals-store.test.ts @@ -0,0 +1,124 @@ +import { + decideProposalMessage, + invalidationReason, + isStaleProposalError, + type ProposalPhase, + sqlMatchesProposal, +} from '@dotaz/frontend-shared/lib/proposal-state' +import type { Proposal, ProposalStatus } from '@dotaz/shared/types/rpc' +import { describe, expect, test } from 'bun:test' + +function proposal(status: ProposalStatus): Proposal { + return { id: 'p1', connectionId: 'c1', sql: 'delete from users', status, createdAt: 1717430000000 } +} + +function entry(phase: ProposalPhase): { phase: ProposalPhase } { + return { phase } +} + +describe('decideProposalMessage', () => { + test('opens a tab for a new pending proposal', () => { + expect(decideProposalMessage(undefined, proposal('pending'))).toEqual({ kind: 'open' }) + }) + + test('focuses the existing tab when the same pending proposal arrives twice', () => { + expect(decideProposalMessage(entry('pending'), proposal('pending'))).toEqual({ kind: 'focus' }) + }) + + test('ignores a non-pending proposal the app never opened', () => { + for (const status of ['cancelled', 'expired', 'executed', 'rejected', 'failed', 'approved'] as const) { + expect(decideProposalMessage(undefined, proposal(status))).toEqual({ kind: 'ignore' }) + } + }) + + test('invalidates a pending banner when the agent cancels', () => { + const action = decideProposalMessage(entry('pending'), proposal('cancelled')) + expect(action).toEqual({ kind: 'invalidate', reason: invalidationReason('cancelled') }) + }) + + test('invalidates a pending banner on expiry', () => { + const action = decideProposalMessage(entry('pending'), proposal('expired')) + expect(action.kind).toBe('invalidate') + expect(action.kind === 'invalidate' && action.reason).toContain('expired') + }) + + test('invalidates a pending banner when another window resolved it', () => { + const action = decideProposalMessage(entry('pending'), proposal('executed')) + expect(action.kind).toBe('invalidate') + expect(action.kind === 'invalidate' && action.reason).toContain('another window') + }) + + test('invalidates while the app is still checking whether it may run', () => { + expect(decideProposalMessage(entry('checking'), proposal('cancelled')).kind).toBe('invalidate') + }) + + test('leaves a running entry alone — the statement is already in flight', () => { + expect(decideProposalMessage(entry('running'), proposal('cancelled'))).toEqual({ kind: 'ignore' }) + }) + + test('keeps the outcome this window produced', () => { + expect(decideProposalMessage(entry('resolved'), proposal('cancelled'))).toEqual({ kind: 'ignore' }) + expect(decideProposalMessage(entry('resolved'), proposal('executed'))).toEqual({ kind: 'ignore' }) + }) + + test('an already invalidated banner is not invalidated again', () => { + expect(decideProposalMessage(entry('invalidated'), proposal('expired'))).toEqual({ kind: 'ignore' }) + }) +}) + +describe('invalidationReason', () => { + test('every terminal status gets its own copy', () => { + const reasons = (['cancelled', 'expired', 'rejected', 'approved', 'executed', 'failed'] as const) + .map((status) => invalidationReason(status)) + expect(new Set(reasons).size).toBe(reasons.length) + for (const reason of reasons) expect(reason.length).toBeGreaterThan(0) + }) + + test('names the agent for a cancellation and says nothing ran', () => { + expect(invalidationReason('cancelled')).toContain('agent cancelled') + expect(invalidationReason('cancelled')).toContain('Nothing was run') + }) +}) + +describe('isStaleProposalError', () => { + test('recognizes the backend refusing an already resolved proposal', () => { + expect(isStaleProposalError(new Error('Proposal 7f3 is already cancelled'))).toBe(true) + expect(isStaleProposalError(new Error('agent.proposals.resolve: Proposal 7f3 is already expired'))).toBe(true) + expect(isStaleProposalError(new Error('Proposal 7f3 is already executed'))).toBe(true) + }) + + test('recognizes a proposal the backend no longer knows', () => { + expect(isStaleProposalError(new Error('agent.proposals.get: Proposal not found: 7f3'))).toBe(true) + }) + + test('leaves real failures to the toast', () => { + expect(isStaleProposalError(new Error('Failed to fetch'))).toBe(false) + expect(isStaleProposalError(new Error('connection closed'))).toBe(false) + expect(isStaleProposalError(undefined)).toBe(false) + }) +}) + +// The run listener matches on the tab alone, so this is what stops "the user ran something in +// this tab" from being reported to the agent as "your write executed". +describe('sqlMatchesProposal', () => { + const proposed = "UPDATE orders SET status='paid' WHERE id=42" + + test('accepts the proposal SQL, including editor reformatting', () => { + expect(sqlMatchesProposal(proposed, proposed)).toBe(true) + expect(sqlMatchesProposal(` ${proposed} `, proposed)).toBe(true) + expect(sqlMatchesProposal(`${proposed};`, proposed)).toBe(true) + expect(sqlMatchesProposal("UPDATE orders SET status='paid'\n WHERE id=42", proposed)).toBe(true) + }) + + test('rejects an edited buffer', () => { + expect(sqlMatchesProposal("UPDATE orders SET status='paid' WHERE id=43", proposed)).toBe(false) + expect(sqlMatchesProposal("UPDATE orders SET status='paid'", proposed)).toBe(false) + expect(sqlMatchesProposal('', proposed)).toBe(false) + }) + + test('rejects one statement of a multi-statement proposal run on its own', () => { + const multi = 'DELETE FROM a; DELETE FROM b' + expect(sqlMatchesProposal('DELETE FROM a', multi)).toBe(false) + expect(sqlMatchesProposal(multi, multi)).toBe(true) + }) +}) diff --git a/tests/agent-ui-snapshot.test.ts b/tests/agent-ui-snapshot.test.ts new file mode 100644 index 00000000..1f67e47c --- /dev/null +++ b/tests/agent-ui-snapshot.test.ts @@ -0,0 +1,95 @@ +import { summarizeProposalRun } from '@dotaz/frontend-shared/lib/agent-proposals' +import { buildUiSnapshot } from '@dotaz/frontend-shared/lib/ui-snapshot' +import type { QueryResult } from '@dotaz/shared/types/query' +import type { TabInfo } from '@dotaz/shared/types/tab' +import { describe, expect, test } from 'bun:test' + +function result(overrides: Partial = {}): QueryResult { + return { columns: [], rows: [], rowCount: 0, durationMs: 1, ...overrides } +} + +function tab(overrides: Partial & { id: string }): TabInfo { + return { type: 'data-grid', title: 'users', connectionId: 'c1', ...overrides } +} + +describe('summarizeProposalRun', () => { + test('sums affected rows across statements', () => { + const outcome = summarizeProposalRun([ + result({ affectedRows: 3 }), + result({ affectedRows: 2 }), + ]) + expect(outcome).toEqual({ status: 'executed', result: { affectedRows: 5, statements: 2 } }) + }) + + test('missing affectedRows counts as zero', () => { + const outcome = summarizeProposalRun([result(), result({ affectedRows: 1 })]) + expect(outcome.status).toBe('executed') + expect(outcome.result).toEqual({ affectedRows: 1, statements: 2 }) + }) + + test('a per-statement error fails the whole proposal', () => { + const outcome = summarizeProposalRun([ + result({ affectedRows: 1 }), + result({ error: 'syntax error at or near ")"' }), + ]) + expect(outcome.status).toBe('failed') + expect(outcome.error).toBe('syntax error at or near ")"') + expect(outcome.result).toEqual({ affectedRows: 1, statements: 2 }) + }) + + test('no results is an executed no-op', () => { + expect(summarizeProposalRun([])).toEqual({ status: 'executed', result: { affectedRows: 0, statements: 0 } }) + }) +}) + +describe('buildUiSnapshot', () => { + const tabs: TabInfo[] = [ + tab({ id: 't1', schema: 'public', table: 'users', database: 'app' }), + tab({ id: 't2', type: 'sql-console', title: 'SQL — app', database: 'app' }), + tab({ id: 't3', type: 'sql-console', title: 'SQL — empty' }), + ] + + const snapshot = buildUiSnapshot({ + tabs, + activeTabId: 't2', + activeConnectionId: 'c1', + getSql: (tabId) => (tabId === 't2' ? 'select 1' : ''), + now: 1717430000000, + }) + + test('maps table coordinates for grid tabs', () => { + expect(snapshot.tabs[0]).toEqual({ + id: 't1', + type: 'data-grid', + title: 'users', + connectionId: 'c1', + database: 'app', + schema: 'public', + table: 'users', + }) + }) + + test('includes editor contents for console tabs only', () => { + expect(snapshot.tabs[1].sql).toBe('select 1') + expect(snapshot.tabs[0].sql).toBeUndefined() + // Empty editors stay out of the payload rather than shipping empty strings. + expect(snapshot.tabs[2].sql).toBeUndefined() + }) + + test('carries the active tab, connection and timestamp', () => { + expect(snapshot.activeTabId).toBe('t2') + expect(snapshot.activeConnectionId).toBe('c1') + expect(snapshot.updatedAt).toBe(1717430000000) + }) + + test('no open tabs yields an empty snapshot', () => { + const empty = buildUiSnapshot({ + tabs: [], + activeTabId: null, + activeConnectionId: null, + getSql: () => undefined, + now: 1, + }) + expect(empty).toEqual({ tabs: [], activeTabId: null, activeConnectionId: null, updatedAt: 1 }) + }) +}) diff --git a/tests/cli-agent-args.test.ts b/tests/cli-agent-args.test.ts new file mode 100644 index 00000000..5d9b885c --- /dev/null +++ b/tests/cli-agent-args.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, test } from 'bun:test' +import { + flagBool, + flagNumber, + flagOptionalNumber, + flagString, + flagStrings, + parseArgs, + requireNonNegativeInt, + requirePositiveInt, +} from '../src/cli-agent/args' +import { extractCommand, mergeSpecs, outputOptions, parseInvocation, timeoutMs } from '../src/cli-agent/cli' +import { findCommand } from '../src/cli-agent/commands' +import { CliError, EXIT } from '../src/cli-agent/errors' + +const SPECS = { + where: { kind: 'string' as const, description: '' }, + limit: { kind: 'number' as const, description: '' }, + param: { kind: 'strings' as const, description: '' }, + analyze: { kind: 'boolean' as const, description: '' }, + wait: { kind: 'optionalNumber' as const, description: '' }, + quiet: { kind: 'boolean' as const, alias: 'q', description: '' }, + format: { kind: 'string' as const, alias: 'f', description: '' }, +} + +function exitCodeOf(fn: () => unknown): number | undefined { + try { + fn() + return undefined + } catch (err) { + return err instanceof CliError ? err.exitCode : undefined + } +} + +describe('parseArgs', () => { + test('collects positionals in order', () => { + const args = parseArgs(['prod/app', 'SELECT 1'], SPECS) + expect(args.positionals).toEqual(['prod/app', 'SELECT 1']) + }) + + test('reads string flags in both spellings', () => { + expect(flagString(parseArgs(['--where', "a='b'"], SPECS), 'where')).toBe("a='b'") + expect(flagString(parseArgs(["--where=a='b'"], SPECS), 'where')).toBe("a='b'") + }) + + test('reads number flags and rejects non-numbers', () => { + expect(flagNumber(parseArgs(['--limit', '20'], SPECS), 'limit')).toBe(20) + expect(exitCodeOf(() => parseArgs(['--limit', 'twenty'], SPECS))).toBe(EXIT.usage) + }) + + test('repeatable flags accumulate', () => { + expect(flagStrings(parseArgs(['--param', 'a', '--param', 'b'], SPECS), 'param')).toEqual(['a', 'b']) + }) + + test('boolean flags need no value', () => { + expect(flagBool(parseArgs(['--analyze'], SPECS), 'analyze')).toBe(true) + expect(flagBool(parseArgs([], SPECS), 'analyze')).toBe(false) + expect(flagBool(parseArgs(['--analyze=false'], SPECS), 'analyze')).toBe(false) + }) + + test('short aliases work', () => { + const args = parseArgs(['-q', '-f', 'csv'], SPECS) + expect(flagBool(args, 'quiet')).toBe(true) + expect(flagString(args, 'format')).toBe('csv') + }) + + test('optional-number flag takes a value only when one is present', () => { + expect(flagOptionalNumber(parseArgs(['--wait'], SPECS), 'wait')).toEqual({ present: true }) + expect(flagOptionalNumber(parseArgs(['--wait', '30'], SPECS), 'wait')).toEqual({ present: true, value: 30 }) + expect(flagOptionalNumber(parseArgs([], SPECS), 'wait')).toEqual({ present: false }) + }) + + test('optional-number flag does not swallow a following positional', () => { + const args = parseArgs(['--wait', 'prod'], SPECS) + expect(flagOptionalNumber(args, 'wait')).toEqual({ present: true }) + expect(args.positionals).toEqual(['prod']) + }) + + test('-- stops flag parsing', () => { + const args = parseArgs(['--analyze', '--', '--where', 'x'], SPECS) + expect(args.positionals).toEqual(['--where', 'x']) + }) + + test('unknown flags are a usage error', () => { + expect(exitCodeOf(() => parseArgs(['--nope'], SPECS))).toBe(EXIT.usage) + expect(exitCodeOf(() => parseArgs(['-z'], SPECS))).toBe(EXIT.usage) + }) + + test('missing values are a usage error', () => { + expect(exitCodeOf(() => parseArgs(['--where'], SPECS))).toBe(EXIT.usage) + }) +}) + +describe('integer guards', () => { + test('positive int', () => { + expect(requirePositiveInt('limit', 5)).toBe(5) + expect(requirePositiveInt('limit', undefined)).toBeUndefined() + expect(exitCodeOf(() => requirePositiveInt('limit', 0))).toBe(EXIT.usage) + expect(exitCodeOf(() => requirePositiveInt('limit', 1.5))).toBe(EXIT.usage) + }) + + test('non-negative int', () => { + expect(requireNonNegativeInt('offset', 0)).toBe(0) + expect(exitCodeOf(() => requireNonNegativeInt('offset', -1))).toBe(EXIT.usage) + }) +}) + +describe('extractCommand', () => { + test('finds a leading command', () => { + expect(extractCommand(['ls', 'prod'])).toEqual({ command: 'ls', rest: ['prod'] }) + }) + + test('skips global flags placed before the command', () => { + expect(extractCommand(['--json', 'ls', 'prod'])).toEqual({ command: 'ls', rest: ['--json', 'prod'] }) + expect(extractCommand(['--format', 'csv', 'rows', 'a/b'])).toEqual({ command: 'rows', rest: ['--format', 'csv', 'a/b'] }) + }) + + test('reports no command when argv holds only flags', () => { + expect(extractCommand(['--help'])).toEqual({ rest: ['--help'] }) + }) +}) + +describe('parseInvocation', () => { + const flagsFor = (name: string) => findCommand(name)?.flags + + test('parses command flags after the command', () => { + const invocation = parseInvocation(['rows', 'prod/app/public/orders', '--limit', '5'], flagsFor) + expect(invocation.command).toBe('rows') + expect(invocation.args.positionals).toEqual(['prod/app/public/orders']) + expect(flagNumber(invocation.args, 'limit')).toBe(5) + }) + + // A shadowing flag reads back through the global reader, which silently reinterprets it — + // `approvals --timeout 300` (seconds) became a 300ms RPC deadline. Now it cannot compile. + test('a command flag may not shadow a global one', () => { + expect(() => mergeSpecs({ timeout: { kind: 'number', description: 'seconds' } })).toThrow(/collides with a global flag/) + expect(() => mergeSpecs({ json: { kind: 'boolean', description: 'json' } })).toThrow(/collides with a global flag/) + }) + + test('approvals spells its wait in seconds under its own flag name', () => { + const invocation = parseInvocation(['approvals', 'wait', 'p1', '--wait', '10'], flagsFor) + expect(invocation.specs.wait?.placeholder).toBe('') + expect(invocation.specs.timeout?.placeholder).not.toBe('') + }) + + test('unknown command is a usage error', () => { + expect(exitCodeOf(() => parseInvocation(['bogus'], flagsFor))).toBe(EXIT.usage) + }) +}) + +describe('outputOptions', () => { + const flagsFor = (name: string) => findCommand(name)?.flags + + test('defaults to an aligned table with a 64 KiB cap', () => { + const opts = outputOptions(parseInvocation(['ls'], flagsFor).args) + expect(opts).toEqual({ format: 'table', maxBytes: 65536, quiet: false }) + }) + + test('--json is shorthand for --format json', () => { + expect(outputOptions(parseInvocation(['ls', '--json'], flagsFor).args).format).toBe('json') + }) + + test('--format wins over --json', () => { + expect(outputOptions(parseInvocation(['ls', '--json', '--format', 'csv'], flagsFor).args).format).toBe('csv') + }) + + test('unknown formats and bad caps are usage errors', () => { + expect(exitCodeOf(() => outputOptions(parseInvocation(['ls', '--format', 'yaml'], flagsFor).args))).toBe(EXIT.usage) + expect(exitCodeOf(() => outputOptions(parseInvocation(['ls', '--max-bytes', '0'], flagsFor).args))).toBe(EXIT.usage) + }) + + test('--timeout must be positive', () => { + expect(timeoutMs(parseInvocation(['ls'], flagsFor).args)).toBe(30_000) + expect(timeoutMs(parseInvocation(['ls', '--timeout', '1000'], flagsFor).args)).toBe(1000) + expect(exitCodeOf(() => timeoutMs(parseInvocation(['ls', '--timeout', '-1'], flagsFor).args))).toBe(EXIT.usage) + }) +}) diff --git a/tests/cli-agent-endpoint.test.ts b/tests/cli-agent-endpoint.test.ts new file mode 100644 index 00000000..1b0265f0 --- /dev/null +++ b/tests/cli-agent-endpoint.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, test } from 'bun:test' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + appDataDir, + candidateEndpointFiles, + discoverEndpoint, + type EndpointInfo, + listLiveInstances, + parseEndpointFile, + userDataRoot, +} from '../src/cli-agent/endpoint' +import { CliError, EXIT } from '../src/cli-agent/errors' + +const VALID: EndpointInfo = { + pid: 4242, + transport: 'unix', + socket: '/run/user/1000/dotaz-4242.sock', + port: null, + token: 'a'.repeat(64), + version: '0.0.42', + protocol: 1, + startedAt: 1717430000000, +} + +function fileFor(endpoint: Partial): string { + return JSON.stringify({ ...VALID, ...endpoint }) +} + +function errorOf(fn: () => unknown): CliError | undefined { + try { + fn() + return undefined + } catch (err) { + return err instanceof CliError ? err : undefined + } +} + +function exitCodeOf(fn: () => unknown): number | undefined { + return errorOf(fn)?.exitCode +} + +describe('userData resolution', () => { + test('matches Electrobun on each platform', () => { + expect(appDataDir('darwin', {}, '/Users/x')).toBe('/Users/x/Library/Application Support') + expect(appDataDir('linux', {}, '/home/x')).toBe('/home/x/.local/share') + expect(appDataDir('linux', { XDG_DATA_HOME: '/data' }, '/home/x')).toBe('/data') + expect(appDataDir('win32', { LOCALAPPDATA: 'C:\\local' }, 'C:\\Users\\x')).toBe('C:\\local') + }) + + test('the endpoint lives under //', () => { + expect(userDataRoot('linux', {}, '/home/x')).toBe('/home/x/.local/share/dotaz.electrobun.dev') + }) +}) + +describe('parseEndpointFile', () => { + test('accepts the documented shape', () => { + expect(parseEndpointFile(fileFor({}))).toEqual(VALID) + }) + + test('accepts a tcp endpoint', () => { + const parsed = parseEndpointFile(fileFor({ transport: 'tcp', socket: null, port: 51234 })) + expect(parsed?.transport).toBe('tcp') + expect(parsed?.port).toBe(51234) + }) + + test('rejects malformed or incomplete files', () => { + expect(parseEndpointFile('not json')).toBeNull() + expect(parseEndpointFile('[]')).toBeNull() + expect(parseEndpointFile(fileFor({ token: '' }))).toBeNull() + expect(parseEndpointFile(fileFor({ pid: 0 }))).toBeNull() + expect(parseEndpointFile(fileFor({ socket: null }))).toBeNull() + expect(parseEndpointFile(fileFor({ transport: 'tcp', socket: null, port: null }))).toBeNull() + }) +}) + +describe('candidateEndpointFiles', () => { + test('collects endpoint-.json from every channel, ignoring anything else', () => { + const root = mkdtempSync(join(tmpdir(), 'dotaz-endpoint-scan-')) + try { + mkdirSync(join(root, 'dev', 'cli'), { recursive: true }) + mkdirSync(join(root, 'stable', 'cli'), { recursive: true }) + // A channel that never enabled CLI access has no cli/ directory at all + mkdirSync(join(root, 'canary'), { recursive: true }) + writeFileSync(join(root, 'dev', 'cli', 'endpoint-11.json'), fileFor({ pid: 11 })) + writeFileSync(join(root, 'dev', 'cli', 'notes.json'), '{}') + writeFileSync(join(root, 'stable', 'cli', 'endpoint-22.json'), fileFor({ pid: 22 })) + + expect(candidateEndpointFiles(root).sort()).toEqual([ + join(root, 'dev', 'cli', 'endpoint-11.json'), + join(root, 'stable', 'cli', 'endpoint-22.json'), + ]) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + test('a missing root is not an error', () => { + expect(candidateEndpointFiles(join(tmpdir(), 'dotaz-does-not-exist-1234'))).toEqual([]) + }) +}) + +describe('discoverEndpoint', () => { + const base = { + platform: 'linux', + env: {}, + home: '/home/x', + readFile: () => fileFor({}), + pidAlive: () => true, + } + + const twoInstances = { + ...base, + listCandidates: () => ['/a.json', '/b.json'], + readFile: (file: string) => fileFor(file === '/b.json' ? { pid: 2, startedAt: 20 } : { pid: 1, startedAt: 10 }), + } + + test('uses an explicit file when given', () => { + const found = discoverEndpoint({ ...base, explicitFile: '/tmp/endpoint.json' }) + expect(found.file).toBe('/tmp/endpoint.json') + expect(found.endpoint.pid).toBe(4242) + expect(found.instances).toHaveLength(1) + }) + + test('DOTAZ_ENDPOINT overrides discovery', () => { + const found = discoverEndpoint({ ...base, env: { DOTAZ_ENDPOINT: '/tmp/from-env.json' } }) + expect(found.file).toBe('/tmp/from-env.json') + }) + + test('scans every channel directory under userData', () => { + const root = userDataRoot('linux', {}, '/home/x') + const file = join(root, 'dev', 'cli', 'endpoint-4242.json') + const found = discoverEndpoint({ ...base, listCandidates: () => [file] }) + expect(found.file).toBe(file) + }) + + test('prefers the most recently started live endpoint', () => { + const found = discoverEndpoint(twoInstances) + expect(found.file).toBe('/b.json') + }) + + test('reports every live instance, newest first', () => { + const found = discoverEndpoint(twoInstances) + expect(found.instances.map((i) => i.pid)).toEqual([2, 1]) + }) + + test('dead instances are left out of the instance list', () => { + const found = discoverEndpoint({ ...twoInstances, pidAlive: (pid: number) => pid === 1 }) + expect(found.endpoint.pid).toBe(1) + expect(found.instances.map((i) => i.pid)).toEqual([1]) + }) + + test('--instance selects a specific pid even when it is not the newest', () => { + const found = discoverEndpoint({ ...twoInstances, instancePid: 1 }) + expect(found.endpoint.pid).toBe(1) + expect(found.file).toBe('/a.json') + }) + + test('--instance with an unknown pid is a usage error listing the live ones', () => { + const err = errorOf(() => discoverEndpoint({ ...twoInstances, instancePid: 999 })) + expect(err?.exitCode).toBe(EXIT.usage) + expect(err?.message).toContain('999') + expect(err?.hint).toBe('Live instances: 2, 1') + }) + + test('--instance with a dead pid is a usage error', () => { + const err = errorOf(() => discoverEndpoint({ ...twoInstances, instancePid: 2, pidAlive: (pid: number) => pid === 1 })) + expect(err?.exitCode).toBe(EXIT.usage) + expect(err?.hint).toBe('Live instances: 1') + }) + + test('--instance beats DOTAZ_ENDPOINT but conflicts with --endpoint', () => { + const found = discoverEndpoint({ ...twoInstances, env: { DOTAZ_ENDPOINT: '/from-env.json' }, instancePid: 1 }) + expect(found.file).toBe('/a.json') + + const err = errorOf(() => discoverEndpoint({ ...twoInstances, instancePid: 1, explicitFile: '/x.json' })) + expect(err?.exitCode).toBe(EXIT.usage) + }) + + test('--instance must be a process id', () => { + expect(exitCodeOf(() => discoverEndpoint({ ...twoInstances, instancePid: 1.5 }))).toBe(EXIT.usage) + expect(exitCodeOf(() => discoverEndpoint({ ...twoInstances, instancePid: -3 }))).toBe(EXIT.usage) + }) + + test('a dead pid is exit 5', () => { + expect(exitCodeOf(() => discoverEndpoint({ ...base, listCandidates: () => ['/a.json'], pidAlive: () => false }))).toBe(EXIT.notRunning) + }) + + test('no endpoint file at all is exit 5', () => { + expect(exitCodeOf(() => discoverEndpoint({ ...base, listCandidates: () => [] }))).toBe(EXIT.notRunning) + }) + + test('an unreadable explicit file is exit 5', () => { + const code = exitCodeOf(() => + discoverEndpoint({ + ...base, + explicitFile: '/nope.json', + readFile: () => { + throw new Error('ENOENT') + }, + }) + ) + expect(code).toBe(EXIT.notRunning) + }) + + test('a malformed file is exit 5, not a crash', () => { + expect(exitCodeOf(() => discoverEndpoint({ ...base, listCandidates: () => ['/a.json'], readFile: () => '{' }))).toBe(EXIT.notRunning) + }) + + test('every exit-5 message tells the user how to enable CLI access', () => { + expect(errorOf(() => discoverEndpoint({ ...base, listCandidates: () => [] }))?.hint).toContain('Allow CLI access') + }) +}) + +describe('listLiveInstances', () => { + test('returns only live instances, newest first', () => { + const instances = listLiveInstances({ + platform: 'linux', + env: {}, + home: '/home/x', + listCandidates: () => ['/a.json', '/b.json', '/c.json'], + readFile: (file: string) => + fileFor(file === '/a.json' ? { pid: 1, startedAt: 10 } : file === '/b.json' ? { pid: 2, startedAt: 30 } : { pid: 3, startedAt: 20 }), + pidAlive: (pid: number) => pid !== 3, + }) + expect(instances.map((i) => i.endpoint.pid)).toEqual([2, 1]) + }) + + test('nothing running is an empty list, not an error', () => { + expect(listLiveInstances({ platform: 'linux', env: {}, home: '/home/x', listCandidates: () => [] })).toEqual([]) + }) +}) diff --git a/tests/cli-agent-exit-codes.test.ts b/tests/cli-agent-exit-codes.test.ts new file mode 100644 index 00000000..72330558 --- /dev/null +++ b/tests/cli-agent-exit-codes.test.ts @@ -0,0 +1,124 @@ +import type { ProposalStatus } from '@dotaz/shared/types/rpc' +import { describe, expect, test } from 'bun:test' +import { ConnectionLostError, RpcError } from '../src/cli-agent/client' +import { CliError, databaseError, EXIT, type ExitCode, notRunningError, readOnlyError, timeoutError, usageError } from '../src/cli-agent/errors' +import { exitCodeForProposal, proposalNote, type ProposalPoller, waitForProposal } from '../src/cli-agent/proposals' + +describe('exit code constants', () => { + test('match the documented contract', () => { + expect(EXIT.ok).toBe(0) + expect(EXIT.usage).toBe(2) + expect(EXIT.database).toBe(3) + expect(EXIT.readOnly).toBe(4) + expect(EXIT.notRunning).toBe(5) + expect(EXIT.timeout).toBe(6) + expect(EXIT.pending).toBe(7) + expect(EXIT.rejected).toBe(8) + }) +}) + +describe('error constructors', () => { + test('carry the right exit code', () => { + expect(usageError('x').exitCode).toBe(EXIT.usage) + expect(databaseError('x').exitCode).toBe(EXIT.database) + expect(readOnlyError('x').exitCode).toBe(EXIT.readOnly) + expect(notRunningError('x').exitCode).toBe(EXIT.notRunning) + expect(timeoutError('x').exitCode).toBe(EXIT.timeout) + }) + + test('a read-only violation always points at dotaz propose', () => { + expect(readOnlyError('nope').hint).toContain('dotaz propose') + }) + + test('"not running" tells the user how to turn CLI access on', () => { + const err = notRunningError('nothing here') + expect(err.hint).toContain('Start Dotaz') + expect(err.hint).toContain('Allow CLI access') + }) + + test('an RPC failure defaults to the database exit code', () => { + const err = new RpcError({ message: 'boom', errorCode: 'QUERY_SYNTAX' }) + expect(err instanceof CliError).toBe(true) + expect(err.exitCode).toBe(EXIT.database) + expect(err.errorCode).toBe('QUERY_SYNTAX') + }) + + test('a lost connection is still exit 5, but recognisable on its own', () => { + const err = new ConnectionLostError('socket closed') + expect(err instanceof CliError).toBe(true) + expect(err.exitCode).toBe(EXIT.notRunning) + }) +}) + +describe('waitForProposal', () => { + const pending = { id: 'p1', connectionId: 'c1', sql: 'DELETE FROM t', status: 'pending', createdAt: 0 } + + function poller(onWait: () => unknown): ProposalPoller { + return { + call: async (method) => { + if (method === 'agent.proposals.get') return pending + return onWait() + }, + } + } + + test('the app closing mid-wait exits 5 and says the proposal is gone', async () => { + const client = poller(() => { + throw new ConnectionLostError('Cannot reach the Dotaz control endpoint (socket closed)') + }) + try { + await waitForProposal(client, 'p1', 1_000) + expect.unreachable() + } catch (err) { + expect(err instanceof CliError && err.exitCode).toBe(EXIT.notRunning) + expect(err instanceof Error && err.message).toContain('Dotaz closed while proposal p1') + expect(err instanceof Error && err.message).toContain('gone') + expect(err instanceof CliError && err.hint).toContain('dotaz propose') + } + }) + + test('an unrelated failure is passed through untouched', async () => { + const client = poller(() => { + throw new RpcError({ message: 'Proposal not found: p1' }) + }) + expect(waitForProposal(client, 'p1', 1_000)).rejects.toThrow('Proposal not found') + }) + + test('a decision ends the wait', async () => { + const client = poller(() => ({ ...pending, status: 'executed', result: { affectedRows: 1 } })) + const proposal = await waitForProposal(client, 'p1', 1_000) + expect(exitCodeForProposal(proposal.status)).toBe(EXIT.ok) + }) + + test('an expired deadline reports the proposal as still pending (exit 7), not as a closed app', async () => { + const client = poller(() => { + throw new Error('the wait must not be issued once the deadline has passed') + }) + const proposal = await waitForProposal(client, 'p1', 0) + expect(exitCodeForProposal(proposal.status)).toBe(EXIT.pending) + }) +}) + +describe('exitCodeForProposal', () => { + const cases: [ProposalStatus, ExitCode][] = [ + ['executed', EXIT.ok], + ['failed', EXIT.database], + ['rejected', EXIT.rejected], + ['cancelled', EXIT.rejected], + ['pending', EXIT.pending], + ['approved', EXIT.pending], + ['expired', EXIT.pending], + ] + + for (const [status, expected] of cases) { + test(`${status} → ${expected}`, () => { + expect(exitCodeForProposal(status)).toBe(expected) + }) + } + + test('every status has a human-readable note', () => { + for (const [status] of cases) { + expect(proposalNote({ id: 'p1', connectionId: 'c1', sql: 'UPDATE t SET a=1', status, createdAt: 0 }).length).toBeGreaterThan(0) + } + }) +}) diff --git a/tests/cli-agent-format.test.ts b/tests/cli-agent-format.test.ts new file mode 100644 index 00000000..dcf8095b --- /dev/null +++ b/tests/cli-agent-format.test.ts @@ -0,0 +1,242 @@ +import { DatabaseDataType } from '@dotaz/shared/types/database' +import { describe, expect, test } from 'bun:test' +import { CliError, EXIT } from '../src/cli-agent/errors' +import { + binaryByteLength, + capJsonRows, + formatByteSize, + formatCell, + MAX_CELL_WIDTH, + OUTPUT_FORMATS, + parseFormat, + renderSections, + type Section, + truncationLine, +} from '../src/cli-agent/format' +import { renderOutput } from '../src/cli-agent/output' + +function rowsSection(count: number, width = 10): Section { + return { + columns: [{ name: 'id' }, { name: 'value' }], + rows: Array.from({ length: count }, (_, i) => ({ id: i, value: 'x'.repeat(width) })), + } +} + +describe('parseFormat', () => { + test('accepts every documented format', () => { + for (const format of OUTPUT_FORMATS) { + expect(parseFormat(format)).toBe(format) + } + }) + + test('rejects anything else with a usage error', () => { + try { + parseFormat('xml') + expect.unreachable() + } catch (err) { + expect(err instanceof CliError && err.exitCode).toBe(EXIT.usage) + } + }) +}) + +describe('formatCell', () => { + test('SQL NULL is distinguishable from the string "NULL"', () => { + expect(formatCell(null)).toBe('NULL') + expect(formatCell(undefined)).toBe('NULL') + expect(formatCell('NULL')).toBe('"NULL"') + expect(formatCell('null')).toBe('"null"') + expect(formatCell('nullable')).toBe('nullable') + }) + + test('binary values print as a size, not as bytes', () => { + expect(formatCell(new Uint8Array(1500))).toBe('') + expect(formatCell({ type: 'Buffer', data: [1, 2, 3] })).toBe('') + expect(formatCell('\\x0102ff', DatabaseDataType.Binary)).toBe('') + }) + + test('control characters are escaped so a row stays one line', () => { + expect(formatCell('a\nb\tc')).toBe('a\\nb\\tc') + }) + + test('scalars and objects', () => { + expect(formatCell(42)).toBe('42') + expect(formatCell(true)).toBe('true') + expect(formatCell(new Date('2024-01-02T03:04:05.000Z'))).toBe('2024-01-02T03:04:05.000Z') + expect(formatCell({ a: 1 })).toBe('{"a":1}') + }) + + test('very wide cells are elided in the human formats', () => { + const rendered = formatCell('y'.repeat(MAX_CELL_WIDTH + 50)) + expect(rendered.length).toBe(MAX_CELL_WIDTH) + expect(rendered.endsWith('…')).toBe(true) + }) +}) + +describe('binaryByteLength', () => { + test('recognises the JSON shapes a Buffer can take', () => { + expect(binaryByteLength(new Uint8Array(4))).toBe(4) + expect(binaryByteLength(new ArrayBuffer(8))).toBe(8) + expect(binaryByteLength({ type: 'Buffer', data: [1, 2] })).toBe(2) + expect(binaryByteLength('plain text')).toBeNull() + }) + + test('formatByteSize scales', () => { + expect(formatByteSize(512)).toBe('512 B') + expect(formatByteSize(2048)).toBe('2.0 KB') + expect(formatByteSize(3 * 1024 * 1024)).toBe('3.0 MB') + }) +}) + +describe('renderSections — table', () => { + test('aligns columns and keeps a separator', () => { + const result = renderSections([{ columns: [{ name: 'id' }, { name: 'name' }], rows: [{ id: 1, name: 'alpha' }] }], 'table', 65536) + const lines = result.stdout.trimEnd().split('\n') + expect(lines[0]).toBe('id name') + expect(lines[1]).toBe('-- -----') + expect(lines[2]).toBe('1 alpha') + expect(result.truncated).toBe(false) + }) + + test('empty result sets print the placeholder', () => { + const result = renderSections([{ columns: [{ name: 'id' }], rows: [], empty: '(no rows)' }], 'table', 65536) + expect(result.stdout).toBe('(no rows)\n') + }) + + test('kv sections print key: value lines', () => { + const result = renderSections( + [{ kind: 'kv', columns: [{ name: 'status' }, { name: 'pid' }], rows: [{ status: 'running', pid: 7 }] }], + 'table', + 65536, + ) + expect(result.stdout).toBe('status: running\npid: 7\n') + }) +}) + +describe('renderSections — byte cap', () => { + test('the last line is exactly the documented truncation line', () => { + const result = renderSections([rowsSection(50)], 'table', 200) + const lines = result.stdout.trimEnd().split('\n') + expect(lines[lines.length - 1]).toBe(truncationLine(result.shown, 50)) + expect(result.truncated).toBe(true) + expect(result.shown).toBeGreaterThan(0) + expect(result.shown).toBeLessThan(50) + }) + + test('the cap is respected in bytes', () => { + const result = renderSections([rowsSection(500)], 'table', 400) + expect(Buffer.byteLength(result.stdout, 'utf8')).toBeLessThanOrEqual(400 + truncationLine(result.shown, 500).length + 1) + }) + + test('nothing is truncated when the output fits', () => { + const result = renderSections([rowsSection(3)], 'table', 65536) + expect(result.truncated).toBe(false) + expect(result.shown).toBe(3) + expect(result.stdout).not.toContain('truncated') + }) + + test('total counts rows across every section', () => { + const result = renderSections([rowsSection(2), rowsSection(3)], 'table', 65536) + expect(result.total).toBe(5) + expect(result.shown).toBe(5) + }) +}) + +describe('renderSections — machine formats', () => { + test('csv quotes what needs quoting', () => { + const result = renderSections([{ columns: [{ name: 'a' }, { name: 'b' }], rows: [{ a: 'x,y', b: null }] }], 'csv', 65536) + expect(result.stdout).toBe('a,b\n"x,y",\n') + }) + + test('jsonl emits one row per line and no header', () => { + const result = renderSections([{ columns: [{ name: 'a' }], rows: [{ a: 1 }, { a: 2 }] }], 'jsonl', 65536) + expect(result.stdout).toBe('{"a":1}\n{"a":2}\n') + }) + + test('machine formats keep the truncation notice off stdout', () => { + const result = renderSections([rowsSection(50)], 'jsonl', 120) + expect(result.truncated).toBe(true) + expect(result.stdout).not.toContain('truncated') + expect(result.stderr.trimEnd()).toBe(truncationLine(result.shown, 50)) + }) + + test('csv drops extra sections and says so', () => { + const result = renderSections([rowsSection(1), rowsSection(1)], 'csv', 65536) + expect(result.stderr).toContain('additional section') + }) + + test('md escapes pipes', () => { + const result = renderSections([{ columns: [{ name: 'a' }], rows: [{ a: 'x|y' }] }], 'md', 65536) + expect(result.stdout).toContain('| x\\|y |') + }) +}) + +describe('capJsonRows', () => { + test('keeps rows while they fit', () => { + const rows = Array.from({ length: 10 }, (_, i) => ({ i, pad: 'z'.repeat(20) })) + const { kept, truncated } = capJsonRows(rows, 200, 20) + expect(truncated).toBe(true) + expect(kept.length).toBeGreaterThan(0) + expect(kept.length).toBeLessThan(10) + }) + + test('keeps everything when it fits', () => { + const rows = [{ a: 1 }, { a: 2 }] + expect(capJsonRows(rows, 65536, 0)).toEqual({ kept: rows, truncated: false }) + }) +}) + +describe('renderOutput', () => { + const opts = { format: 'json' as const, maxBytes: 65536, quiet: false } + + test('--json emits a single object', () => { + const rendered = renderOutput({ sections: [], json: { ok: true, rows: [{ a: 1 }] } }, opts) + expect(JSON.parse(rendered.stdout)).toEqual({ ok: true, rows: [{ a: 1 }] }) + }) + + test('--json stays valid JSON when it has to drop rows', () => { + const rows = Array.from({ length: 200 }, (_, i) => ({ i, pad: 'q'.repeat(50) })) + const rendered = renderOutput({ sections: [], json: { rows } }, { ...opts, maxBytes: 500 }) + const parsed = JSON.parse(rendered.stdout) + expect(parsed.truncated).toBe(true) + expect(parsed.total).toBe(200) + expect(parsed.shown).toBe(parsed.rows.length) + expect(parsed.rows.length).toBeLessThan(200) + expect(rendered.stderr).toContain('truncated') + }) + + test('--quiet suppresses notes and diagnostics', () => { + const rendered = renderOutput({ sections: [rowsSection(1)], json: {}, notes: ['a note'] }, { format: 'table', maxBytes: 65536, quiet: true }) + expect(rendered.stderr).toBe('') + }) + + // For csv/jsonl the stderr line is the ONLY signal that rows are missing — stdout carries + // no footer. Dropping it under --quiet let an agent read a truncated result as a whole + // table, and `--format jsonl --quiet` is exactly how an agent pipes rows. + test.each(['jsonl', 'csv'] as const)('--quiet still reports truncation for --format %s', (format) => { + const rendered = renderOutput({ sections: [rowsSection(50)], json: {}, notes: ['a note'] }, { format, maxBytes: 40, quiet: true }) + + expect(rendered.stderr).toContain('truncated') + expect(rendered.stderr).not.toContain('a note') + }) + + test.each(['table', 'md'] as const)('--format %s carries truncation on stdout, so --quiet cannot hide it', (format) => { + const rendered = renderOutput({ sections: [rowsSection(50)], json: {}, notes: ['a note'] }, { format, maxBytes: 40, quiet: true }) + + expect(rendered.stdout).toContain('truncated') + expect(rendered.stderr).toBe('') + }) + + test('--quiet still reports truncation for --json', () => { + const rows = Array.from({ length: 200 }, (_, i) => ({ i, pad: 'q'.repeat(50) })) + const rendered = renderOutput({ sections: [], json: { rows } }, { ...opts, maxBytes: 500, quiet: true }) + + expect(JSON.parse(rendered.stdout).truncated).toBe(true) + expect(rendered.stderr).toContain('truncated') + }) + + test('notes go to stderr, never stdout', () => { + const rendered = renderOutput({ sections: [rowsSection(1)], json: {}, notes: ['a note'] }, { format: 'table', maxBytes: 65536, quiet: false }) + expect(rendered.stderr).toContain('a note') + expect(rendered.stdout).not.toContain('a note') + }) +}) diff --git a/tests/cli-agent-integration.test.ts b/tests/cli-agent-integration.test.ts new file mode 100644 index 00000000..60a28c8a --- /dev/null +++ b/tests/cli-agent-integration.test.ts @@ -0,0 +1,549 @@ +// Drives the real CLI client against a real control server over a real unix socket. +// Windows uses loopback TCP instead, and the desktop control server is not part of the +// web/CI matrix there — skip rather than fake it. + +import { isReadOnlySql } from '@dotaz/shared/sql/statements' +import { DatabaseError } from '@dotaz/shared/types/errors' +import type { Proposal } from '@dotaz/shared/types/rpc' +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { type ControlServerHandle, startControlServer } from '../src/backend-desktop/control-server' +import { DotazClient } from '../src/cli-agent/client' +import { decodeAgentHello, decodeConnections } from '../src/cli-agent/decode' +import { discoverEndpoint, type EndpointInfo } from '../src/cli-agent/endpoint' +import { CliError, EXIT } from '../src/cli-agent/errors' +import { waitForProposal } from '../src/cli-agent/proposals' + +const runOnUnixSocket = process.platform !== 'win32' +const MAIN = join(import.meta.dir, '../src/cli-agent/main.ts') + +const sessions = { created: 0, destroyed: 0, active: 0, lastSql: '', lastQueryId: '', lastReadOnly: false } +const cancelledQueries: string[] = [] +/** queryId → release, so a "long" query ends when it is cancelled (or when the suite tears down). */ +const sleepingQueries = new Map void>() +const bookmarkCalls: { connectionId: string; search?: string }[] = [] +const proposals = new Map() +const uiCalls: Record[] = [] + +/** Poll until the mock app reports what we are waiting for — no fixed sleeps in the timing tests. */ +async function waitUntil(predicate: () => boolean, what: string, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`) + await Bun.sleep(10) + } +} + +/** Stands in for the backend-owned read-only session around each agent operation. */ +async function withMockReadOnlySession(operation: () => T | Promise): Promise { + sessions.created++ + sessions.active++ + sessions.lastReadOnly = true + try { + return await operation() + } finally { + sessions.destroyed++ + sessions.active-- + } +} + +const schema = { + schemas: [{ name: 'public' }], + tables: { public: [{ schema: 'public', name: 'orders', type: 'table' }] }, + columns: { + 'public.orders': [ + { name: 'id', dataType: 'integer', nullable: false, defaultValue: null, isPrimaryKey: true, isAutoIncrement: true }, + { name: 'note', dataType: 'text', nullable: true, defaultValue: null, isPrimaryKey: false, isAutoIncrement: false }, + ], + }, + indexes: {}, + foreignKeys: {}, + referencingForeignKeys: {}, +} + +const handlers = { + 'agent.hello': () => ({ version: '9.9.9', mode: 'desktop', pid: process.pid, protocol: 1 }), + 'connections.list': () => [ + { + id: 'c-test', + name: 'testdb', + config: { type: 'postgresql', host: 'localhost', port: 5432, database: 'app', user: 'u', password: 'super-secret' }, + state: 'connected', + readOnly: false, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + 'databases.list': () => [{ name: 'app', isDefault: true, isActive: true }], + 'agent.schema': () => withMockReadOnlySession(() => schema), + 'agent.query': (params: { sql: string; queryId?: string }) => + withMockReadOnlySession(async () => { + sessions.lastSql = params?.sql ?? '' + sessions.lastQueryId = params?.queryId ?? '' + // The real engine is what rejects a write; this stands in for it using the same + // classifier the app uses, so the mock cannot be laxer than production. + if (!isReadOnlySql(sessions.lastSql)) { + throw new DatabaseError('READ_ONLY_SESSION', 'cannot execute UPDATE in a read-only transaction') + } + if (/\bsleep\b/i.test(sessions.lastSql)) { + // Stands in for a long query: it only returns once something cancels it + const queryId = sessions.lastQueryId + await new Promise((resolve) => sleepingQueries.set(queryId, resolve)) + sleepingQueries.delete(queryId) + } + return [{ + columns: [{ name: 'id', dataType: 'integer' }, { name: 'note', dataType: 'text' }], + rows: [{ id: 1, note: null }, { id: 2, note: 'NULL' }], + rowCount: 2, + durationMs: 3, + }] + }), + 'agent.search': () => + withMockReadOnlySession(() => ({ + matches: [], + searchedTables: 1, + totalMatches: 0, + cancelled: false, + elapsedMs: 1, + })), + 'query.cancel': ({ queryId }: { queryId: string }) => { + cancelledQueries.push(queryId) + sleepingQueries.get(queryId)?.() + }, + 'bookmarks.list': ({ connectionId, search }: { connectionId: string; search?: string }) => { + bookmarkCalls.push({ connectionId, search }) + return [{ + id: 'b-1', + connectionId, + database: 'app', + name: 'daily orders', + description: 'orders of the day', + sql: "SELECT * FROM orders WHERE created_at > now() - interval '1 day'", + createdAt: '2024-05-05T10:00:00.000Z', + updatedAt: '2024-05-06T10:00:00.000Z', + }] + }, + 'agent.proposeWrite': ({ connectionId, sql, reason }: { connectionId: string; sql: string; reason?: string }) => { + const id = `p-${proposals.size + 1}` + proposals.set(id, { id, connectionId, sql, reason, status: 'pending', createdAt: Date.now() }) + return { proposalId: id } + }, + 'agent.proposals.get': ({ proposalId }: { proposalId: string }) => { + const proposal = proposals.get(proposalId) + if (!proposal) throw new Error(`Proposal not found: ${proposalId}`) + return proposal + }, + 'agent.proposals.list': () => [...proposals.values()], + 'agent.proposals.wait': ({ proposalId }: { proposalId: string }) => proposals.get(proposalId), + 'agent.proposals.cancel': ({ proposalId }: { proposalId: string }) => { + const proposal = proposals.get(proposalId) + if (proposal) proposal.status = 'cancelled' + }, + 'ui.state': () => ({ + tabs: [{ id: 't1', type: 'data-grid', title: 'orders', connectionId: 'c-test', database: 'app', schema: 'public', table: 'orders' }], + activeTabId: 't1', + activeConnectionId: 'c-test', + updatedAt: 0, + }), + 'ui.openTable': (params: Record) => { + uiCalls.push({ kind: 'open-table', ...params }) + return { ok: true } + }, + 'ui.openConsole': (params: { sql?: string; run?: boolean }) => { + // Mirrors the real handler: auto-running a write would bypass the approval flow + if (params?.run && !/^\s*SELECT/i.test(params.sql ?? '')) { + throw new DatabaseError('READ_ONLY_SESSION', 'Only read-only SQL can be auto-run — submit writes via agent.proposeWrite') + } + uiCalls.push({ kind: 'open-console', ...params }) + return { ok: true } + }, + 'ui.runCommand': (params: Record) => { + uiCalls.push({ kind: 'run-command', ...params }) + return { ok: true } + }, + 'history.list': () => [ + { + id: 1, + connectionId: 'c-test', + database: 'app', + sql: 'SELECT 1', + status: 'success', + durationMs: 2, + rowCount: 1, + executedAt: '2024-05-05T10:00:00Z', + }, + ], + // Present in the handler map but absent from the CLI allowlist + 'settings.set': () => undefined, +} + +describe.skipIf(!runOnUnixSocket)('cli-agent ↔ control server', () => { + let server: ControlServerHandle + let userDataDir: string + + beforeAll(async () => { + userDataDir = mkdtempSync(join(tmpdir(), 'dotaz-cli-test-')) + server = await startControlServer({ + handlers, + userDataDir, + appVersion: '9.9.9', + }) + }) + + afterAll(async () => { + for (const release of sleepingQueries.values()) release() + await server?.stop() + rmSync(userDataDir, { recursive: true, force: true }) + }) + + function client(tokenOverride?: string): DotazClient { + const found = discoverEndpoint({ explicitFile: server.endpointFile }) + const endpoint = tokenOverride === undefined ? found.endpoint : { ...found.endpoint, token: tokenOverride } + return new DotazClient(endpoint, 5_000) + } + + test('the endpoint file is discoverable and describes a unix socket', () => { + const found = discoverEndpoint({ explicitFile: server.endpointFile }) + expect(found.endpoint.transport).toBe('unix') + expect(found.endpoint.pid).toBe(process.pid) + expect(found.endpoint.token).toBe(server.token) + }) + + test('health needs no token', async () => { + const health = await client('nonsense').health() + expect(health.ok).toBe(true) + expect(health.version).toBe('9.9.9') + }) + + test('a successful call returns the decoded payload', async () => { + const hello = decodeAgentHello(await client().call('agent.hello')) + expect(hello).toEqual({ version: '9.9.9', mode: 'desktop', pid: process.pid, protocol: 1 }) + }) + + test('connections come back without passwords', async () => { + const payload = await client().call('connections.list') + expect(JSON.stringify(payload)).not.toContain('super-secret') + const connections = decodeConnections(payload) + expect(connections).toHaveLength(1) + expect(connections[0].name).toBe('testdb') + }) + + test('a read-only violation maps to exit 4 with the propose hint', async () => { + try { + await client().call('agent.query', { sql: 'UPDATE t SET a = 1' }) + expect.unreachable() + } catch (err) { + expect(err instanceof CliError).toBe(true) + expect(err instanceof CliError && err.exitCode).toBe(EXIT.readOnly) + expect(err instanceof CliError && err.hint).toContain('dotaz propose') + } + }) + + test('a method outside the allowlist is simply unknown', async () => { + try { + await client().call('settings.set', { key: 'cli.enabled', value: 'false' }) + expect.unreachable() + } catch (err) { + expect(err instanceof CliError && err.exitCode).toBe(EXIT.database) + expect(err instanceof Error && err.message).toContain('Unknown method') + } + }) + + test('a bad token is rejected with exit 5', async () => { + try { + await client('b'.repeat(64)).call('agent.hello') + expect.unreachable() + } catch (err) { + expect(err instanceof CliError && err.exitCode).toBe(EXIT.notRunning) + expect(err instanceof Error && err.message).toContain('token') + } + }) + + test('a socket that is not there is exit 5', async () => { + const dead = new DotazClient( + { ...discoverEndpoint({ explicitFile: server.endpointFile }).endpoint, socket: join(userDataDir, 'gone.sock') }, + 2_000, + ) + try { + await dead.call('agent.hello') + expect.unreachable() + } catch (err) { + expect(err instanceof CliError && err.exitCode).toBe(EXIT.notRunning) + } + }) + + test('`dotaz status --json` exits 0 and reports the app', async () => { + const proc = Bun.spawn(['bun', MAIN, 'status', '--json', '--endpoint', server.endpointFile], { stdout: 'pipe', stderr: 'pipe' }) + const stdout = await new Response(proc.stdout).text() + expect(await proc.exited).toBe(EXIT.ok) + const parsed = JSON.parse(stdout) + expect(parsed.status).toBe('running') + expect(parsed.version).toBe('9.9.9') + expect(parsed.connections).toBe(1) + }) + + test('`dotaz ls` renders the connection table', async () => { + const proc = Bun.spawn(['bun', MAIN, 'ls', '--endpoint', server.endpointFile], { stdout: 'pipe', stderr: 'pipe' }) + const stdout = await new Response(proc.stdout).text() + expect(await proc.exited).toBe(EXIT.ok) + expect(stdout).toContain('testdb') + expect(stdout).toContain('c-test') + }) + + test('a dead pid in the endpoint file exits 5', async () => { + const dead = Bun.spawn(['bun', '-e', 'process.exit(0)']) + await dead.exited + const file = join(userDataDir, 'dead-endpoint.json') + writeFileSync( + file, + JSON.stringify({ pid: dead.pid, transport: 'unix', socket: '/nonexistent.sock', port: null, token: 'x', version: '0', protocol: 1, startedAt: 1 }), + ) + + const proc = Bun.spawn(['bun', MAIN, 'status', '--endpoint', file], { stdout: 'pipe', stderr: 'pipe' }) + const stderr = await new Response(proc.stderr).text() + expect(await proc.exited).toBe(EXIT.notRunning) + expect(stderr).toContain('Allow CLI access') + }) + + test('a usage error exits 2 without touching the app', async () => { + const proc = Bun.spawn(['bun', MAIN, 'rows', '--endpoint', server.endpointFile], { stdout: 'pipe', stderr: 'pipe' }) + expect(await proc.exited).toBe(EXIT.usage) + }) + + test('`dotaz rows` builds a quoted SELECT in a read-only session and cleans it up', async () => { + const before = { ...sessions } + const proc = Bun.spawn(['bun', MAIN, 'rows', 'testdb/app/public/orders', '--limit', '2', '--order', 'id:desc', '--endpoint', server.endpointFile], { + stdout: 'pipe', + stderr: 'pipe', + }) + const stdout = await new Response(proc.stdout).text() + expect(await proc.exited).toBe(EXIT.ok) + + expect(sessions.lastReadOnly).toBe(true) + expect(sessions.lastSql).toBe('SELECT * FROM "public"."orders" ORDER BY "id" DESC LIMIT $1 OFFSET $2') + expect(sessions.created).toBe(before.created + 2) + expect(sessions.destroyed).toBe(before.destroyed + 2) + expect(sessions.active).toBe(0) + // SQL NULL and the string "NULL" must not look the same + expect(stdout).toContain('NULL') + expect(stdout).toContain('"NULL"') + }) + + test('`dotaz propose` without --wait exits 7 and prints the proposal id', async () => { + const proc = Bun.spawn( + ['bun', MAIN, 'propose', 'testdb', "UPDATE orders SET note='x'", '--reason', 'test', '--json', '--endpoint', server.endpointFile], + { stdout: 'pipe', stderr: 'pipe' }, + ) + const stdout = await new Response(proc.stdout).text() + expect(await proc.exited).toBe(EXIT.pending) + const parsed = JSON.parse(stdout) + expect(parsed.status).toBe('pending') + expect(proposals.get(parsed.id)?.reason).toBe('test') + }) + + test('`dotaz approvals status` reports a rejected proposal with exit 8', async () => { + proposals.set('p-rejected', { + id: 'p-rejected', + connectionId: 'c-test', + sql: 'DELETE FROM orders', + status: 'rejected', + createdAt: Date.now(), + resolvedAt: Date.now(), + }) + const proc = Bun.spawn(['bun', MAIN, 'approvals', 'status', 'p-rejected', '--endpoint', server.endpointFile], { stdout: 'pipe', stderr: 'pipe' }) + const stderr = await new Response(proc.stderr).text() + expect(await proc.exited).toBe(EXIT.rejected) + expect(stderr).toContain('rejected by the user') + }) + + test('`dotaz approvals cancel` exits 0', async () => { + proposals.set('p-cancel', { id: 'p-cancel', connectionId: 'c-test', sql: 'DELETE FROM orders', status: 'pending', createdAt: Date.now() }) + const proc = Bun.spawn(['bun', MAIN, 'approvals', 'cancel', 'p-cancel', '--endpoint', server.endpointFile], { stdout: 'pipe', stderr: 'pipe' }) + expect(await proc.exited).toBe(EXIT.ok) + expect(proposals.get('p-cancel')?.status).toBe('cancelled') + }) + + test('`dotaz ui state` and `dotaz ui open` drive the app', async () => { + const state = Bun.spawn(['bun', MAIN, 'ui', 'state', '--json', '--endpoint', server.endpointFile], { stdout: 'pipe', stderr: 'pipe' }) + const stdout = await new Response(state.stdout).text() + expect(await state.exited).toBe(EXIT.ok) + expect(JSON.parse(stdout).activeTabId).toBe('t1') + + const open = Bun.spawn(['bun', MAIN, 'ui', 'open', 'testdb/app/public/orders', '--endpoint', server.endpointFile], { + stdout: 'pipe', + stderr: 'pipe', + }) + expect(await open.exited).toBe(EXIT.ok) + expect(uiCalls.at(-1)).toMatchObject({ kind: 'open-table', connectionId: 'c-test', schema: 'public', table: 'orders' }) + }) + + test('`dotaz ui console --run` refuses a write with exit 4', async () => { + const proc = Bun.spawn(['bun', MAIN, 'ui', 'console', 'testdb', '--sql', 'DELETE FROM orders', '--run', '--endpoint', server.endpointFile], { + stdout: 'pipe', + stderr: 'pipe', + }) + const stderr = await new Response(proc.stderr).text() + expect(await proc.exited).toBe(EXIT.readOnly) + expect(stderr).toContain('dotaz propose') + }) + + test('`dotaz history` lists recent queries', async () => { + const proc = Bun.spawn(['bun', MAIN, 'history', '--json', '--endpoint', server.endpointFile], { stdout: 'pipe', stderr: 'pipe' }) + const stdout = await new Response(proc.stdout).text() + expect(await proc.exited).toBe(EXIT.ok) + expect(JSON.parse(stdout).rows[0].sql).toBe('SELECT 1') + }) + + test('`dotaz describe` renders the schema sections', async () => { + const proc = Bun.spawn(['bun', MAIN, 'describe', 'testdb/app/public/orders', '--endpoint', server.endpointFile], { stdout: 'pipe', stderr: 'pipe' }) + const stdout = await new Response(proc.stdout).text() + expect(await proc.exited).toBe(EXIT.ok) + expect(stdout).toContain('Columns') + expect(stdout).toContain('Referenced by') + }) + + test('`dotaz bookmarks list` reads the saved queries of every connection', async () => { + bookmarkCalls.length = 0 + const proc = Bun.spawn(['bun', MAIN, 'bookmarks', 'list', '--json', '--endpoint', server.endpointFile], { stdout: 'pipe', stderr: 'pipe' }) + const stdout = await new Response(proc.stdout).text() + expect(await proc.exited).toBe(EXIT.ok) + expect(JSON.parse(stdout).rows[0].name).toBe('daily orders') + expect(bookmarkCalls).toEqual([{ connectionId: 'c-test', search: undefined }]) + }) + + test('`dotaz bookmarks list` passes --conn and --search through', async () => { + bookmarkCalls.length = 0 + const proc = Bun.spawn(['bun', MAIN, 'bookmarks', 'list', '--conn', 'testdb', '--search', 'orders', '--endpoint', server.endpointFile], { + stdout: 'pipe', + stderr: 'pipe', + }) + const stdout = await new Response(proc.stdout).text() + expect(await proc.exited).toBe(EXIT.ok) + expect(stdout).toContain('daily orders') + expect(bookmarkCalls).toEqual([{ connectionId: 'c-test', search: 'orders' }]) + }) + + test('`dotaz bookmarks` without a subcommand is a usage error', async () => { + const proc = Bun.spawn(['bun', MAIN, 'bookmarks', '--endpoint', server.endpointFile], { stdout: 'pipe', stderr: 'pipe' }) + expect(await proc.exited).toBe(EXIT.usage) + }) + + test('`dotaz query --limit` pushes the limit into the SQL', async () => { + const proc = Bun.spawn(['bun', MAIN, 'query', 'testdb', 'SELECT * FROM orders', '--limit', '1', '--json', '--endpoint', server.endpointFile], { + stdout: 'pipe', + stderr: 'pipe', + }) + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + expect(await proc.exited).toBe(EXIT.ok) + expect(sessions.lastSql).toBe('SELECT * FROM orders\nLIMIT 1') + const parsed = JSON.parse(stdout) + expect(parsed.limit).toMatchObject({ requested: 1, appliedTo: 'sql' }) + expect(parsed.rows).toHaveLength(1) + expect(stderr).toContain('pushed into the SQL') + }) + + test('`dotaz query --limit` falls back to trimming the printed rows and says so', async () => { + const proc = Bun.spawn(['bun', MAIN, 'query', 'testdb', 'SELECT 1; SELECT 2', '--limit', '1', '--json', '--endpoint', server.endpointFile], { + stdout: 'pipe', + stderr: 'pipe', + }) + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + expect(await proc.exited).toBe(EXIT.ok) + expect(sessions.lastSql).toBe('SELECT 1; SELECT 2') + expect(JSON.parse(stdout).limit).toMatchObject({ requested: 1, appliedTo: 'rows' }) + expect(stderr).toContain('still ran the full query') + }) + + test('a query that outlives --timeout is cancelled in the app, and the session still goes away', async () => { + const before = { ...sessions } + cancelledQueries.length = 0 + const proc = Bun.spawn(['bun', MAIN, 'query', 'testdb', 'SELECT sleep(60)', '--timeout', '700', '--endpoint', server.endpointFile], { + stdout: 'pipe', + stderr: 'pipe', + }) + const stderr = await new Response(proc.stderr).text() + expect(await proc.exited).toBe(EXIT.timeout) + expect(stderr).toContain('did not respond') + expect(stderr).toContain('cancelled in Dotaz') + expect(cancelledQueries).toEqual([sessions.lastQueryId]) + expect(sessions.destroyed).toBe(before.destroyed + 1) + expect(sessions.active).toBe(0) + }) + + test('SIGINT cancels the running query instead of orphaning it', async () => { + const before = { ...sessions } + cancelledQueries.length = 0 + const proc = Bun.spawn(['bun', MAIN, 'query', 'testdb', 'SELECT sleep(61)', '--endpoint', server.endpointFile], { stdout: 'pipe', stderr: 'pipe' }) + await waitUntil(() => sessions.lastSql.includes('sleep(61)'), 'the query to reach the app') + + proc.kill('SIGINT') + const stderr = await new Response(proc.stderr).text() + expect(await proc.exited).toBe(EXIT.timeout) + expect(stderr).toContain('Interrupted') + expect(cancelledQueries).toEqual([sessions.lastQueryId]) + expect(sessions.destroyed).toBe(before.destroyed + 1) + expect(sessions.active).toBe(0) + }) + + test('the app closing mid-wait is reported as a lost proposal, not as a timeout', async () => { + const socket = join(userDataDir, 'dying.sock') + let requests = 0 + const dying = Bun.serve({ + unix: socket, + async fetch() { + requests++ + if (requests === 1) { + return Response.json({ + type: 'response', + id: 0, + success: true, + payload: { id: 'p-lost', connectionId: 'c-test', sql: 'DELETE FROM orders', status: 'pending', createdAt: Date.now() }, + }) + } + // The long poll is in flight when the app quits + setTimeout(() => void dying.stop(true), 50).unref() + await new Promise(() => {}) + return new Response('unreachable') + }, + }) + + const endpoint: EndpointInfo = { + pid: process.pid, + transport: 'unix', + socket, + port: null, + token: 'token', + version: '9.9.9', + protocol: 1, + startedAt: Date.now(), + } + + try { + await waitForProposal(new DotazClient(endpoint, 5_000), 'p-lost', 5_000) + expect.unreachable() + } catch (err) { + expect(err instanceof CliError && err.exitCode).toBe(EXIT.notRunning) + expect(err instanceof Error && err.message).toContain('Dotaz closed while proposal p-lost') + } finally { + await dying.stop(true) + rmSync(socket, { force: true }) + } + }) + + test('`dotaz query` with a write exits 4 and still destroys the session', async () => { + const before = { ...sessions } + const proc = Bun.spawn(['bun', MAIN, 'query', 'testdb', 'UPDATE orders SET note = 1', '--endpoint', server.endpointFile], { + stdout: 'pipe', + stderr: 'pipe', + }) + const stderr = await new Response(proc.stderr).text() + expect(await proc.exited).toBe(EXIT.readOnly) + expect(stderr).toContain('dotaz propose') + expect(sessions.destroyed).toBe(before.destroyed + 1) + expect(sessions.active).toBe(0) + }) +}) diff --git a/tests/cli-agent-paths.test.ts b/tests/cli-agent-paths.test.ts new file mode 100644 index 00000000..1021d97c --- /dev/null +++ b/tests/cli-agent-paths.test.ts @@ -0,0 +1,245 @@ +import type { DatabaseInfo, SchemaData } from '@dotaz/shared/types/database' +import { DatabaseDataType } from '@dotaz/shared/types/database' +import { describe, expect, test } from 'bun:test' +import type { CliConnection } from '../src/cli-agent/decode' +import { CliError, EXIT } from '../src/cli-agent/errors' +import { + requireTable, + resolveConnectionRef, + resolveDatabaseRef, + resolvePath, + type ResolverContext, + resolveScope, + splitPath, +} from '../src/cli-agent/paths' + +const CONNECTIONS: CliConnection[] = [ + { id: 'c-prod', name: 'production', type: 'postgresql', state: 'connected', readOnly: false }, + { id: 'c-prodigy', name: 'prodigy', type: 'postgresql', state: 'disconnected', readOnly: false }, + { id: 'c-shop', name: 'shop', type: 'mysql', state: 'connected', readOnly: false }, + { id: 'c-notes', name: 'notes', type: 'sqlite', state: 'connected', readOnly: false }, + { id: 'c-dup-a', name: 'twin', type: 'postgresql', state: 'connected', readOnly: false }, + { id: 'c-dup-b', name: 'twin', type: 'postgresql', state: 'connected', readOnly: false }, +] + +function emptySchema(): SchemaData { + return { schemas: [], tables: {}, columns: {}, indexes: {}, foreignKeys: {}, referencingForeignKeys: {} } +} + +function schemaWith(tables: Record): SchemaData { + const data = emptySchema() + data.schemas = Object.keys(tables).map((name) => ({ name })) + for (const [schema, names] of Object.entries(tables)) { + data.tables[schema] = names.map((name) => ({ schema, name, type: 'table' as const })) + for (const name of names) { + data.columns[`${schema}.${name}`] = [ + { name: 'id', dataType: DatabaseDataType.Integer, nullable: false, defaultValue: null, isPrimaryKey: true, isAutoIncrement: true }, + ] + } + } + return data +} + +const SCHEMAS: Record = { + 'c-prod app': schemaWith({ public: ['orders', 'users'], billing: ['invoices', 'users'] }), + 'c-shop shopdb': schemaWith({ shopdb: ['items', 'carts'] }), + 'c-notes ': schemaWith({ main: ['tasks', 'tags'] }), +} + +const DATABASES: Record = { + 'c-prod': [{ name: 'app', isDefault: true, isActive: true }, { name: 'analytics', isDefault: false, isActive: false }], + 'c-prodigy': [], + 'c-shop': [{ name: 'shopdb', isDefault: true, isActive: true }], +} + +const ctx: ResolverContext = { + async listConnections() { + return CONNECTIONS + }, + async listDatabases(connectionId) { + return DATABASES[connectionId] ?? [] + }, + async loadSchema(connectionId, database) { + return SCHEMAS[`${connectionId} ${database ?? ''}`] ?? emptySchema() + }, +} + +async function exitCodeOf(fn: () => unknown | Promise): Promise { + try { + await fn() + return undefined + } catch (err) { + return err instanceof CliError ? err.exitCode : undefined + } +} + +describe('splitPath', () => { + test('splits on slashes', () => { + expect(splitPath('a/b/c')).toEqual(['a', 'b', 'c']) + }) + + test('tolerates one trailing slash', () => { + expect(splitPath('a/b/')).toEqual(['a', 'b']) + }) + + test('rejects empty segments', async () => { + expect(await exitCodeOf(() => splitPath('a//b'))).toBe(EXIT.usage) + }) +}) + +describe('resolveConnectionRef', () => { + test('matches by id', () => { + expect(resolveConnectionRef(CONNECTIONS, 'c-notes').name).toBe('notes') + }) + + test('matches by exact name', () => { + expect(resolveConnectionRef(CONNECTIONS, 'production').id).toBe('c-prod') + }) + + test('matches by case-insensitive exact name', () => { + expect(resolveConnectionRef(CONNECTIONS, 'PRODUCTION').id).toBe('c-prod') + }) + + test('matches by unique case-insensitive prefix', () => { + expect(resolveConnectionRef(CONNECTIONS, 'no').id).toBe('c-notes') + expect(resolveConnectionRef(CONNECTIONS, 'shO').id).toBe('c-shop') + }) + + test('an ambiguous prefix is a usage error', async () => { + expect(await exitCodeOf(() => resolveConnectionRef(CONNECTIONS, 'prod'))).toBe(EXIT.usage) + }) + + test('duplicate names are a usage error pointing at ids', async () => { + let hint: string | undefined + try { + resolveConnectionRef(CONNECTIONS, 'twin') + } catch (err) { + hint = err instanceof CliError ? err.hint : undefined + } + expect(hint).toContain('c-dup-a') + expect(hint).toContain('c-dup-b') + }) + + test('unknown connection is a usage error', async () => { + expect(await exitCodeOf(() => resolveConnectionRef(CONNECTIONS, 'nope'))).toBe(EXIT.usage) + }) + + test('no connections at all is a usage error', async () => { + expect(await exitCodeOf(() => resolveConnectionRef([], 'anything'))).toBe(EXIT.usage) + }) +}) + +describe('resolveDatabaseRef', () => { + test('matches exactly and case-insensitively', () => { + expect(resolveDatabaseRef(DATABASES['c-prod'], 'app')).toBe('app') + expect(resolveDatabaseRef(DATABASES['c-prod'], 'APP')).toBe('app') + }) + + test('passes through when the driver lists no databases', () => { + expect(resolveDatabaseRef([], 'whatever')).toBe('whatever') + }) + + test('unknown database is a usage error', async () => { + expect(await exitCodeOf(() => resolveDatabaseRef(DATABASES['c-prod'], 'nope'))).toBe(EXIT.usage) + }) +}) + +describe('resolvePath', () => { + test('no path means the connection list', async () => { + expect(await resolvePath(undefined, ctx)).toBeNull() + }) + + test('connection level', async () => { + const resolved = await resolvePath('production', ctx) + expect(resolved?.level).toBe('connection') + expect(resolved?.database).toBeUndefined() + }) + + test('database level', async () => { + const resolved = await resolvePath('production/app', ctx) + expect(resolved?.level).toBe('database') + expect(resolved?.database).toBe('app') + }) + + test('schema level', async () => { + const resolved = await resolvePath('production/app/public', ctx) + expect(resolved?.level).toBe('schema') + expect(resolved?.schema).toBe('public') + expect(resolved?.table).toBeUndefined() + }) + + test('table level', async () => { + const resolved = await resolvePath('production/app/public/orders', ctx) + expect(resolved?.level).toBe('table') + expect(resolved?.schema).toBe('public') + expect(resolved?.table).toBe('orders') + }) + + test('SQLite accepts the shortened connection/table form', async () => { + const resolved = await resolvePath('notes/tasks', ctx) + expect(resolved?.level).toBe('table') + expect(resolved?.schema).toBe('main') + expect(resolved?.table).toBe('tasks') + }) + + test('SQLite also accepts the explicit schema', async () => { + const resolved = await resolvePath('notes/main/tags', ctx) + expect(resolved?.table).toBe('tags') + }) + + test('MySQL skips the schema segment because there is only one', async () => { + const resolved = await resolvePath('shop/shopdb/items', ctx) + expect(resolved?.level).toBe('table') + expect(resolved?.schema).toBe('shopdb') + expect(resolved?.table).toBe('items') + }) + + test('a table present in several schemas must be qualified', async () => { + expect(await exitCodeOf(() => resolvePath('production/app/users', ctx))).toBe(EXIT.usage) + }) + + test('unknown schema or table is a usage error', async () => { + expect(await exitCodeOf(() => resolvePath('production/app/nope', ctx))).toBe(EXIT.usage) + expect(await exitCodeOf(() => resolvePath('production/app/public/nope', ctx))).toBe(EXIT.usage) + }) + + test('too many segments is a usage error', async () => { + expect(await exitCodeOf(() => resolvePath('production/app/public/orders/extra', ctx))).toBe(EXIT.usage) + }) +}) + +describe('resolveScope', () => { + test('connection only', async () => { + const scope = await resolveScope('production', ctx) + expect(scope.connection.id).toBe('c-prod') + expect(scope.database).toBeUndefined() + }) + + test('connection with database', async () => { + expect((await resolveScope('production/analytics', ctx)).database).toBe('analytics') + }) + + test('SQLite rejects a database segment', async () => { + expect(await exitCodeOf(() => resolveScope('notes/main', ctx))).toBe(EXIT.usage) + }) + + test('more than two segments is a usage error', async () => { + expect(await exitCodeOf(() => resolveScope('production/app/public', ctx))).toBe(EXIT.usage) + }) +}) + +describe('requireTable', () => { + test('passes a table path through', async () => { + const resolved = await resolvePath('notes/tasks', ctx) + expect(requireTable(resolved, 'notes/tasks').table).toBe('tasks') + }) + + test('rejects a schema path', async () => { + const resolved = await resolvePath('production/app/public', ctx) + expect(await exitCodeOf(() => requireTable(resolved, 'production/app/public'))).toBe(EXIT.usage) + }) + + test('rejects a null path', async () => { + expect(await exitCodeOf(() => requireTable(null, ''))).toBe(EXIT.usage) + }) +}) diff --git a/tests/cli-agent-sql.test.ts b/tests/cli-agent-sql.test.ts new file mode 100644 index 00000000..b18c49b5 --- /dev/null +++ b/tests/cli-agent-sql.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, test } from 'bun:test' +import { CliError, EXIT } from '../src/cli-agent/errors' +import { applySqlLimit, buildExplainSql, buildRowsQuery, dialectFor, parseColumnList, parseOrderBy } from '../src/cli-agent/sql' + +function exitCodeOf(fn: () => unknown): number | undefined { + try { + fn() + return undefined + } catch (err) { + return err instanceof CliError ? err.exitCode : undefined + } +} + +describe('parseOrderBy', () => { + test('defaults to ascending', () => { + expect(parseOrderBy('id')).toEqual([{ column: 'id', direction: 'asc' }]) + }) + + test('parses several keys with directions', () => { + expect(parseOrderBy('created_at:desc, id')).toEqual([ + { column: 'created_at', direction: 'desc' }, + { column: 'id', direction: 'asc' }, + ]) + }) + + test('rejects an unknown direction', () => { + expect(exitCodeOf(() => parseOrderBy('id:sideways'))).toBe(EXIT.usage) + }) + + test('undefined stays undefined', () => { + expect(parseOrderBy(undefined)).toBeUndefined() + }) +}) + +describe('parseColumnList', () => { + test('splits and trims', () => { + expect(parseColumnList('id, name ')).toEqual(['id', 'name']) + }) + + test('rejects an empty list', () => { + expect(exitCodeOf(() => parseColumnList(' , '))).toBe(EXIT.usage) + }) +}) + +describe('buildRowsQuery', () => { + test('quotes identifiers with the PostgreSQL dialect', () => { + const { sql, params } = buildRowsQuery({ + schema: 'public', + table: 'orders', + dialect: dialectFor('postgresql'), + limit: 20, + offset: 0, + }) + expect(sql).toBe('SELECT * FROM "public"."orders" LIMIT $1 OFFSET $2') + expect(params).toEqual([20, 0]) + }) + + test('quotes identifiers with the MySQL dialect', () => { + const { sql } = buildRowsQuery({ schema: 'shopdb', table: 'items', dialect: dialectFor('mysql'), limit: 5, offset: 5 }) + expect(sql).toBe('SELECT * FROM `shopdb`.`items` LIMIT ? OFFSET ?') + }) + + test('SQLite omits the main schema prefix', () => { + const { sql } = buildRowsQuery({ schema: 'main', table: 'tasks', dialect: dialectFor('sqlite'), limit: 1, offset: 0 }) + expect(sql).toBe('SELECT * FROM "tasks" LIMIT $1 OFFSET $2') + }) + + test('column and sort identifiers are quoted, --where is not', () => { + const { sql } = buildRowsQuery({ + schema: 'public', + table: 'orders', + dialect: dialectFor('postgresql'), + columns: ['id', 'total amount'], + sort: [{ column: 'created_at', direction: 'desc' }], + where: "status = 'new'", + limit: 10, + offset: 20, + }) + expect(sql).toBe( + 'SELECT "id", "total amount" FROM "public"."orders" WHERE (status = \'new\') ORDER BY "created_at" DESC LIMIT $1 OFFSET $2', + ) + }) + + test('embedded quotes in identifiers are escaped, not concatenated raw', () => { + const { sql } = buildRowsQuery({ schema: 'public', table: 'we"ird', dialect: dialectFor('postgresql'), limit: 1, offset: 0 }) + expect(sql).toContain('"we""ird"') + }) +}) + +describe('applySqlLimit', () => { + test('appends LIMIT to a plain SELECT', () => { + const limited = applySqlLimit('SELECT * FROM orders', 10, 'postgresql') + expect(limited.mode).toBe('sql') + expect(limited.sql).toBe('SELECT * FROM orders\nLIMIT 10') + }) + + test('every supported engine gets the same clause', () => { + expect(applySqlLimit('select id from t', 5, 'mysql').sql).toBe('select id from t\nLIMIT 5') + expect(applySqlLimit('select id from t', 5, 'sqlite').sql).toBe('select id from t\nLIMIT 5') + }) + + test('a trailing semicolon is dropped, so LIMIT lands inside the statement', () => { + expect(applySqlLimit('SELECT 1;', 3, 'postgresql').sql).toBe('SELECT 1\nLIMIT 3') + }) + + test('a trailing line comment cannot swallow the clause', () => { + const limited = applySqlLimit('SELECT 1 -- why', 3, 'postgresql') + expect(limited.mode).toBe('sql') + expect(limited.sql.endsWith('\nLIMIT 3')).toBe(true) + }) + + test('a CTE ending in SELECT is limited', () => { + const limited = applySqlLimit('WITH recent AS (SELECT * FROM orders) SELECT * FROM recent', 2, 'postgresql') + expect(limited.mode).toBe('sql') + expect(limited.sql).toBe('WITH recent AS (SELECT * FROM orders) SELECT * FROM recent\nLIMIT 2') + }) + + test('a UNION is limited as a whole', () => { + expect(applySqlLimit('SELECT 1 UNION SELECT 2', 1, 'postgresql').mode).toBe('sql') + }) + + test('a statement that already limits itself is left alone', () => { + const limited = applySqlLimit('SELECT * FROM orders LIMIT 100', 10, 'postgresql') + expect(limited).toMatchObject({ mode: 'rows', sql: 'SELECT * FROM orders LIMIT 100' }) + expect(limited.reason).toContain('already limits') + }) + + test('FETCH FIRST and a LIMIT inside a subquery also count as self-limiting', () => { + expect(applySqlLimit('SELECT * FROM orders FETCH FIRST 5 ROWS ONLY', 10, 'postgresql').mode).toBe('rows') + expect(applySqlLimit('SELECT * FROM (SELECT * FROM orders LIMIT 5) s', 10, 'postgresql').mode).toBe('rows') + }) + + test('multi-statement input is never rewritten', () => { + const limited = applySqlLimit('SELECT 1; SELECT 2', 1, 'postgresql') + expect(limited).toMatchObject({ mode: 'rows', sql: 'SELECT 1; SELECT 2' }) + expect(limited.reason).toContain('more than one statement') + }) + + test('a non-SELECT is never rewritten', () => { + expect(applySqlLimit('UPDATE orders SET note = 1', 1, 'postgresql')).toMatchObject({ mode: 'rows' }) + expect(applySqlLimit('SHOW TABLES', 1, 'mysql')).toMatchObject({ mode: 'rows' }) + expect(applySqlLimit('EXPLAIN SELECT 1', 1, 'postgresql')).toMatchObject({ mode: 'rows' }) + expect(applySqlLimit(' ', 1, 'postgresql')).toMatchObject({ mode: 'rows' }) + }) + + test('a semicolon inside a string literal does not look like a second statement', () => { + expect(applySqlLimit("SELECT * FROM t WHERE note = 'a;b'", 1, 'sqlite').mode).toBe('sql') + }) + + test('clauses that have to stay last block the rewrite', () => { + expect(applySqlLimit('SELECT * FROM orders FOR UPDATE', 1, 'postgresql').mode).toBe('rows') + expect(applySqlLimit('SELECT * FROM orders OFFSET 20', 1, 'postgresql').mode).toBe('rows') + expect(applySqlLimit('SELECT * INTO backup FROM orders', 1, 'postgresql').mode).toBe('rows') + }) + + test('a limit that is not a positive integer is refused', () => { + expect(applySqlLimit('SELECT 1', 0, 'postgresql').mode).toBe('rows') + expect(applySqlLimit('SELECT 1', 1.5, 'postgresql').mode).toBe('rows') + }) +}) + +describe('buildExplainSql', () => { + test('PostgreSQL', () => { + expect(buildExplainSql('postgresql', 'SELECT 1', false)).toBe('EXPLAIN SELECT 1') + expect(buildExplainSql('postgresql', 'SELECT 1', true)).toBe('EXPLAIN (ANALYZE, BUFFERS) SELECT 1') + }) + + test('MySQL', () => { + expect(buildExplainSql('mysql', 'SELECT 1;', false)).toBe('EXPLAIN SELECT 1') + expect(buildExplainSql('mysql', 'SELECT 1', true)).toBe('EXPLAIN ANALYZE SELECT 1') + }) + + test('SQLite has a query plan but no ANALYZE', () => { + expect(buildExplainSql('sqlite', 'SELECT 1', false)).toBe('EXPLAIN QUERY PLAN SELECT 1') + expect(exitCodeOf(() => buildExplainSql('sqlite', 'SELECT 1', true))).toBe(EXIT.usage) + }) +}) diff --git a/tests/cli-startup.test.ts b/tests/cli-startup.test.ts index 6ed94c15..ecb1a619 100644 --- a/tests/cli-startup.test.ts +++ b/tests/cli-startup.test.ts @@ -1,5 +1,5 @@ -import { hasExplicitEncryptionKey, validateEncryptionKeyStartup } from '@dotaz/cli/startup' import { describe, expect, test } from 'bun:test' +import { hasExplicitEncryptionKey, validateEncryptionKeyStartup } from '../src/cli/startup' describe('CLI startup encryption key policy', () => { test('allows loopback binds without an explicit encryption key', () => { diff --git a/tests/control-server.test.ts b/tests/control-server.test.ts new file mode 100644 index 00000000..73710190 --- /dev/null +++ b/tests/control-server.test.ts @@ -0,0 +1,197 @@ +// The control server's TCP transport is the Windows path, so nothing else in the suite +// covers it. `transport: 'tcp'` makes it reachable on Linux/macOS too. + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { endpointFilePath, resolveTransport, startControlServer } from '../src/backend-desktop/control-server' +import type { ControlServerHandle } from '../src/backend-desktop/control-server' +import { DotazClient } from '../src/cli-agent/client' +import { candidateEndpointFiles, discoverEndpoint, parseEndpointFile } from '../src/cli-agent/endpoint' +import { CliError, EXIT } from '../src/cli-agent/errors' + +const handlers = { + 'agent.hello': () => ({ version: '9.9.9', mode: 'desktop', pid: process.pid, protocol: 1 }), +} + +function endpointFile(userDataDir: string, pid: number): string { + return join(userDataDir, 'cli', `endpoint-${pid}.json`) +} + +function seedEndpointFile(userDataDir: string, pid: number, startedAt: number): string { + const file = endpointFile(userDataDir, pid) + mkdirSync(join(userDataDir, 'cli'), { recursive: true }) + writeFileSync( + file, + JSON.stringify({ + pid, + transport: 'unix', + socket: `/tmp/dotaz-${pid}.sock`, + port: null, + token: 'x'.repeat(64), + version: '0.0.1', + protocol: 1, + startedAt, + }), + ) + return file +} + +describe('control server over TCP', () => { + let root: string + let userDataDir: string + let server: ControlServerHandle + let liveNeighbour: ReturnType + let liveFile: string + let deadFile: string + let unrelatedFile: string + + beforeAll(async () => { + // The CLI scans //cli, so userData must sit one level below the root + root = mkdtempSync(join(tmpdir(), 'dotaz-control-')) + userDataDir = join(root, 'dev') + mkdirSync(userDataDir) + + liveNeighbour = Bun.spawn(['sleep', '30']) + const dead = Bun.spawn(['sleep', '0']) + await dead.exited + + liveFile = seedEndpointFile(userDataDir, liveNeighbour.pid, 1) + deadFile = seedEndpointFile(userDataDir, dead.pid, 2) + unrelatedFile = join(userDataDir, 'cli', 'not-an-endpoint.json') + writeFileSync(unrelatedFile, '{}') + + server = await startControlServer({ handlers, userDataDir, appVersion: '9.9.9', transport: 'tcp' }) + }) + + afterAll(async () => { + await server?.stop() + liveNeighbour?.kill() + rmSync(root, { recursive: true, force: true }) + }) + + test('binds a loopback port on any platform', () => { + expect(server.address.transport).toBe('tcp') + expect(server.address.transport === 'tcp' && server.address.port).toBeGreaterThan(0) + }) + + test('publishes one endpoint file per pid, readable only by the owner', () => { + expect(server.endpointFile).toBe(endpointFilePath(userDataDir, process.pid)) + expect(server.endpointFile).toBe(endpointFile(userDataDir, process.pid)) + expect(statSync(server.endpointDir).mode & 0o777).toBe(0o700) + expect(statSync(server.endpointFile).mode & 0o777).toBe(0o600) + + const endpoint = parseEndpointFile(readFileSync(server.endpointFile, 'utf8')) + expect(endpoint?.transport).toBe('tcp') + expect(endpoint?.socket).toBeNull() + expect(endpoint?.port).toBe(server.address.transport === 'tcp' ? server.address.port : 0) + expect(endpoint?.token).toBe(server.token) + }) + + test('startup drops files of dead instances only', () => { + expect(existsSync(deadFile)).toBe(false) + expect(existsSync(liveFile)).toBe(true) + expect(existsSync(unrelatedFile)).toBe(true) + }) + + test('the CLI discovers it by scanning the userData root', () => { + expect(candidateEndpointFiles(root)).toContain(server.endpointFile) + const found = discoverEndpoint({ env: {}, home: root, listCandidates: () => candidateEndpointFiles(root) }) + // Ours started last, so it wins over the seeded neighbour + expect(found.endpoint.pid).toBe(process.pid) + expect(found.instances.map((i) => i.pid).sort()).toEqual([liveNeighbour.pid, process.pid].sort()) + }) + + test('--instance picks a specific pid', () => { + const candidates = () => candidateEndpointFiles(root) + const found = discoverEndpoint({ env: {}, home: root, listCandidates: candidates, instancePid: liveNeighbour.pid }) + expect(found.endpoint.pid).toBe(liveNeighbour.pid) + + try { + discoverEndpoint({ env: {}, home: root, listCandidates: candidates, instancePid: 999_999_999 }) + expect.unreachable() + } catch (err) { + expect(err instanceof CliError && err.exitCode).toBe(EXIT.usage) + expect(err instanceof CliError && err.hint).toContain(String(process.pid)) + } + }) + + test('health needs no token over TCP', async () => { + const found = discoverEndpoint({ explicitFile: server.endpointFile }) + const health = await new DotazClient({ ...found.endpoint, token: 'nonsense' }, 5_000).health() + expect(health.ok).toBe(true) + expect(health.version).toBe('9.9.9') + }) + + test('a bad token is rejected on /rpc', async () => { + const found = discoverEndpoint({ explicitFile: server.endpointFile }) + try { + await new DotazClient({ ...found.endpoint, token: 'b'.repeat(64) }, 5_000).call('agent.hello') + expect.unreachable() + } catch (err) { + expect(err instanceof CliError && err.exitCode).toBe(EXIT.notRunning) + expect(err instanceof Error && err.message).toContain('token') + } + }) + + test('a valid call round-trips over TCP', async () => { + const found = discoverEndpoint({ explicitFile: server.endpointFile }) + const payload = await new DotazClient(found.endpoint, 5_000).call('agent.hello') + expect(payload).toEqual({ version: '9.9.9', mode: 'desktop', pid: process.pid, protocol: 1 }) + }) +}) + +describe('control server shutdown', () => { + test('removes only its own endpoint file', async () => { + const root = mkdtempSync(join(tmpdir(), 'dotaz-control-stop-')) + const userDataDir = join(root, 'dev') + mkdirSync(userDataDir) + const neighbour = Bun.spawn(['sleep', '30']) + const neighbourFile = seedEndpointFile(userDataDir, neighbour.pid, 1) + + const server = await startControlServer({ handlers, userDataDir, appVersion: '9.9.9', transport: 'tcp' }) + expect(existsSync(server.endpointFile)).toBe(true) + + await server.stop() + expect(existsSync(server.endpointFile)).toBe(false) + expect(existsSync(neighbourFile)).toBe(true) + expect(existsSync(server.endpointDir)).toBe(true) + + neighbour.kill() + rmSync(root, { recursive: true, force: true }) + }) + + test('a unix server removes its socket, and stop() is idempotent', async () => { + const userDataDir = mkdtempSync(join(tmpdir(), 'dotaz-control-unix-')) + const server = await startControlServer({ handlers, userDataDir, appVersion: '9.9.9', transport: 'unix' }) + expect(server.address.transport).toBe('unix') + const socket = server.address.transport === 'unix' ? server.address.socket : '' + expect(existsSync(socket)).toBe(true) + + await server.stop() + await server.stop() + expect(existsSync(socket)).toBe(false) + expect(existsSync(server.endpointFile)).toBe(false) + + rmSync(userDataDir, { recursive: true, force: true }) + }) +}) + +describe('resolveTransport', () => { + test('the explicit option wins over everything', () => { + expect(resolveTransport({ transport: 'tcp', env: { DOTAZ_CLI_TRANSPORT: 'unix' }, platform: 'linux' })).toBe('tcp') + }) + + test('DOTAZ_CLI_TRANSPORT overrides the platform default', () => { + expect(resolveTransport({ env: { DOTAZ_CLI_TRANSPORT: 'tcp' }, platform: 'linux' })).toBe('tcp') + expect(resolveTransport({ env: { DOTAZ_CLI_TRANSPORT: 'unix' }, platform: 'win32' })).toBe('unix') + }) + + test('falls back to the platform, ignoring a bogus env value', () => { + expect(resolveTransport({ env: {}, platform: 'linux' })).toBe('unix') + expect(resolveTransport({ env: {}, platform: 'darwin' })).toBe('unix') + expect(resolveTransport({ env: {}, platform: 'win32' })).toBe('tcp') + expect(resolveTransport({ env: { DOTAZ_CLI_TRANSPORT: 'http' }, platform: 'linux' })).toBe('unix') + }) +}) diff --git a/tests/error-mapping.test.ts b/tests/error-mapping.test.ts index c0c27b98..5f0487d3 100644 --- a/tests/error-mapping.test.ts +++ b/tests/error-mapping.test.ts @@ -32,6 +32,19 @@ describe('mapPostgresError', () => { expect(err.code).toBe('CONNECTION_TIMEOUT') }) + // A statement timeout says "timeout" but the connection is healthy — reporting it as a + // connection failure would tell an agent the app died. + test('maps a statement timeout to QUERY_CANCELED, not CONNECTION_TIMEOUT', () => { + const pgErr = Object.assign(new Error('canceling statement due to statement timeout'), { errno: '57014' }) + const err = mapPostgresError(pgErr) + expect(err.code).toBe('QUERY_CANCELED') + }) + + test('maps an explicit cancel to QUERY_CANCELED', () => { + const pgErr = Object.assign(new Error('canceling statement due to user request'), { code: '57014' }) + expect(mapPostgresError(pgErr).code).toBe('QUERY_CANCELED') + }) + test('maps ENOTFOUND to ConnectionError', () => { const err = mapPostgresError(new Error('getaddrinfo ENOTFOUND bad.host')) expect(err).toBeInstanceOf(ConnectionError) @@ -217,6 +230,14 @@ describe('mapMysqlError', () => { expect(err.code).toBe('CONNECTION_REFUSED') }) + test('maps MAX_EXECUTION_TIME (3024) and MariaDB max_statement_time (1969) to QUERY_CANCELED', () => { + const mysqlErr = Object.assign(new Error('Query execution was interrupted, maximum statement execution time exceeded'), { errno: 3024 }) + expect(mapMysqlError(mysqlErr).code).toBe('QUERY_CANCELED') + + const mariaErr = Object.assign(new Error('Query execution was interrupted (max_statement_time exceeded)'), { errno: 1969 }) + expect(mapMysqlError(mariaErr).code).toBe('QUERY_CANCELED') + }) + test('maps errno 2003 to ConnectionError', () => { const mysqlErr = Object.assign(new Error("Can't connect"), { errno: 2003 }) const err = mapMysqlError(mysqlErr) diff --git a/tests/explain.test.ts b/tests/explain.test.ts index c1eb4e9a..68526fba 100644 --- a/tests/explain.test.ts +++ b/tests/explain.test.ts @@ -18,6 +18,7 @@ function makeMockDriver(overrides?: Partial): DatabaseDriver { return { execute: mock(async () => makeSuccessResult()), cancel: mock(async () => {}), + isSessionReadOnly: () => false, quoteIdentifier: (name: string) => `"${name}"`, getDriverType: () => 'sqlite' as const, qualifyTable: (schema: string, table: string) => schema === 'main' ? `"${table}"` : `"${schema}"."${table}"`, diff --git a/tests/proposal-store.test.ts b/tests/proposal-store.test.ts new file mode 100644 index 00000000..1bbd5e76 --- /dev/null +++ b/tests/proposal-store.test.ts @@ -0,0 +1,369 @@ +import { MAX_PROPOSALS, PROPOSAL_TTL_MS, ProposalStore } from '@dotaz/backend-shared/services/proposal-store' +import type { ProposalStatus } from '@dotaz/shared/types/rpc' +import { describe, expect, test } from 'bun:test' + +/** Store with a clock the test controls — expiry is never waited out for real. */ +function withClock(ttlMs = PROPOSAL_TTL_MS) { + const clock = { now: 1_700_000_000_000 } + const store = new ProposalStore({ ttlMs, now: () => clock.now }) + return { store, clock } +} + +function createProposal(store: ProposalStore, overrides: Partial<{ connectionId: string; sql: string; database: string; reason: string }> = {}) { + return store.create({ + connectionId: overrides.connectionId ?? 'conn-1', + database: overrides.database, + sql: overrides.sql ?? 'DELETE FROM users WHERE id = 1', + reason: overrides.reason, + }) +} + +describe('ProposalStore', () => { + describe('create / get', () => { + test('create returns a pending proposal', () => { + const { store, clock } = withClock() + const proposal = createProposal(store, { reason: 'cleanup' }) + + expect(proposal.id).toBeTruthy() + expect(proposal.status).toBe('pending') + expect(proposal.connectionId).toBe('conn-1') + expect(proposal.sql).toBe('DELETE FROM users WHERE id = 1') + expect(proposal.reason).toBe('cleanup') + expect(proposal.createdAt).toBe(clock.now) + expect(proposal.resolvedAt).toBeUndefined() + store.dispose() + }) + + test('get returns the stored proposal, null for unknown id', () => { + const { store } = withClock() + const created = createProposal(store) + + expect(store.get(created.id)?.id).toBe(created.id) + expect(store.get('nope')).toBeNull() + store.dispose() + }) + + test('returned proposals are copies — mutating them does not corrupt the store', () => { + const { store } = withClock() + const created = createProposal(store) + created.status = 'executed' + + expect(store.get(created.id)?.status).toBe('pending') + store.dispose() + }) + }) + + describe('resolve / cancel', () => { + test('resolves to executed with a result', () => { + const { store, clock } = withClock() + const created = createProposal(store) + clock.now += 5_000 + + const resolved = store.resolve({ proposalId: created.id, status: 'executed', result: { affectedRows: 3, statements: 1 } }) + + expect(resolved.status).toBe('executed') + expect(resolved.result).toEqual({ affectedRows: 3, statements: 1 }) + expect(resolved.resolvedAt).toBe(clock.now) + store.dispose() + }) + + test('resolves to rejected', () => { + const { store } = withClock() + const created = createProposal(store) + + expect(store.resolve({ proposalId: created.id, status: 'rejected' }).status).toBe('rejected') + store.dispose() + }) + + test('resolves to failed with an error message', () => { + const { store } = withClock() + const created = createProposal(store) + + const resolved = store.resolve({ proposalId: created.id, status: 'failed', error: 'syntax error' }) + + expect(resolved.status).toBe('failed') + expect(resolved.error).toBe('syntax error') + store.dispose() + }) + + test('cancel moves pending to cancelled', () => { + const { store } = withClock() + const created = createProposal(store) + + expect(store.cancel(created.id).status).toBe('cancelled') + store.dispose() + }) + + test('resolving twice throws', () => { + const { store } = withClock() + const created = createProposal(store) + store.resolve({ proposalId: created.id, status: 'executed' }) + + expect(() => store.resolve({ proposalId: created.id, status: 'rejected' })).toThrow(/already executed/) + expect(() => store.cancel(created.id)).toThrow(/already executed/) + store.dispose() + }) + + test('resolving to pending is an illegal transition', () => { + const { store } = withClock() + const created = createProposal(store) + + expect(() => store.resolve({ proposalId: created.id, status: 'pending' })).toThrow(/Cannot resolve/) + expect(store.get(created.id)?.status).toBe('pending') + store.dispose() + }) + + test('resolving an unknown proposal throws', () => { + const { store } = withClock() + + expect(() => store.resolve({ proposalId: 'nope', status: 'executed' })).toThrow(/not found/) + expect(() => store.cancel('nope')).toThrow(/not found/) + store.dispose() + }) + }) + + describe('expiry', () => { + test('pending proposals expire after the TTL', () => { + const { store, clock } = withClock(60_000) + const created = createProposal(store) + + clock.now += 59_999 + expect(store.get(created.id)?.status).toBe('pending') + + clock.now += 2 + const expired = store.get(created.id) + expect(expired?.status).toBe('expired') + expect(expired?.resolvedAt).toBe(clock.now) + store.dispose() + }) + + test('expiry never rewrites an already resolved status', () => { + const { store, clock } = withClock(60_000) + const created = createProposal(store) + store.resolve({ proposalId: created.id, status: 'executed' }) + + clock.now += 59_000 + expect(store.get(created.id)?.status).toBe('executed') + store.dispose() + }) + + test('resolved proposals are evicted once the retention window passes', () => { + const { store, clock } = withClock(60_000) + const created = createProposal(store) + store.resolve({ proposalId: created.id, status: 'executed' }) + + clock.now += 60_001 + expect(store.get(created.id)).toBeNull() + store.dispose() + }) + + test('resolved proposals are evicted oldest-first past the cap', () => { + const { store } = withClock(60_000) + for (let i = 0; i < MAX_PROPOSALS + 10; i++) { + const proposal = createProposal(store) + store.resolve({ proposalId: proposal.id, status: 'executed' }) + } + + expect(store.list().length).toBeLessThanOrEqual(MAX_PROPOSALS) + store.dispose() + }) + + test('the cap never evicts a pending proposal', () => { + const { store } = withClock(60_000) + const pending = createProposal(store) + for (let i = 0; i < MAX_PROPOSALS + 10; i++) { + const proposal = createProposal(store) + store.resolve({ proposalId: proposal.id, status: 'executed' }) + } + + expect(store.get(pending.id)?.status).toBe('pending') + store.dispose() + }) + + test('an expired proposal can no longer be resolved', () => { + const { store, clock } = withClock(60_000) + const created = createProposal(store) + clock.now += 60_001 + + expect(() => store.resolve({ proposalId: created.id, status: 'executed' })).toThrow(/already expired/) + store.dispose() + }) + + test('default TTL is one hour', () => { + expect(PROPOSAL_TTL_MS).toBe(60 * 60 * 1000) + }) + }) + + // The app has to learn about transitions it did not cause, or a stale approval banner + // stays actionable and a click runs SQL for a proposal that no longer exists. + describe('onChange', () => { + test('reports creation, resolution and cancellation', () => { + const { store } = withClock() + const seen: ProposalStatus[] = [] + store.onChange((p) => seen.push(p.status)) + + const created = store.create({ connectionId: 'c1', sql: 'DELETE FROM t' }) + store.cancel(created.id) + const second = store.create({ connectionId: 'c1', sql: 'DELETE FROM t' }) + store.resolve({ proposalId: second.id, status: 'executed' }) + + expect(seen).toEqual(['pending', 'cancelled', 'pending', 'executed']) + store.dispose() + }) + + test('reports expiry, which nothing else would announce', () => { + const { store, clock } = withClock(60_000) + const created = createProposal(store) + const seen: string[] = [] + store.onChange((p) => seen.push(`${p.id === created.id ? 'same' : 'other'}:${p.status}`)) + + clock.now += 60_001 + store.get(created.id) + + expect(seen).toEqual(['same:expired']) + store.dispose() + }) + + test('unsubscribing stops delivery', () => { + const { store } = withClock() + const seen: ProposalStatus[] = [] + const off = store.onChange((p) => seen.push(p.status)) + off() + store.create({ connectionId: 'c1', sql: 'DELETE FROM t' }) + + expect(seen).toEqual([]) + store.dispose() + }) + }) + + describe('list', () => { + test('filters by status and connectionId', () => { + const { store } = withClock() + const a = createProposal(store, { connectionId: 'conn-1', sql: 'DELETE FROM a' }) + createProposal(store, { connectionId: 'conn-1', sql: 'DELETE FROM b' }) + createProposal(store, { connectionId: 'conn-2', sql: 'DELETE FROM c' }) + store.resolve({ proposalId: a.id, status: 'executed' }) + + expect(store.list()).toHaveLength(3) + expect(store.list({ status: 'pending' })).toHaveLength(2) + expect(store.list({ status: 'executed' }).map((p) => p.id)).toEqual([a.id]) + expect(store.list({ connectionId: 'conn-2' })).toHaveLength(1) + expect(store.list({ status: 'pending', connectionId: 'conn-1' })).toHaveLength(1) + store.dispose() + }) + + test('lists expired proposals after a sweep', () => { + const { store, clock } = withClock(60_000) + createProposal(store) + clock.now += 60_001 + + expect(store.list({ status: 'expired' })).toHaveLength(1) + expect(store.list({ status: 'pending' })).toHaveLength(0) + store.dispose() + }) + }) + + describe('wait', () => { + test('returns immediately when already resolved', async () => { + const { store } = withClock() + const created = createProposal(store) + store.resolve({ proposalId: created.id, status: 'rejected' }) + + expect((await store.wait(created.id, 50)).status).toBe('rejected') + expect(store.waiterCount()).toBe(0) + store.dispose() + }) + + test('resolves as soon as the status leaves pending', async () => { + const { store } = withClock() + const created = createProposal(store) + + const pending = store.wait(created.id, 5_000) + expect(store.waiterCount()).toBe(1) + store.resolve({ proposalId: created.id, status: 'executed', result: { affectedRows: 1 } }) + + const result = await pending + expect(result.status).toBe('executed') + expect(result.result).toEqual({ affectedRows: 1 }) + expect(store.waiterCount()).toBe(0) + store.dispose() + }) + + test('times out and returns the still-pending proposal', async () => { + const { store } = withClock() + const created = createProposal(store) + + const result = await store.wait(created.id, 20) + + expect(result.status).toBe('pending') + expect(store.waiterCount()).toBe(0) + store.dispose() + }) + + test('concurrent waiters all resolve and none leak', async () => { + const { store } = withClock() + const created = createProposal(store) + + const waits = [store.wait(created.id, 5_000), store.wait(created.id, 5_000), store.wait(created.id, 5_000)] + expect(store.waiterCount()).toBe(3) + store.cancel(created.id) + + const results = await Promise.all(waits) + expect(results.map((p) => p.status)).toEqual(['cancelled', 'cancelled', 'cancelled']) + expect(store.waiterCount()).toBe(0) + store.dispose() + }) + + test('waiting on an unknown proposal rejects', async () => { + const { store } = withClock() + + await expect(store.wait('nope', 10)).rejects.toThrow(/not found/) + expect(store.waiterCount()).toBe(0) + store.dispose() + }) + + test('expiry wakes a waiter', async () => { + const { store, clock } = withClock(60_000) + const created = createProposal(store) + + const pending = store.wait(created.id, 5_000) + clock.now += 60_001 + store.list() // any access sweeps + + expect((await pending).status).toBe('expired') + expect(store.waiterCount()).toBe(0) + store.dispose() + }) + }) + + describe('dispose', () => { + test('resolves in-flight waits and clears them', async () => { + const { store } = withClock() + const created = createProposal(store) + + const pending = store.wait(created.id, 60_000) + store.dispose() + + expect((await pending).status).toBe('pending') + expect(store.waiterCount()).toBe(0) + }) + + test('disposing stops delivering changes to observers', () => { + const { store } = withClock() + const seen: ProposalStatus[] = [] + store.onChange((p) => seen.push(p.status)) + store.dispose() + + expect(seen).toEqual([]) + }) + + test('a disposed store rejects further use', () => { + const { store } = withClock() + store.dispose() + + expect(() => createProposal(store)).toThrow(/disposed/) + expect(() => store.resolve({ proposalId: 'x', status: 'executed' })).toThrow(/disposed/) + expect(store.list()).toEqual([]) + }) + }) +}) diff --git a/tests/query-executor.test.ts b/tests/query-executor.test.ts index 410cad7e..5634dd1b 100644 --- a/tests/query-executor.test.ts +++ b/tests/query-executor.test.ts @@ -597,6 +597,7 @@ function makeMockDriver(overrides?: Partial): DatabaseDriver { cancel: mock(async () => {}), reserveSession: mock(async () => {}), releaseSession: mock(async () => {}), + isSessionReadOnly: () => false, quoteIdentifier: (name: string) => `"${name}"`, getDriverType: () => 'sqlite' as const, qualifyTable: (schema: string, table: string) => schema === 'main' ? `"${table}"` : `"${schema}"."${table}"`, diff --git a/tests/readonly-session.test.ts b/tests/readonly-session.test.ts new file mode 100644 index 00000000..7cf232aa --- /dev/null +++ b/tests/readonly-session.test.ts @@ -0,0 +1,812 @@ +/** + * Engine-enforced read-only sessions. + * + * SQLite needs a file-backed database: a read-only session gets its own `SQL` + * handle with `PRAGMA query_only = ON`, and `:memory:` cannot be shared between + * handles. PostgreSQL/MySQL coverage needs `docker compose up -d`. + * + * Run: bun test tests/readonly-session.test.ts + */ +import { MysqlDriver } from '@dotaz/backend-shared/drivers/mysql-driver' +import { PostgresDriver } from '@dotaz/backend-shared/drivers/postgres-driver' +import { SqliteDriver } from '@dotaz/backend-shared/drivers/sqlite-driver' +import { ConnectionManager } from '@dotaz/backend-shared/services/connection-manager' +import { QueryExecutor } from '@dotaz/backend-shared/services/query-executor' +import { SessionManager } from '@dotaz/backend-shared/services/session-manager' +import { AppDatabase } from '@dotaz/backend-shared/storage/app-db' +import type { MysqlConnectionConfig, PostgresConnectionConfig, SqliteConnectionConfig } from '@dotaz/shared/types/connection' +import { DatabaseError } from '@dotaz/shared/types/errors' +import { SQL } from 'bun' +import { Database } from 'bun:sqlite' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test' +import { rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { MYSQL_URL, PG_URL, seedMysql, seedPostgres, seedSqlite } from './helpers' + +let dbPath: string +let config: SqliteConnectionConfig + +const pgConfig: PostgresConnectionConfig = { + type: 'postgresql', + host: 'localhost', + port: 5488, + database: 'dotaz_test', + user: 'dotaz', + password: 'dotaz', +} + +const mysqlConfig: MysqlConnectionConfig = { + type: 'mysql', + host: 'localhost', + port: 3388, + database: 'dotaz_test', + user: 'dotaz', + password: 'dotaz', +} + +/** First column value of the first row — PRAGMA result column names vary. */ +function firstValue(rows: Record[]): unknown { + return rows.length > 0 ? Object.values(rows[0])[0] : undefined +} + +function createTempDb(): string { + const path = join(tmpdir(), `dotaz-readonly-${crypto.randomUUID()}.db`) + const seed = new Database(path) + seedSqlite(seed) + seed.close() + return path +} + +function removeTempDb(path: string): void { + for (const suffix of ['', '-wal', '-shm']) { + rmSync(`${path}${suffix}`, { force: true }) + } +} + +/** Register hooks giving each test in the enclosing describe a freshly seeded SQLite file. */ +function useTempSqliteDb(): void { + beforeEach(() => { + dbPath = createTempDb() + config = { type: 'sqlite', path: dbPath } + }) + + afterEach(() => { + removeTempDb(dbPath) + }) +} + +/** + * The docker-compose services are optional locally — skip their tests when they aren't up. + * + * Never skip in the integration job: engine-enforced read-only is the guarantee this file + * exists to prove, and a silent skip there once meant a whole invariant went unverified in CI + * while the suite reported green. + */ +async function isReachable(url: string): Promise { + try { + const db = new SQL({ url }) + await db.unsafe('SELECT 1') + await db.close() + return true + } catch (err) { + if (process.env.DOTAZ_REQUIRE_DB === '1') { + throw new Error(`${url} is unreachable, but DOTAZ_REQUIRE_DB=1 forbids skipping: ${err}`) + } + return false + } +} + +const pgReachable = await isReachable(PG_URL) +const mysqlReachable = await isReachable(MYSQL_URL) + +describe('SqliteDriver read-only sessions', () => { + useTempSqliteDb() + + let driver: SqliteDriver + let readOnlyId: string + let writableId: string + + beforeEach(async () => { + driver = new SqliteDriver() + await driver.connect(config) + readOnlyId = crypto.randomUUID() + writableId = crypto.randomUUID() + await driver.reserveSession(readOnlyId, { readOnly: true }) + await driver.reserveSession(writableId) + }) + + afterEach(async () => { + await driver.disconnect() + }) + + test('reports which sessions are read-only', () => { + expect(driver.isSessionReadOnly(readOnlyId)).toBe(true) + expect(driver.isSessionReadOnly(writableId)).toBe(false) + expect(driver.getSessionIds()).toContain(readOnlyId) + }) + + // The handle is opened read-only, so `PRAGMA query_only` is a second layer rather than + // the enforcement — the pragma's function form carries no `=`, classifies as a read, and + // would otherwise let the session switch its own enforcement off. + test('the read-only handle cannot be made writable from inside the session', async () => { + await driver.execute('PRAGMA query_only(0)', undefined, readOnlyId).catch(() => {}) + + await expect(driver.execute("INSERT INTO users (name) VALUES ('sneaky')", undefined, readOnlyId)).rejects.toThrow() + const count = await driver.execute("SELECT count(*) AS c FROM users WHERE name = 'sneaky'", undefined, readOnlyId) + expect(firstValue(count.rows)).toBe(0) + }) + + test('DDL is refused on a read-only session too', async () => { + await expect(driver.execute('CREATE TABLE sneaky (a INTEGER)', undefined, readOnlyId)).rejects.toThrow() + }) + + // SQLite shares one handle across sessions, so an unrecognised id used to fall through to + // it — turning a lost read-only handle (terminated, reconnected) into a silent write. + // PostgreSQL and MySQL already threw for the same input. + test('an unknown session id is refused instead of falling back to the writable handle', async () => { + const ghost = crypto.randomUUID() + + await expect(driver.execute("INSERT INTO users (name) VALUES ('ghost')", undefined, ghost)).rejects.toThrow(/not found/) + await expect(driver.beginTransaction(ghost)).rejects.toThrow(/not found/) + await expect(driver.commit(ghost)).rejects.toThrow(/not found/) + await expect(driver.rollback(ghost)).rejects.toThrow(/not found/) + }) + + test('a released read-only session cannot keep executing', async () => { + await driver.releaseSession(readOnlyId) + + await expect(driver.execute('SELECT 1', undefined, readOnlyId)).rejects.toThrow(/not found/) + }) + + test('query_only is on for the read-only session and nowhere else', async () => { + expect(firstValue((await driver.execute('PRAGMA query_only', undefined, readOnlyId)).rows)).toBe(1) + expect(firstValue((await driver.execute('PRAGMA query_only', undefined, writableId)).rows)).toBe(0) + expect(firstValue((await driver.execute('PRAGMA query_only')).rows)).toBe(0) + }) + + test('the read-only session still reads', async () => { + const result = await driver.execute('SELECT name FROM users ORDER BY id', undefined, readOnlyId) + expect(result.rows).toHaveLength(3) + expect(result.rows[0].name).toBe('Alice') + }) + + test('the read-only session still iterates', async () => { + const batches: Record[][] = [] + for await (const batch of driver.iterate('SELECT id FROM users ORDER BY id', undefined, 2, undefined, readOnlyId)) { + batches.push(batch) + } + expect(batches.flat()).toHaveLength(3) + }) + + test('INSERT is rejected by the engine', async () => { + const promise = driver.execute("INSERT INTO users (name, email) VALUES ('Dana', 'dana@example.com')", undefined, readOnlyId) + await expect(promise).rejects.toThrow(/readonly/i) + }) + + test('UPDATE is rejected by the engine', async () => { + const promise = driver.execute("UPDATE users SET name = 'Nope'", undefined, readOnlyId) + await expect(promise).rejects.toThrow(/readonly/i) + }) + + test('DELETE is rejected by the engine', async () => { + const promise = driver.execute('DELETE FROM users', undefined, readOnlyId) + await expect(promise).rejects.toThrow(/readonly/i) + }) + + test('DDL is rejected by the engine', async () => { + const promise = driver.execute('CREATE TABLE sneaky (id INTEGER)', undefined, readOnlyId) + await expect(promise).rejects.toThrow(/readonly/i) + }) + + test('a write disguised as a PRAGMA is still rejected by the engine', async () => { + // classifyStatement() calls this `unknown`, so only the engine can stop it + const promise = driver.execute('PRAGMA user_version = 4', undefined, readOnlyId) + await expect(promise).rejects.toThrow(/readonly/i) + }) + + test('a write inside an explicit transaction is still rejected', async () => { + await driver.beginTransaction(readOnlyId) + expect(driver.inTransaction(readOnlyId)).toBe(true) + try { + const promise = driver.execute('DELETE FROM posts', undefined, readOnlyId) + await expect(promise).rejects.toThrow(/readonly/i) + } finally { + await driver.rollback(readOnlyId) + } + expect(driver.inTransaction(readOnlyId)).toBe(false) + }) + + test('a normal session on the same connection still writes', async () => { + const inserted = await driver.execute( + "INSERT INTO users (name, email) VALUES ('Dana', 'dana@example.com')", + undefined, + writableId, + ) + expect(inserted.affectedRows).toBe(1) + + const pooled = await driver.execute("UPDATE users SET age = 40 WHERE name = 'Dana'") + expect(pooled.affectedRows).toBe(1) + + // The read-only session sees the committed write on the same file + const seen = await driver.execute("SELECT age FROM users WHERE name = 'Dana'", undefined, readOnlyId) + expect(seen.rows[0].age).toBe(40) + }) + + test("a read-only session's transaction does not block the shared connection", async () => { + await driver.beginTransaction(readOnlyId) + try { + const result = await driver.execute("INSERT INTO users (name, email) VALUES ('Eve', 'eve@example.com')", undefined, writableId) + expect(result.affectedRows).toBe(1) + } finally { + await driver.rollback(readOnlyId) + } + }) + + test('the dedicated handle is listed and closed on release', async () => { + const before = driver.listConnectionHandles().filter((h) => h.sessionId === readOnlyId) + expect(before).toHaveLength(1) + expect(before[0].role).toBe('session') + + await driver.releaseSession(readOnlyId) + + expect(driver.isSessionReadOnly(readOnlyId)).toBe(false) + expect(driver.getSessionIds()).not.toContain(readOnlyId) + expect(driver.listConnectionHandles().filter((h) => h.sessionId === readOnlyId)).toHaveLength(0) + }) +}) + +describe('SqliteDriver read-only sessions — unsupported databases', () => { + test('an in-memory database cannot back a read-only session', async () => { + const driver = new SqliteDriver() + await driver.connect({ type: 'sqlite', path: ':memory:' }) + try { + await expect(driver.reserveSession(crypto.randomUUID(), { readOnly: true })).rejects.toThrow(/file-backed/i) + // A failed reserve must not leave the session registered + expect(driver.getSessionIds()).toHaveLength(0) + } finally { + await driver.disconnect() + } + }) +}) + +describe('SqliteDriver read-only sessions — initSql', () => { + useTempSqliteDb() + + test('setup SQL is applied to the dedicated handle', async () => { + const driver = new SqliteDriver() + await driver.connect({ ...config, initSql: 'PRAGMA busy_timeout = 7000' }) + const sessionId = crypto.randomUUID() + await driver.reserveSession(sessionId, { readOnly: true }) + try { + expect(firstValue((await driver.execute('PRAGMA busy_timeout', undefined, sessionId)).rows)).toBe(7000) + expect(firstValue((await driver.execute('PRAGMA query_only', undefined, sessionId)).rows)).toBe(1) + } finally { + await driver.disconnect() + } + }) +}) + +describe('SqliteDriver read-only sessions — statement timeout', () => { + useTempSqliteDb() + + // SQLite has no statement timeout, so the option is deliberately a no-op — it must + // not silently change the handle (busy_timeout is a lock wait, not a runtime cap). + test('the statement timeout option changes nothing', async () => { + const driver = new SqliteDriver() + await driver.connect(config) + const sessionId = crypto.randomUUID() + await driver.reserveSession(sessionId, { readOnly: true, statementTimeoutMs: 200 }) + try { + expect(firstValue((await driver.execute('PRAGMA busy_timeout', undefined, sessionId)).rows)).toBe(0) + expect(firstValue((await driver.execute('PRAGMA query_only', undefined, sessionId)).rows)).toBe(1) + const result = await driver.execute('SELECT count(*) AS n FROM users', undefined, sessionId) + expect(Number(result.rows[0].n)).toBe(3) + } finally { + await driver.disconnect() + } + }) +}) + +describe('SessionManager read-only sessions', () => { + useTempSqliteDb() + + let appDb: AppDatabase + let cm: ConnectionManager + let sm: SessionManager + let connectionId: string + + beforeEach(async () => { + AppDatabase.resetInstance() + appDb = AppDatabase.getInstance(':memory:') + cm = new ConnectionManager(appDb) + sm = new SessionManager(cm, appDb) + connectionId = cm.createConnection({ name: 'Test', config }).id + await cm.connect(connectionId) + }) + + afterEach(async () => { + sm.dispose() + await cm.disconnectAll() + AppDatabase.resetInstance() + }) + + test('createSession marks the session read-only on the driver', async () => { + const session = await sm.createSession(connectionId, undefined, { readOnly: true, label: 'Agent' }) + expect(session.readOnly).toBe(true) + expect(session.label).toBe('Agent') + expect(cm.getDriver(connectionId).isSessionReadOnly(session.sessionId)).toBe(true) + }) + + test('existing two-argument callers stay writable', async () => { + const session = await sm.createSession(connectionId) + expect(session.readOnly).toBeFalsy() + expect(session.label).toBe('Session 1') + expect(cm.getDriver(connectionId).isSessionReadOnly(session.sessionId)).toBe(false) + }) + + test('listSessions and getSession report the read-only flag', async () => { + const session = await sm.createSession(connectionId, undefined, { readOnly: true }) + expect(sm.getSession(session.sessionId)?.readOnly).toBe(true) + expect(sm.listSessions(connectionId)[0].readOnly).toBe(true) + }) + + test('restoration after reconnect keeps the session read-only and labelled', async () => { + await sm.createSession(connectionId, undefined, { readOnly: true, label: 'Agent CLI' }) + await sm.createSession(connectionId) + + sm.handleConnectionLost(connectionId) + const restored = await sm.handleConnectionRestored(connectionId) + + expect(restored).toHaveLength(2) + const agent = restored.find((s) => s.label === 'Agent CLI') + expect(agent).toBeDefined() + expect(agent!.readOnly).toBe(true) + expect(cm.getDriver(connectionId).isSessionReadOnly(agent!.sessionId)).toBe(true) + + const normal = restored.find((s) => s.label === 'Session 2') + expect(normal).toBeDefined() + expect(cm.getDriver(connectionId).isSessionReadOnly(normal!.sessionId)).toBe(false) + }) +}) + +// Requires `docker compose up -d` — skipped when the container isn't reachable. +describe.skipIf(!pgReachable)('SessionManager read-only statement timeout', () => { + let appDb: AppDatabase + let cm: ConnectionManager + let sm: SessionManager + let connectionId: string + + beforeEach(async () => { + AppDatabase.resetInstance() + appDb = AppDatabase.getInstance(':memory:') + cm = new ConnectionManager(appDb) + sm = new SessionManager(cm, appDb) + connectionId = cm.createConnection({ name: 'Test PG', config: pgConfig }).id + await cm.connect(connectionId) + }, 30_000) + + afterEach(async () => { + sm.dispose() + await cm.disconnectAll() + AppDatabase.resetInstance() + }) + + /** The engine's own view of the cap on a session. */ + async function statementTimeout(sessionId: string): Promise { + return firstValue((await cm.getDriver(connectionId).execute('SHOW statement_timeout', undefined, sessionId)).rows) + } + + test('the queryTimeout setting caps a read-only session', async () => { + appDb.setSetting('queryTimeout', '250') + const session = await sm.createSession(connectionId, undefined, { readOnly: true, label: 'Agent' }) + expect(await statementTimeout(session.sessionId)).toBe('250ms') + await expect(cm.getDriver(connectionId).execute('SELECT pg_sleep(5)', undefined, session.sessionId)) + .rejects.toThrow(/statement timeout/i) + }) + + test('the default setting applies when nothing is stored', async () => { + const session = await sm.createSession(connectionId, undefined, { readOnly: true }) + expect(await statementTimeout(session.sessionId)).toBe('30s') + }) + + test('a normal session is left uncapped', async () => { + appDb.setSetting('queryTimeout', '250') + const session = await sm.createSession(connectionId) + expect(await statementTimeout(session.sessionId)).toBe('0') + }) + + test('queryTimeout = 0 means no cap', async () => { + appDb.setSetting('queryTimeout', '0') + const session = await sm.createSession(connectionId, undefined, { readOnly: true }) + expect(await statementTimeout(session.sessionId)).toBe('0') + }) + + test('a restored read-only session comes back capped', async () => { + appDb.setSetting('queryTimeout', '250') + await sm.createSession(connectionId, undefined, { readOnly: true, label: 'Agent CLI' }) + + sm.handleConnectionLost(connectionId) + const restored = await sm.handleConnectionRestored(connectionId) + + expect(restored).toHaveLength(1) + expect(await statementTimeout(restored[0].sessionId)).toBe('250ms') + }) +}) + +describe('QueryExecutor read-only guard', () => { + useTempSqliteDb() + + let appDb: AppDatabase + let cm: ConnectionManager + let sm: SessionManager + let qe: QueryExecutor + let connectionId: string + let readOnlySessionId: string + let writableSessionId: string + + beforeEach(async () => { + AppDatabase.resetInstance() + appDb = AppDatabase.getInstance(':memory:') + cm = new ConnectionManager(appDb) + sm = new SessionManager(cm, appDb) + qe = new QueryExecutor(cm) + connectionId = cm.createConnection({ name: 'Test', config }).id + await cm.connect(connectionId) + readOnlySessionId = (await sm.createSession(connectionId, undefined, { readOnly: true })).sessionId + writableSessionId = (await sm.createSession(connectionId)).sessionId + }) + + afterEach(async () => { + sm.dispose() + await cm.disconnectAll() + AppDatabase.resetInstance() + }) + + test('rejects a write before it reaches the driver', async () => { + const promise = qe.executeQuery( + connectionId, + "INSERT INTO users (name, email) VALUES ('Dana', 'dana@example.com')", + undefined, + undefined, + undefined, + undefined, + readOnlySessionId, + ) + await expect(promise).rejects.toThrow(DatabaseError) + await promise.catch((err: unknown) => { + expect(err).toBeInstanceOf(DatabaseError) + expect((err as DatabaseError).code).toBe('READ_ONLY_SESSION') + expect((err as DatabaseError).message).toContain('dotaz propose') + }) + }) + + test('rejects a data-modifying CTE', async () => { + const promise = qe.executeQuery( + connectionId, + 'WITH t AS (SELECT 1 AS n) DELETE FROM users WHERE id IN (SELECT n FROM t)', + undefined, + undefined, + undefined, + undefined, + readOnlySessionId, + ) + await expect(promise).rejects.toThrow(/read-only/i) + }) + + test('rejects a batch where only one statement writes', async () => { + const promise = qe.executeQuery( + connectionId, + 'SELECT 1; DELETE FROM users;', + undefined, + undefined, + undefined, + undefined, + readOnlySessionId, + ) + await expect(promise).rejects.toThrow(/read-only/i) + }) + + test('fails closed on a statement it cannot classify', async () => { + const promise = qe.executeQuery(connectionId, 'PRAGMA user_version = 4', undefined, undefined, undefined, undefined, readOnlySessionId) + await expect(promise).rejects.toThrow(/read-only/i) + }) + + test('allows reads on the read-only session', async () => { + const results = await qe.executeQuery( + connectionId, + 'SELECT name FROM users ORDER BY id', + undefined, + undefined, + undefined, + undefined, + readOnlySessionId, + ) + expect(results).toHaveLength(1) + expect(results[0].error).toBeUndefined() + expect(results[0].rows).toHaveLength(3) + }) + + test('rejects EXPLAIN ANALYZE of a write but allows plain EXPLAIN', async () => { + const analyze = qe.explainQuery(connectionId, 'DELETE FROM users', true, undefined, readOnlySessionId) + await expect(analyze).rejects.toThrow(/read-only/i) + + const plain = await qe.explainQuery(connectionId, 'SELECT * FROM users', false, undefined, readOnlySessionId) + expect(plain.error).toBeUndefined() + }) + + test('leaves normal sessions alone', async () => { + const results = await qe.executeQuery( + connectionId, + "INSERT INTO users (name, email) VALUES ('Dana', 'dana@example.com')", + undefined, + undefined, + undefined, + undefined, + writableSessionId, + ) + expect(results[0].error).toBeUndefined() + expect(results[0].affectedRows).toBe(1) + }) + + test('leaves sessionless (pool) execution alone', async () => { + const results = await qe.executeQuery(connectionId, "UPDATE users SET age = 41 WHERE name = 'Alice'") + expect(results[0].error).toBeUndefined() + expect(results[0].affectedRows).toBe(1) + }) +}) + +// Requires `docker compose up -d` — skipped when the container isn't reachable. +describe.skipIf(!pgReachable)('PostgresDriver read-only sessions', () => { + let driver: PostgresDriver + let readOnlyId: string + let writableId: string + + beforeAll(async () => { + await seedPostgres() + driver = new PostgresDriver() + await driver.connect(pgConfig) + }, 30_000) + + afterAll(async () => { + if (driver.isConnected()) await driver.disconnect() + }) + + beforeEach(async () => { + readOnlyId = crypto.randomUUID() + writableId = crypto.randomUUID() + await driver.reserveSession(readOnlyId, { readOnly: true }) + await driver.reserveSession(writableId) + }) + + afterEach(async () => { + await driver.releaseSession(readOnlyId) + await driver.releaseSession(writableId) + }) + + test('the session connection reports read-only, others do not', async () => { + expect(driver.isSessionReadOnly(readOnlyId)).toBe(true) + expect(driver.isSessionReadOnly(writableId)).toBe(false) + expect(firstValue((await driver.execute('SHOW transaction_read_only', undefined, readOnlyId)).rows)).toBe('on') + expect(firstValue((await driver.execute('SHOW transaction_read_only', undefined, writableId)).rows)).toBe('off') + }) + + test('writes and DDL are rejected by the engine', async () => { + const statements = [ + "INSERT INTO test_schema.users (name, email) VALUES ('Dana', 'dana@example.com')", + "UPDATE test_schema.users SET name = 'Nope'", + 'DELETE FROM test_schema.users', + 'CREATE TABLE test_schema.sneaky (id integer)', + ] + for (const sql of statements) { + await expect(driver.execute(sql, undefined, readOnlyId)).rejects.toThrow(/read-only transaction/i) + } + }) + + test('a write hidden behind a read (nextval) is rejected by the engine', async () => { + // classifyStatement() calls this a read — only the engine can stop it + await expect(driver.execute("SELECT nextval('test_schema.users_id_seq')", undefined, readOnlyId)) + .rejects.toThrow(/read-only transaction/i) + }) + + test('a transaction started later on the session is read-only too', async () => { + await driver.beginTransaction(readOnlyId) + try { + expect(driver.inTransaction(readOnlyId)).toBe(true) + await expect(driver.execute("INSERT INTO test_schema.users (name, email) VALUES ('Eve', 'eve@example.com')", undefined, readOnlyId)) + .rejects.toThrow(/read-only transaction/i) + } finally { + await driver.rollback(readOnlyId) + } + }) + + test('the read-only session still reads', async () => { + const result = await driver.execute('SELECT count(*)::int AS n FROM test_schema.users', undefined, readOnlyId) + expect(Number(result.rows[0].n)).toBeGreaterThan(0) + }) + + test('a normal session on the same connection still writes', async () => { + const result = await driver.execute( + "INSERT INTO test_schema.users (name, email) VALUES ('Frank', 'frank@example.com')", + undefined, + writableId, + ) + expect(result.affectedRows).toBe(1) + }) +}) + +// Requires `docker compose up -d` — skipped when the container isn't reachable. +describe.skipIf(!pgReachable)('PostgresDriver read-only statement timeout', () => { + let driver: PostgresDriver + + beforeAll(async () => { + driver = new PostgresDriver() + await driver.connect(pgConfig) + }, 30_000) + + afterAll(async () => { + if (driver.isConnected()) await driver.disconnect() + }) + + test('the engine cancels a slow query on a capped read-only session', async () => { + const sessionId = crypto.randomUUID() + await driver.reserveSession(sessionId, { readOnly: true, statementTimeoutMs: 200 }) + try { + expect(firstValue((await driver.execute('SHOW statement_timeout', undefined, sessionId)).rows)).toBe('200ms') + await expect(driver.execute('SELECT pg_sleep(5)', undefined, sessionId)).rejects.toThrow(/statement timeout/i) + } finally { + await driver.releaseSession(sessionId) + } + }) + + test('a normal session on the same connection is never capped', async () => { + const sessionId = crypto.randomUUID() + // Even when asked for — the cap belongs to read-only sessions alone + await driver.reserveSession(sessionId, { statementTimeoutMs: 200 }) + try { + expect(firstValue((await driver.execute('SHOW statement_timeout', undefined, sessionId)).rows)).toBe('0') + const result = await driver.execute('SELECT pg_sleep(0.6)', undefined, sessionId) + expect(result.rows).toHaveLength(1) + expect(firstValue((await driver.execute('SHOW statement_timeout')).rows)).toBe('0') + } finally { + await driver.releaseSession(sessionId) + } + }) + + test('no timeout is set when the value is 0 or absent', async () => { + const zeroId = crypto.randomUUID() + const absentId = crypto.randomUUID() + await driver.reserveSession(zeroId, { readOnly: true, statementTimeoutMs: 0 }) + await driver.reserveSession(absentId, { readOnly: true }) + try { + expect(firstValue((await driver.execute('SHOW statement_timeout', undefined, zeroId)).rows)).toBe('0') + expect(firstValue((await driver.execute('SHOW statement_timeout', undefined, absentId)).rows)).toBe('0') + const result = await driver.execute('SELECT pg_sleep(0.6)', undefined, zeroId) + expect(result.rows).toHaveLength(1) + } finally { + await driver.releaseSession(zeroId) + await driver.releaseSession(absentId) + } + }) +}) + +// Requires `docker compose up -d` — skipped when the container isn't reachable. +describe.skipIf(!mysqlReachable)('MysqlDriver read-only sessions', () => { + let driver: MysqlDriver + let readOnlyId: string + let writableId: string + + beforeAll(async () => { + await seedMysql() + driver = new MysqlDriver() + await driver.connect(mysqlConfig) + }, 30_000) + + afterAll(async () => { + if (driver.isConnected()) await driver.disconnect() + }) + + beforeEach(async () => { + readOnlyId = crypto.randomUUID() + writableId = crypto.randomUUID() + await driver.reserveSession(readOnlyId, { readOnly: true }) + await driver.reserveSession(writableId) + }) + + afterEach(async () => { + await driver.releaseSession(readOnlyId) + await driver.releaseSession(writableId) + }) + + test('the session connection reports read-only, others do not', async () => { + expect(driver.isSessionReadOnly(readOnlyId)).toBe(true) + expect(driver.isSessionReadOnly(writableId)).toBe(false) + expect(Number(firstValue((await driver.execute('SELECT @@session.transaction_read_only AS v', undefined, readOnlyId)).rows))).toBe(1) + expect(Number(firstValue((await driver.execute('SELECT @@session.transaction_read_only AS v', undefined, writableId)).rows))).toBe(0) + }) + + test('writes and DDL are rejected by the engine', async () => { + const statements = [ + "INSERT INTO users (name, email) VALUES ('Dana', 'dana@example.com')", + "UPDATE users SET name = 'Nope'", + 'DELETE FROM users', + 'CREATE TABLE sneaky (id int)', + ] + for (const sql of statements) { + await expect(driver.execute(sql, undefined, readOnlyId)).rejects.toThrow(/read only transaction/i) + } + }) + + test('a transaction started later on the session is read-only too', async () => { + await driver.beginTransaction(readOnlyId) + try { + expect(driver.inTransaction(readOnlyId)).toBe(true) + await expect(driver.execute("INSERT INTO users (name, email) VALUES ('Eve', 'eve@example.com')", undefined, readOnlyId)) + .rejects.toThrow(/read only transaction/i) + } finally { + await driver.rollback(readOnlyId) + } + }) + + test('the read-only session still reads', async () => { + const result = await driver.execute('SELECT count(*) AS n FROM users', undefined, readOnlyId) + expect(Number(result.rows[0].n)).toBeGreaterThan(0) + }) + + test('a normal session on the same connection still writes', async () => { + const result = await driver.execute("INSERT INTO users (name, email) VALUES ('Frank', 'frank@example.com')", undefined, writableId) + expect(result.affectedRows).toBe(1) + }) +}) + +// Requires `docker compose up -d` — skipped when the container isn't reachable. +describe.skipIf(!mysqlReachable)('MysqlDriver read-only statement timeout', () => { + let driver: MysqlDriver + + beforeAll(async () => { + driver = new MysqlDriver() + await driver.connect(mysqlConfig) + }, 30_000) + + afterAll(async () => { + if (driver.isConnected()) await driver.disconnect() + }) + + test('the engine cancels a slow query on a capped read-only session', async () => { + const sessionId = crypto.randomUUID() + await driver.reserveSession(sessionId, { readOnly: true, statementTimeoutMs: 200 }) + try { + // MySQL: "maximum statement execution time exceeded"; MariaDB: "max_statement_time exceeded" + await expect(driver.execute('SELECT SLEEP(5) AS s', undefined, sessionId)) + .rejects.toThrow(/statement.execution.time exceeded|max_statement_time exceeded/i) + } finally { + await driver.releaseSession(sessionId) + } + }) + + test('a normal session on the same connection is never capped', async () => { + const sessionId = crypto.randomUUID() + // Even when asked for — the cap belongs to read-only sessions alone + await driver.reserveSession(sessionId, { statementTimeoutMs: 200 }) + try { + const result = await driver.execute('SELECT SLEEP(0.6) AS s', undefined, sessionId) + expect(Number(result.rows[0].s)).toBe(0) + } finally { + await driver.releaseSession(sessionId) + } + }) + + test('no timeout is applied when the value is 0 or absent', async () => { + const zeroId = crypto.randomUUID() + const absentId = crypto.randomUUID() + await driver.reserveSession(zeroId, { readOnly: true, statementTimeoutMs: 0 }) + await driver.reserveSession(absentId, { readOnly: true }) + try { + expect(Number(firstValue((await driver.execute('SELECT SLEEP(0.6) AS s', undefined, zeroId)).rows))).toBe(0) + expect(Number(firstValue((await driver.execute('SELECT SLEEP(0.6) AS s', undefined, absentId)).rows))).toBe(0) + } finally { + await driver.releaseSession(zeroId) + await driver.releaseSession(absentId) + } + }) +}) diff --git a/tests/rpc-dispatch.test.ts b/tests/rpc-dispatch.test.ts new file mode 100644 index 00000000..e97f3011 --- /dev/null +++ b/tests/rpc-dispatch.test.ts @@ -0,0 +1,125 @@ +/** + * Tests for the transport-agnostic RPC dispatcher shared by the web WebSocket + * server and the CLI control server. + * + * Run: bun test tests/rpc-dispatch.test.ts + */ +import { dispatchRpc, invalidJsonResponse, parseRpcRequest } from '@dotaz/backend-shared/rpc/dispatch' +import type { RpcHandler, RpcHandlerLookup } from '@dotaz/backend-shared/rpc/dispatch' +import { DatabaseError } from '@dotaz/shared/types/errors' +import { describe, expect, test } from 'bun:test' + +function lookup(handlers: Record): RpcHandlerLookup { + return (method) => handlers[method] +} + +describe('dispatchRpc', () => { + test('successful dispatch returns the handler payload', async () => { + const res = await dispatchRpc( + { id: 1, method: 'ping', params: { x: 1 } }, + lookup({ ping: (params) => ({ echo: params }) }), + ) + expect(res).toEqual({ type: 'response', id: 1, success: true, payload: { echo: { x: 1 } } }) + }) + + test('unknown method reports success: false with the method name', async () => { + const res = await dispatchRpc({ id: 2, method: 'nope.method' }, lookup({})) + expect(res).toEqual({ type: 'response', id: 2, success: false, error: 'Unknown method: nope.method' }) + }) + + test('handler throwing a plain Error becomes an error response', async () => { + const res = await dispatchRpc( + { id: 3, method: 'boom' }, + lookup({ + boom: () => { + throw new Error('kaboom') + }, + }), + ) + expect(res).toEqual({ type: 'response', id: 3, success: false, error: 'kaboom', errorCode: undefined }) + }) + + test('handler throwing a DatabaseError propagates errorCode', async () => { + const res = await dispatchRpc( + { id: 4, method: 'writeInReadOnly' }, + lookup({ + writeInReadOnly: () => { + throw new DatabaseError('READ_ONLY_SESSION', 'session is read-only') + }, + }), + ) + expect(res).toEqual({ + type: 'response', + id: 4, + success: false, + error: 'session is read-only', + errorCode: 'READ_ONLY_SESSION', + }) + }) + + test('async handlers are awaited', async () => { + const res = await dispatchRpc( + { id: 5, method: 'slow' }, + lookup({ + slow: async () => { + await Promise.resolve() + return { done: true } + }, + }), + ) + expect(res).toEqual({ type: 'response', id: 5, success: true, payload: { done: true } }) + }) + + test('an async handler that rejects is treated the same as a throw', async () => { + const res = await dispatchRpc( + { id: 6, method: 'rejects' }, + lookup({ rejects: async () => Promise.reject(new Error('rejected')) }), + ) + expect(res).toEqual({ type: 'response', id: 6, success: false, error: 'rejected', errorCode: undefined }) + }) + + test('missing id defaults to 0', async () => { + const res = await dispatchRpc({ method: 'ping' }, lookup({ ping: () => 'pong' })) + expect(res.id).toBe(0) + expect(res).toEqual({ type: 'response', id: 0, success: true, payload: 'pong' }) + }) +}) + +describe('parseRpcRequest', () => { + test('parses a valid JSON request from a string', () => { + const req = parseRpcRequest(JSON.stringify({ id: 7, method: 'connections.list', params: { a: 1 } })) + expect(req).toEqual({ id: 7, method: 'connections.list', params: { a: 1 } }) + }) + + test('parses a valid JSON request from a Uint8Array', () => { + const bytes = new TextEncoder().encode(JSON.stringify({ method: 'ping' })) + const req = parseRpcRequest(bytes) + expect(req).toEqual({ id: undefined, method: 'ping', params: undefined }) + }) + + test('parses a valid JSON request from an ArrayBuffer', () => { + const buffer = new TextEncoder().encode(JSON.stringify({ method: 'ping' })).buffer + const req = parseRpcRequest(buffer) + expect(req).toEqual({ id: undefined, method: 'ping', params: undefined }) + }) + + test('returns null for invalid JSON', () => { + expect(parseRpcRequest('{not json')).toBeNull() + }) + + test('returns null when method is missing', () => { + expect(parseRpcRequest(JSON.stringify({ id: 1, params: {} }))).toBeNull() + }) + + test('returns null for non-object JSON', () => { + expect(parseRpcRequest('"just a string"')).toBeNull() + expect(parseRpcRequest('42')).toBeNull() + expect(parseRpcRequest('null')).toBeNull() + }) +}) + +describe('invalidJsonResponse', () => { + test('matches the documented envelope', () => { + expect(invalidJsonResponse()).toEqual({ type: 'response', id: 0, success: false, error: 'Invalid JSON' }) + }) +}) diff --git a/tests/search-service.test.ts b/tests/search-service.test.ts index 2f713a5b..ca6c33e4 100644 --- a/tests/search-service.test.ts +++ b/tests/search-service.test.ts @@ -18,6 +18,8 @@ let driver: SqliteDriver class CapturingMysqlSearchDriver implements DatabaseDriver { readonly executedSql: string[] = [] readonly executedParams: unknown[][] = [] + readonly executeSessionIds: (string | undefined)[] = [] + readonly schemaSessionIds: (string | undefined)[] = [] connect(_config: ConnectionConfig): Promise { return Promise.resolve() @@ -39,13 +41,18 @@ class CapturingMysqlSearchDriver implements DatabaseDriver { return Promise.resolve() } + isSessionReadOnly(_sessionId: string): boolean { + return false + } + getSessionIds(): string[] { return [] } - execute(sql: string, params?: unknown[]): Promise { + execute(sql: string, params?: unknown[], sessionId?: string): Promise { this.executedSql.push(sql) this.executedParams.push(params ?? []) + this.executeSessionIds.push(sessionId) return Promise.resolve({ columns: [], rows: [], @@ -69,7 +76,8 @@ class CapturingMysqlSearchDriver implements DatabaseDriver { return Promise.resolve(0) } - loadSchema(_sessionId?: string): Promise { + loadSchema(sessionId?: string): Promise { + this.schemaSessionIds.push(sessionId) return Promise.resolve({ schemas: [{ name: 'app' }], tables: { @@ -383,6 +391,7 @@ describe('searchDatabase', () => { searchTerm: 'Alice', scope: 'database', resultsPerTable: 5, + sessionId: 'agent-session', }, () => {}, () => false, @@ -392,5 +401,7 @@ describe('searchDatabase', () => { 'SELECT * FROM `app`.`users` WHERE CAST(`name` AS CHAR) LIKE ? OR CAST(`age` AS CHAR) LIKE ? LIMIT ?', ]) expect(mysqlDriver.executedParams).toEqual([['%Alice%', '%Alice%', 5]]) + expect(mysqlDriver.schemaSessionIds).toEqual(['agent-session']) + expect(mysqlDriver.executeSessionIds).toEqual(['agent-session']) }) }) diff --git a/tests/statement-classification.test.ts b/tests/statement-classification.test.ts new file mode 100644 index 00000000..dbfb03a2 --- /dev/null +++ b/tests/statement-classification.test.ts @@ -0,0 +1,241 @@ +/** + * Statement classification — pure unit tests, no database needed. + * + * Run: bun test tests/statement-classification.test.ts + */ +import { classifyStatement, isReadOnlySql } from '@dotaz/shared/sql/statements' +import { describe, expect, test } from 'bun:test' + +describe('classifyStatement — reads', () => { + test('SELECT', () => { + expect(classifyStatement('SELECT * FROM users')).toBe('read') + expect(classifyStatement(' select 1 ')).toBe('read') + }) + + test('WITH ... SELECT', () => { + expect(classifyStatement('WITH t AS (SELECT 1 AS n) SELECT * FROM t')).toBe('read') + expect(classifyStatement('WITH RECURSIVE t(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM t) SELECT n FROM t')).toBe('read') + }) + + test('parenthesised compound SELECT', () => { + expect(classifyStatement('(SELECT 1) UNION (SELECT 2)')).toBe('read') + }) + + test('SHOW / DESCRIBE / DESC', () => { + expect(classifyStatement('SHOW search_path')).toBe('read') + expect(classifyStatement('DESCRIBE users')).toBe('read') + expect(classifyStatement('DESC users')).toBe('read') + }) + + test('VALUES and TABLE', () => { + expect(classifyStatement('VALUES (1), (2)')).toBe('read') + expect(classifyStatement('TABLE users')).toBe('read') + }) + + test('PRAGMA read', () => { + expect(classifyStatement('PRAGMA table_info(users)')).toBe('read') + expect(classifyStatement('PRAGMA query_only')).toBe('read') + }) + + test('PRAGMA assignment is not provably a read', () => { + expect(classifyStatement('PRAGMA query_only = ON')).toBe('unknown') + expect(classifyStatement('PRAGMA user_version = 4')).toBe('unknown') + }) +}) + +describe('classifyStatement — EXPLAIN', () => { + test('plain EXPLAIN never executes the statement', () => { + expect(classifyStatement('EXPLAIN SELECT * FROM users')).toBe('read') + expect(classifyStatement('EXPLAIN INSERT INTO users (name) VALUES (1)')).toBe('read') + expect(classifyStatement('EXPLAIN QUERY PLAN SELECT * FROM users')).toBe('read') + expect(classifyStatement('EXPLAIN FORMAT=JSON SELECT * FROM users')).toBe('read') + }) + + test('EXPLAIN ANALYZE takes the kind of the statement it runs', () => { + expect(classifyStatement('EXPLAIN ANALYZE SELECT * FROM users')).toBe('read') + expect(classifyStatement('EXPLAIN ANALYZE INSERT INTO users (name) VALUES (1)')).toBe('write') + expect(classifyStatement('EXPLAIN ANALYZE DELETE FROM users')).toBe('write') + expect(classifyStatement('EXPLAIN ANALYZE VERBOSE UPDATE users SET name = 1')).toBe('write') + }) + + test('EXPLAIN with a parenthesised option list', () => { + expect(classifyStatement('EXPLAIN (FORMAT JSON) SELECT 1')).toBe('read') + expect(classifyStatement('EXPLAIN (ANALYZE, FORMAT JSON) SELECT 1')).toBe('read') + expect(classifyStatement('EXPLAIN (ANALYZE, FORMAT JSON) UPDATE users SET name = 1')).toBe('write') + expect(classifyStatement('EXPLAIN (ANALYZE FALSE) DELETE FROM users')).toBe('read') + }) +}) + +describe('classifyStatement — writes', () => { + test('plain DML', () => { + expect(classifyStatement('INSERT INTO users (name) VALUES (1)')).toBe('write') + expect(classifyStatement('UPDATE users SET name = 1')).toBe('write') + expect(classifyStatement('DELETE FROM users')).toBe('write') + expect(classifyStatement('REPLACE INTO users (id) VALUES (1)')).toBe('write') + expect(classifyStatement('TRUNCATE users')).toBe('write') + expect(classifyStatement('MERGE INTO users USING staged ON (users.id = staged.id)')).toBe('write') + }) + + test('procedure invocation', () => { + expect(classifyStatement('CALL do_the_thing()')).toBe('write') + expect(classifyStatement('DO $$ BEGIN PERFORM 1; END $$')).toBe('write') + }) + + test('COPY ... FROM loads data', () => { + expect(classifyStatement("COPY users FROM '/tmp/users.csv'")).toBe('write') + }) + + test('data-modifying CTEs are classified by their tail', () => { + expect(classifyStatement('WITH deleted AS (SELECT id FROM stale) DELETE FROM users WHERE id IN (SELECT id FROM deleted)')).toBe('write') + expect(classifyStatement('WITH t AS (SELECT 1 AS n) INSERT INTO users (id) SELECT n FROM t')).toBe('write') + expect(classifyStatement('WITH t AS (SELECT 1 AS n) UPDATE users SET id = (SELECT n FROM t)')).toBe('write') + expect(classifyStatement('WITH a AS (SELECT 1), b AS (SELECT 2) DELETE FROM users')).toBe('write') + }) + + test('a SELECT nested inside a CTE body does not make the statement a read', () => { + expect(classifyStatement('WITH t AS (SELECT id FROM users WHERE id IN (SELECT id FROM other)) DELETE FROM users')).toBe('write') + }) +}) + +describe('classifyStatement — DDL', () => { + test('schema changes', () => { + expect(classifyStatement('CREATE TABLE t (id INTEGER)')).toBe('ddl') + expect(classifyStatement('ALTER TABLE t ADD COLUMN x INTEGER')).toBe('ddl') + expect(classifyStatement('DROP TABLE t')).toBe('ddl') + expect(classifyStatement('CREATE INDEX idx ON t (id)')).toBe('ddl') + }) + + test('privileges and maintenance', () => { + expect(classifyStatement('GRANT SELECT ON users TO alice')).toBe('ddl') + expect(classifyStatement('REVOKE SELECT ON users FROM alice')).toBe('ddl') + expect(classifyStatement('VACUUM')).toBe('ddl') + expect(classifyStatement('REINDEX users')).toBe('ddl') + expect(classifyStatement("ATTACH DATABASE '/tmp/other.db' AS other")).toBe('ddl') + expect(classifyStatement('DETACH DATABASE other')).toBe('ddl') + }) +}) + +describe('classifyStatement — unknown', () => { + test('empty and comment-only input', () => { + expect(classifyStatement('')).toBe('unknown') + expect(classifyStatement(' ')).toBe('unknown') + expect(classifyStatement('-- just a comment')).toBe('unknown') + expect(classifyStatement('/* block */')).toBe('unknown') + }) + + test('session state and transaction control are not provable reads', () => { + expect(classifyStatement('SET search_path TO public')).toBe('unknown') + expect(classifyStatement('BEGIN')).toBe('unknown') + expect(classifyStatement('COMMIT')).toBe('unknown') + expect(classifyStatement('ANALYZE')).toBe('unknown') + }) + + test('COPY ... TO is not a database read', () => { + expect(classifyStatement("COPY users TO '/tmp/users.csv'")).toBe('unknown') + }) +}) + +describe('classifyStatement — literals and comments', () => { + test('keywords inside string literals do not count', () => { + expect(classifyStatement("SELECT 'DELETE FROM users' AS sql")).toBe('read') + expect(classifyStatement(`SELECT 'it''s an INSERT' AS note`)).toBe('read') + expect(classifyStatement('SELECT $$ DROP TABLE users $$ AS body')).toBe('read') + }) + + test('keywords inside quoted identifiers do not count', () => { + expect(classifyStatement('SELECT "insert" FROM "update"')).toBe('read') + }) + + test('leading comments do not hide the operation', () => { + expect(classifyStatement('-- read the users\nSELECT * FROM users')).toBe('read') + expect(classifyStatement('/* audit */ DELETE FROM users')).toBe('write') + expect(classifyStatement('/* SELECT */ UPDATE users SET name = 1')).toBe('write') + }) +}) + +describe('isReadOnlySql', () => { + test('true only when every statement reads', () => { + expect(isReadOnlySql('SELECT 1')).toBe(true) + expect(isReadOnlySql('SELECT 1; SELECT 2;')).toBe(true) + expect(isReadOnlySql('SELECT 1; DELETE FROM users;')).toBe(false) + expect(isReadOnlySql('DELETE FROM users; SELECT 1')).toBe(false) + }) + + test('trailing semicolons and whitespace are ignored', () => { + expect(isReadOnlySql(' SELECT 1 ; ')).toBe(true) + }) + + test('fails closed on unknown and empty input', () => { + expect(isReadOnlySql('')).toBe(false) + expect(isReadOnlySql(' ')).toBe(false) + expect(isReadOnlySql('-- nothing here')).toBe(false) + expect(isReadOnlySql('SELECT 1; SET search_path TO public')).toBe(false) + }) + + test('a write hidden behind a CTE is still a write', () => { + expect(isReadOnlySql('WITH t AS (SELECT 1 AS n) INSERT INTO users (id) SELECT n FROM t')).toBe(false) + }) + + test('a semicolon inside a literal does not split the statement', () => { + expect(isReadOnlySql("SELECT 'a;DELETE FROM users' AS s")).toBe(true) + }) +}) + +// Every case below classified as `read` before the review that added them. Where the +// classifier is the only gate (`ui.openConsole { run: true }`) that meant an auto-run write; +// on a read-only session it meant a statement that revokes the session's own enforcement. +describe('read-only escapes the classifier must not wave through', () => { + test('a data-modifying CTE is a write even when the tail only selects', () => { + // PostgreSQL executes the CTE body regardless of what the tail does with it + expect(classifyStatement("WITH x AS (INSERT INTO users(name) VALUES ('p') RETURNING id) SELECT * FROM x")).toBe('write') + expect(classifyStatement('WITH x AS (DELETE FROM orders RETURNING id) SELECT * FROM x')).toBe('write') + expect(classifyStatement('WITH x AS (UPDATE t SET a = 1 RETURNING a) SELECT * FROM x')).toBe('write') + expect(classifyStatement('WITH a AS (SELECT 1), b AS (DELETE FROM t RETURNING 1) SELECT * FROM a')).toBe('write') + }) + + test('a plain CTE still reads', () => { + expect(classifyStatement('WITH x AS (SELECT 1) SELECT * FROM x')).toBe('read') + expect(classifyStatement('WITH RECURSIVE x AS (SELECT 1) SELECT * FROM x')).toBe('read') + expect(classifyStatement('WITH x AS MATERIALIZED (SELECT 1) SELECT * FROM x')).toBe('read') + }) + + test('a nested CTE inside a body fails closed', () => { + expect(classifyStatement('WITH x AS (WITH y AS (INSERT INTO t VALUES (1) RETURNING 1) SELECT * FROM y) SELECT * FROM x')) + .toBe('unknown') + }) + + test('SELECT … INTO writes a table or a file', () => { + expect(classifyStatement('SELECT * INTO stolen FROM users')).toBe('unknown') + expect(classifyStatement("SELECT * FROM users INTO OUTFILE '/tmp/users'")).toBe('unknown') + expect(classifyStatement("SELECT * FROM users INTO DUMPFILE '/tmp/users'")).toBe('unknown') + }) + + test('a column literal containing the word INTO is still a read', () => { + expect(classifyStatement("SELECT 'into' AS x")).toBe('read') + }) + + test('set_config can clear the GUC that makes a PostgreSQL session read-only', () => { + expect(classifyStatement("SELECT set_config('default_transaction_read_only','off',false)")).toBe('unknown') + expect(classifyStatement("SELECT pg_catalog.set_config('statement_timeout','0',false)")).toBe('unknown') + }) + + test('a PRAGMA with an argument changes state — the function form carries no `=`', () => { + expect(classifyStatement('PRAGMA query_only(0)')).toBe('unknown') + expect(classifyStatement('PRAGMA query_only (off)')).toBe('unknown') + expect(classifyStatement('PRAGMA main.query_only(0)')).toBe('unknown') + expect(classifyStatement('PRAGMA user_version(42)')).toBe('unknown') + expect(classifyStatement('PRAGMA query_only = ON')).toBe('unknown') + }) + + test('introspection pragmas still read', () => { + expect(classifyStatement('PRAGMA table_info(users)')).toBe('read') + expect(classifyStatement('PRAGMA foreign_key_list(orders)')).toBe('read') + expect(classifyStatement('PRAGMA index_list(t)')).toBe('read') + expect(classifyStatement('PRAGMA journal_mode')).toBe('read') + }) + + test('switching the session back to read-write is not a read', () => { + expect(classifyStatement('SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE')).toBe('unknown') + expect(classifyStatement('SET SESSION TRANSACTION READ WRITE')).toBe('unknown') + }) +}) diff --git a/tests/tabs-store.test.ts b/tests/tabs-store.test.ts index 937c7281..d284cee4 100644 --- a/tests/tabs-store.test.ts +++ b/tests/tabs-store.test.ts @@ -2,6 +2,11 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test' // Mock solid-js/store before importing the module // We replicate createStore behavior for testing +// +// FRAGILE: this only works while no earlier test file has already evaluated stores/tabs.ts — +// its module-level createStore() would then have run against the real implementation and every +// test here fails. If you add a test for something that imports a store, import the pure logic +// from a store-free module instead (see lib/ui-snapshot.ts, lib/proposal-state.ts). const stores: any[] = [] mock.module('solid-js/store', () => ({ diff --git a/tsconfig.json b/tsconfig.json index 3ce525ed..b572c860 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,5 @@ { "extends": "./tsconfig.base.json", "include": ["src", "tests", "scripts"], - "exclude": ["node_modules", "dist", "build", "dist-server"] + "exclude": ["node_modules", "dist", "build", "dist-agent-cli", "dist-server"] }