Skip to content

feat: per-connection session setup SQL (survives connection pooling)#15

Merged
matej21 merged 2 commits into
contember:mainfrom
soukicz:feat/session-setup-sql
Jun 8, 2026
Merged

feat: per-connection session setup SQL (survives connection pooling)#15
matej21 merged 2 commits into
contember:mainfrom
soukicz:feat/session-setup-sql

Conversation

@soukicz

@soukicz soukicz commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an opt-in session setup SQL (initSql) per PostgreSQL connection — one or more statements that dotaz runs on every backend session and re-applies after the internal DISCARD ALL reset. This makes dotaz usable against databases that gate data access on session state, the headline case being Row-Level Security keyed on a session GUC:

CREATE POLICY tenant_isolation ON sale_item
  USING (shop = current_setting('app.current_shop'));

Today such databases show every table as empty in dotaz with no workaround that survives table browsing. With initSql = "SET app.current_shop = 'acme'", clicking a table shows that tenant's rows, and it stays scoped across repeated browsing/queries.

The feature is opt-in and empty by default — a no-op for existing users.

Why a startup parameter isn't enough

The pool resets reused connections with DISCARD ALL (i.e. RESET ALL), which clears any session GUC — including startup-supplied ones. So forwarding a startup option only works for the first query; every subsequent pooled query/table-browse goes empty again. The correct primitive is re-runnable SQL re-applied after every reset, not a startup option.

What changed

  • ConnectionPool gains an optional initFn, applied at every point a physical connection is (re)established or reset: system-connection connect/reconnect, freshly created pinned/ephemeral connections, and after DISCARD ALL on release. createConnection/acquireConnection are now async (callers updated).
  • PostgresDriver derives initFn from config.initSql. A failing initSql rejects connect() with a surfaced error; init-failure paths clean up rather than leaking a socket or publishing an un-initialized system connection.
  • MySQL driver: await-only updates for the now-async pool methods (no feature — PostgreSQL-only scope).
  • Headless / Docker (env-connection.ts): reads DOTAZ_INIT_SQL (takes precedence) and translates a libpq-style ?options=-c key=value in DATABASE_URL into SET key = 'value'; so standard libpq URLs work and survive the pool reset.
  • UI: a "Session setup SQL" textarea (PostgreSQL only) in the connection editor.
  • Config type: initSql?: string on PostgresConnectionConfig, stored as plain config metadata (like host/user), not via the encrypted secrets path.

Empty/unset initSql ⇒ behavior is byte-for-byte unchanged (DISCARD ALL only, as today).

Coverage

All four session-start points are covered: the pooled table-browser/grid/loadSchema path (persistent system connection), the acquire/release + DISCARD ALL path (CSV/JSON export, iterate, default transaction), and pinned/ephemeral sessions.

Tests

  • tests/postgres-init-sql.test.ts (new, docker-gated): against a real RLS table and a non-superuser role — proves a pooled query is scoped, that scoping survives DISCARD ALL across repeated iterate/export passes (not just the first query), that a pinned session is scoped without a manual SET, per-tenant isolation, the empty-initSql control, and that a failing initSql surfaces a connection error.
  • tests/env-connection.test.ts (extended): DOTAZ_INIT_SQL, libpq ?options= translation, precedence, and quote-escaping.

bunx tsc --noEmit, bun run lint, and dprint check all clean; no regressions in the existing pg/driver/pool/export/explain suites.

Acceptance criteria

  1. ✅ With initSql (or DATABASE_URL=…?options=-c app.current_shop=acme, or DOTAZ_INIT_SQL), clicking a table shows the tenant's rows.
  2. ✅ Browsing many tables / running many queries stays scoped (survives the reset).
  3. ✅ A pinned/manual session is scoped without typing SET.
  4. ✅ EXPLAIN/ANALYZE and CSV/JSON export return scoped data.
  5. ✅ With initSql empty, behavior is identical to current main.
  6. ✅ A failing initSql produces a visible connection error.

🤖 Generated with Claude Code

soukicz and others added 2 commits June 8, 2026 11:25
Add an opt-in `initSql` to PostgreSQL connections — SQL run at the start of
every backend session and re-applied after the pool's internal DISCARD ALL
reset. This makes dotaz usable against databases that gate data access on
session state, the headline case being Row-Level Security keyed on a session
GUC (`SET app.current_shop = 'acme'`), where today every table browses empty.

The pool reset (DISCARD ALL → RESET ALL) wipes any session GUC, so a startup
parameter would only survive the first query. The fix applies re-runnable SQL
at every point a physical connection is (re)established or reset:

