Skip to content

feat(cli): db --remote for execute and studio - #213

Merged
Brooooooklyn merged 14 commits into
mainfrom
feat/db-remote
May 29, 2026
Merged

Brooooooklyn merged 14 commits into
mainfrom
feat/db-remote

Conversation

@Brooooooklyn

@Brooooooklyn Brooooooklyn commented May 27, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Adds void db execute --remote <sql> for both dialects: D1 routes through the platform proxy (POST /d1/query, JWT-authed); PG fetches the stored connection string via the new GET /:id/database-url endpoint and connects directly with pg.Client.
  • Adds void db studio --remote for PG only (opens Drizzle Studio against the deployed Postgres). D1 errors out with a pointer to db execute --remote.
  • Adds PlatformClient.getDatabaseUrl(projectId).
  • Destructive-statement TTY confirmation prompt; --remote is never the default.
  • Help text, HELP_TREE entries, and docs/reference/cli.md updated for both subcommands.

Safety hardening

  • isSafeStatement fails closed: WITH removed from the safe-list (writable CTEs are indistinguishable from read-only without a parser); multi-statement SQL (SELECT 1; DELETE ...) is treated as unsafe.
  • void db export --remote now errors immediately instead of silently dumping local data.
  • D1 --file rejection fires BEFORE the destructive-statement prompt.

Test plan

  • pnpm test:unit packages/void/test/unit/db-execute-remote.test.ts packages/void/test/unit/db-studio-remote.test.ts packages/void/test/unit/db-args.test.ts packages/void/test/unit/get-database-url.test.ts — 69/69 pass
  • pnpm -C packages/void exec tsgo --noEmit — clean
  • Manual end-to-end against staging (void db execute --remote 'SELECT 1', void db studio --remote, void db execute --remote --file dump.sql → rejection, void db export --remote → rejection)

Companion PR

Platform: voidzero-dev/void-platform#170 — adds the GET /:id/database-url endpoint and persists the connection string at provision time. This framework PR depends on those API changes.


Note

High Risk
Runs arbitrary SQL against production (D1 via proxy, PG via fetched connection strings) with only a heuristic safety prompt in TTY; non-TTY/CI can execute destructive statements without confirmation.

Overview
Adds --remote to void db execute and void db studio so you can target the deployed database instead of only local dev.

For void db execute --remote, D1 projects send SQL through the Void proxy (POST …/d1/query) with your auth token and an auto-resolved D1 binding (platform resources, then void.json inference, then DB). PostgreSQL projects call the new PlatformClient.getDatabaseUrl (GET …/database-url) and run the query with a direct pg connection; a missing stored URL surfaces a void db set-url hint on 404.

void db studio --remote is PostgreSQL-only: it loads the remote URL from the platform and launches Drizzle Studio. Remote D1 Studio is explicitly unsupported (error points users at db execute --remote).

Safety and UX: a fail-closed isSafeStatement heuristic skips the TTY confirmation for plain SELECT / non-ANALYZE EXPLAIN, but still prompts (or blocks cancel) for destructive SQL in interactive terminals; CI/non-TTY runs proceed without a prompt. void db export --remote now errors instead of silently exporting local data; D1 --file + --remote is rejected before file read or confirmation.

CLI parsing, help tree, cheat sheet in docs/reference/cli.md, and unit/integration tests are updated accordingly (depends on platform GET /:id/database-url from the companion PR).

Reviewed by Cursor Bugbot for commit ad6ffbf. Bugbot is set up for automated code reviews on this repo. Configure here.

Brooooooklyn and others added 8 commits May 27, 2026 18:09
…tudio

- Add PlatformClient.getDatabaseUrl(projectId) that GETs
  /projects/:id/database-url and delegates errors to throwPlatformApiError.
- Add --remote boolean flag to db execute and db studio subcommand arg
  shapes (DbArgs union + parseDbArgs + help text).
- Unit tests: get-database-url.test.ts covers success, 404, 403, and
  no-body invariants; db-args.test.ts extended for --remote on execute
  and studio.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- D1 path POSTs to ${proxyUrl}/d1/query with token, binding (auto-discovered
  via getResources, defaulting to DB), and cfAccessHeaders; errors on --file
- PG path calls getDatabaseUrl to fetch the connection string, then connects
  directly with pg.Client; surfaces a friendly message on 404
- Safety guard prompts on destructive statements (non-SELECT/EXPLAIN/WITH) when
  stdin is a TTY; skipped on non-TTY and when --yes is implied by non-interactive
- isSafeStatement / confirmDestructiveRemote helpers keep the logic clean
- Updated execute subcommand description in db-args.ts to reflect --remote
- 22 unit tests in db-execute-remote.test.ts covering D1 and PG paths,
  error cases (404, non-OK proxy, missing token, unlinked project), TTY prompt,
  table rendering, and regression for the non-remote local path
- Add a WITH (CTE) test case to confirm the safety prompt is skipped for
  statements starting with WITH, alongside the existing SELECT and EXPLAIN
  cases.
- Add a test that stubs CF_ACCESS_CLIENT_ID + CF_ACCESS_CLIENT_SECRET and
  asserts the CF-Access-Client-Id / CF-Access-Client-Secret headers are
  forwarded on the POST request to the D1 proxy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…e prompt

Move the D1 --file unsupported check to the very top of the remote
branch, before reading the file or calling confirmDestructiveRemote.
Add a regression test that asserts the confirmation prompt is never
reached when --file is passed to a D1 remote execute.

Also adds three deferral/limitation comments:
- isSafeStatement: note that leading SQL comments are not stripped
- confirmDestructiveRemote: note --yes flag is intentionally deferred
- D1 binding resolution: TODO for --binding multi-binding support

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- runStudio now accepts an optional `remote` flag
- PG remote: fetches stored connection URL via getDatabaseUrl, passes it
  to writeDrizzleConfig + drizzle-kit studio (same as local PG path)
- D1 remote: errors with a friendly "not yet supported" message pointing
  users to `void db execute --remote` for ad-hoc queries
- help.ts HELP_TREE: updated execute and studio entries to document
  --remote flag with options and examples
- db-args.ts: updated studio subcommand description to mention --remote
  and the D1 limitation
- docs/reference/cli.md: fully documents --remote for both execute and
  studio (dialects, confirmation prompt, 404 case, D1 limitation)