- ConnectionPool gains an optional `initFn`, applied on system-connection
  connect/reconnect, on freshly created (pinned/ephemeral) connections, and
  after DISCARD ALL on release. createConnection/acquireConnection are now async.
- PostgresDriver derives `initFn` from `config.initSql`; a failing initSql
  rejects connect() with a surfaced error. Cleanup on init failure avoids
  leaking sockets and never publishes an un-initialized system connection.
- MySQL driver: await-only updates for the now-async pool methods (no feature).
- Headless/Docker: env-connection reads `DOTAZ_INIT_SQL` (precedence) and
  translates a libpq `?options=-c key=value` into `SET key = 'value';`.
- UI: "Session setup SQL" textarea (PostgreSQL only) in the connection editor.

Empty/unset initSql is byte-for-byte unchanged behavior. Stored as plain config
metadata, not via the encrypted secrets path.

Tests: new postgres-init-sql.test.ts proves scoping survives DISCARD ALL across
pooled, iterate/export, and pinned-session paths against a non-superuser RLS
role; extended env-connection.test.ts covers DOTAZ_INIT_SQL and options parsing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the per-connection `initSql` introduced for PostgreSQL into the other two
drivers, so session/connection setup is available everywhere it makes sense.

- MySQL: derive the pool's `initFn` from `config.initSql` (re-applied after the
  pool's RESET CONNECTION reset, exactly like the PostgreSQL DISCARD ALL path),
  and mirror the connect-failure cleanup so a bad initSql can't leak a socket.
  Headless: env-connection reads DOTAZ_INIT_SQL for mysql:// URLs too (no libpq
  `?options=` translation — that syntax is PostgreSQL-only).
- SQLite: there is no pool/reset, so initSql is plain per-connection setup. Runs
  after the built-in journal_mode/foreign_keys PRAGMAs on the main connection,
  and on the separate read connection iterate() opens for file-based databases
  (getIterateDb is now async). Each statement is run separately via splitStatements:
  SQLite's driver stops a multi-statement unsafe() at the first result-returning
  statement (e.g. `PRAGMA busy_timeout = N` returns a row), which would otherwise
  silently skip the rest — the common multi-PRAGMA case. Connect cleans up the
  handle on failure. Intended for PRAGMAs, ATTACH DATABASE, or loading extensions.
- Config: `initSql?` added to MysqlConnectionConfig and SqliteConnectionConfig,
  stored as plain config metadata (not the encrypted secrets path), like the
  PostgreSQL one. Survives the app-db store/load round-trip (whole config is
  serialized; only password/SSH secrets are transformed).
- UI: the "Session setup SQL" textarea now shows for MySQL too (with MySQL-flavored
  placeholder/hint), and a "Setup SQL" textarea is added to the SQLite form.

Tests: new mysql-init-sql.test.ts proves setup survives RESET CONNECTION across
the pooled/iterate/pinned paths via an observable session time_zone (docker-gated);
new sqlite-init-sql.test.ts proves the PRAGMA reaches both the main and the iterate
connection and that a multi-statement initSql runs every statement; extended
env-connection.test.ts covers DOTAZ_INIT_SQL for mysql and that libpq `?options=`
is not applied there.

Empty/unset initSql is byte-for-byte unchanged behavior on all three drivers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@matej21 matej21 merged commit 26861e0 into contember:main Jun 8, 2026
2 checks passed
@matej21

matej21 commented Jun 8, 2026

Copy link
Copy Markdown
Member

Thanks a lot for this 🙏 Really nicely solved — especially the insight that a startup parameter isn't enough and the session SQL has to be re-applied after DISCARD ALL. That's exactly the right call.

While reviewing, we went ahead and extended it to MySQL (re-applied after RESET CONNECTION) and SQLite (per-connection setup, also applied to the separate iterate connection) — so initSql is now consistent across all three drivers, including DOTAZ_INIT_SQL for MySQL in headless mode and the connection-dialog UI field.

One thing we hit while testing: on SQLite, Bun's driver stops a multi-statement unsafe() at the first statement that returns rows (PRAGMA x = N returns its value), so a typical PRAGMA a; PRAGMA b silently dropped the second one — handled now by running statements one at a time. Added tests cover it (mysql-init-sql, sqlite-init-sql), CI green.

Squash-merged into main. Thanks again! 🚀

@matej21

matej21 commented Jun 8, 2026

Copy link
Copy Markdown
Member

Shipped in v0.0.23 🎉 — https://github.com/contember/dotaz/releases/tag/v0.0.23

Desktop builds (Linux x64/ARM64, macOS x64/ARM64, Windows x64), the ghcr.io/contember/dotaz Docker image and the @dotaz/server npm package are all out. Thanks again for the contribution! 🙏

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