- Tests: db-studio-remote.test.ts covering PG remote happy path, 404,
  non-404 re-throw, auth/project errors, D1 remote rejection, and local
  regression guards (9 tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Type `mockConfirm` with its actual parameter shape so the clack mock
wrapper can forward the argument without a variadic spread, and so that
`mock.calls[0][0]` resolves to `{ message: string }` instead of the
out-of-bounds `undefined` that the empty-tuple signature produced.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix cheat sheet: update `void db studio` row description; add missing `void db execute` row
- Shorten studio one-liner: drop "D1 remote not yet supported" caveat (belongs in HELP_TREE and the error message)
- Use backticks for \`void db execute --remote\` command reference in D1 studio error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove WITH from isSafeStatement safe-list (writable CTEs are indistinguishable
  without a real parser; fail-closed by prompting for all WITH queries)
- Reject multi-statement SQL: strip trailing semicolon then treat any remaining
  internal `;` as unsafe
- Reject `void db export --remote` with an actionable error before it silently
  falls through to the local export path
- Update WITH CTE test to expect the prompt to fire (semantic change: now unsafe)
- Add db-args regression tests for export --remote rejection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@netlify

netlify Bot commented May 27, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for staging-void-cloud ready!

Name Link
🔨 Latest commit ad6ffbf
🔍 Latest deploy log https://app.netlify.com/projects/staging-void-cloud/deploys/6a17aea8216f130008ecaf4f
😎 Deploy Preview https://deploy-preview-213--staging-void-cloud.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d0bfa8368c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/void/src/cli/db.ts Outdated
Comment thread packages/void/src/cli/db.ts
…ANALYZE as safe

- `isSafeStatement`: treat `EXPLAIN ANALYZE` as destructive — in PostgreSQL
  it actually executes the underlying DML to collect runtime statistics,
  so the previous `EXPLAIN` prefix match was a safety hole. Only plain
  `EXPLAIN` (without `ANALYZE`) is now allowed through.

- D1 remote execute: when `getResources()` fails or returns no D1 resource,
  fall back to the binding name configured in `void.json`
  (`inference.bindings.db`) instead of always hardcoding `'DB'`. Projects
  with a custom D1 binding name no longer silently send the wrong binding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 276ed57. Configure here.

Comment thread packages/void/src/cli/db.ts Outdated
Brooooooklyn and others added 2 commits May 27, 2026 20:10
…iption

The `void db execute` description was updated in this PR from
'Run SQL against the local database' to
'Run SQL against the database (local by default; …)'; update the
integration test assertion to match the new wording.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Forward-port the `confirmExisting` UX (prompt before overwriting an
existing deployment) from main into this branch so the test file matches
the expected behavior.  Add `packages/void/test/unit/link.test.ts` with
a `picocolors` no-op mock so that assertions on the `outro` message work
on CI where `FORCE_COLOR` makes `pc.blue()` emit ANSI escape codes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

remote: { type: 'boolean' },

P2 Badge Reject --remote where it is unsupported

Because remote is registered as a global parse option, commands that do not handle it now accept it silently; for example void db seed --remote --file seed.sql parses as a normal local seed and resets/seeds the local database, while the caller likely intended the deployed database. This is especially confusing because export --remote is explicitly rejected, but other unsupported db subcommands (push, reset, seed, etc.) ignore the flag instead of failing fast.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# Conflicts:
#	packages/void/src/cli/prompt.ts
#	packages/void/test/unit/link.test.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4e9e698642

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/void/src/cli/db.ts Outdated
…l fetch

isSafeStatement now also scans the PostgreSQL parenthesized option list
(EXPLAIN (ANALYZE) / EXPLAIN (ANALYZE TRUE) / EXPLAIN (ANALYZE FALSE)) for
ANALYZE and treats those forms as destructive, alongside the existing bare
EXPLAIN ANALYZE check. Fail-closed: even ANALYZE FALSE is rejected since
we don't parse arbitrary option expressions.

Extract fetchRemoteDatabaseUrl helper so runExecute and runStudio share a
single getDatabaseUrl call site with the same 404-to-friendly-error path.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

remote: { type: 'boolean' },

P2 Badge Reject --remote where it is unsupported

Adding remote to the shared parseArgs option set makes every void db subcommand accept it unless that branch explicitly rejects it; for example, void db reset --remote now parses successfully and proceeds through the local reset path, silently resetting the local database instead of reporting that remote reset is unsupported. Gate parsed.values.remote in the subcommands that don't implement remote behavior, similar to the explicit export --remote rejection.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/void/src/cli/db.ts
PostgreSQL treats /* */ and -- ... comments as whitespace before parsing,
so EXPLAIN /*x*/ ANALYZE DELETE FROM users (or with a line comment, or a
leading block comment) is equivalent to EXPLAIN ANALYZE and executes the
DELETE. The previous regex required literal whitespace between EXPLAIN
and ANALYZE, so these commented variants bypassed the destructive prompt.

isSafeStatement now strips block and line comments before applying the
existing keyword checks. Side benefit: leading comments on safe queries
(e.g. -- header\nSELECT 1) are now correctly classified as safe,
resolving the previously-documented UX nuisance.

Naive comment stripping doesn't understand string literals; documented
in the JSDoc but harmless for fail-closed classification.
@Brooooooklyn
Brooooooklyn merged commit e416aa4 into main May 29, 2026
10 checks passed
@Brooooooklyn
Brooooooklyn deleted the feat/db-remote branch May 29, 2026 07:08
cpojer pushed a commit that referenced this pull request Sep 11, 2026
* feat(api): add project_database_credentials table and helpers

Adds the project_database_credentials table (one row per project, PK on
project_id, ON DELETE CASCADE) to store encrypted database connection
strings. Includes the drizzle migration (0019), an updated snapshot, and
three db.ts helpers: upsertProjectDatabaseUrl, getProjectDatabaseUrl, and
deleteProjectDatabaseUrl.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(api): align project_database_credentials helpers with peer pattern

- `upsertProjectDatabaseUrl`: add `.returning()` and return
  `Promise<ProjectDatabaseCredentials>` instead of `Promise<void>`,
  matching `upsertProjectSecret`.
- `getProjectDatabaseUrl`: narrow return type from an inline object
  literal to `Promise<ProjectDatabaseCredentials | null>`, matching
  `getProjectSecret`.
- Import `ProjectDatabaseCredentials` from `./types.ts`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(api): encrypt+persist database URL at set-url time, expose GET endpoint

- POST /:id/database and PUT /:id/database-url now encrypt the connection
  string with AES-GCM (using encryptProjectSecret) and upsert it into
  project_database_credentials before calling Cloudflare Hyperdrive, so
  the URL is never unrecoverable if Hyperdrive succeeds but DB write fails.
- New GET /:id/database-url decrypts and returns { url } to the project
  owner; returns 404 with database_url_not_stored error when no row exists.
- Integration tests cover: round-trip via POST and PUT, last-write-wins
  upsert, ownership 403, and structured 404.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(api): return 403 (not 404) on database-url ownership mismatch

Split the combined `!project || wrong-owner` guard in GET /:id/database-url
into two separate checks — 404 when the project does not exist, 403 when it
exists but belongs to a different user. Matches the pattern used by all other
ownership-gated endpoints in projects.ts (e.g. /resources, /logs).

Update the corresponding test assertion from 404 to 403.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: bump framework (getDatabaseUrl + db --remote args)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: bump framework (db execute --remote)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: bump framework (db execute --remote test coverage)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: bump framework (db execute --remote ordering fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: bump framework (db studio --remote + docs)

Points framework submodule at de247c3 which adds --remote support to
`void db studio` (PG only), updates help.ts and db-args.ts descriptions,
adds full docs in reference/cli.md, and includes 9 new unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: bump framework (test type fixes)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: bump framework (db --remote polish)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(api): persist database URL only after Cloudflare accepts it

CF is the source of truth for production routing; the stored credential
is the URL CF accepted and is now serving. Previously both POST
/:id/database and PUT /:id/database-url wrote to project_database_credentials
before calling Cloudflare, so a CF rejection would leave a stale row
that GET /:id/database-url (used by the CLI for remote access) would
serve — diverging the CLI from what production Hyperdrive actually routes.

The fix calls CF first and only persists on success. If the upsert fails
after CF succeeds (rare), a 500 is returned instructing the user to
re-run `void db set-url`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: bump framework (remote-db safety hardening)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(api): update orphaned Hyperdrive before persisting URL in isAlreadyExistsError paths

In both POST /:id/database and PUT /:id/database-url, the isAlreadyExistsError
recovery branch retrieved an existing Hyperdrive ID (via getHyperdriveByName) but
never pushed the submitted connectionString to CF before storing it locally. This
could cause GET /:id/database-url to return a URL that diverged from what CF was
actually routing.

Fix: call updateHyperdrive with the found ID and submitted connectionString before
falling through to the persist block, ensuring CF and the stored credential stay in
sync. Also add updateHyperdrive to the POST handler's dynamic import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* style(api): apply vp fmt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: bump framework to main (db --remote merged via #213)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
cpojer pushed a commit that referenced this pull request Sep 11, 2026
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants