feat: per-connection session setup SQL (survives connection pooling)#15
Conversation
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>
|
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 While reviewing, we went ahead and extended it to MySQL (re-applied after One thing we hit while testing: on SQLite, Bun's driver stops a multi-statement Squash-merged into main. Thanks again! 🚀 |
|
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 |
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 internalDISCARD ALLreset. 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: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
ConnectionPoolgains an optionalinitFn, applied at every point a physical connection is (re)established or reset: system-connection connect/reconnect, freshly created pinned/ephemeral connections, and afterDISCARD ALLon release.createConnection/acquireConnectionare now async (callers updated).PostgresDriverderivesinitFnfromconfig.initSql. A failinginitSqlrejectsconnect()with a surfaced error; init-failure paths clean up rather than leaking a socket or publishing an un-initialized system connection.env-connection.ts): readsDOTAZ_INIT_SQL(takes precedence) and translates a libpq-style?options=-c key=valueinDATABASE_URLintoSET key = 'value';so standard libpq URLs work and survive the pool reset.initSql?: stringonPostgresConnectionConfig, stored as plain config metadata (like host/user), not via the encrypted secrets path.Empty/unset
initSql⇒ behavior is byte-for-byte unchanged (DISCARD ALLonly, as today).Coverage
All four session-start points are covered: the pooled table-browser/grid/
loadSchemapath (persistent system connection), theacquire/release+DISCARD ALLpath (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 survivesDISCARD ALLacross repeated iterate/export passes (not just the first query), that a pinned session is scoped without a manualSET, per-tenant isolation, the empty-initSqlcontrol, and that a failinginitSqlsurfaces 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, anddprint checkall clean; no regressions in the existing pg/driver/pool/export/explain suites.Acceptance criteria
initSql(orDATABASE_URL=…?options=-c app.current_shop=acme, orDOTAZ_INIT_SQL), clicking a table shows the tenant's rows.SET.initSqlempty, behavior is identical to currentmain.initSqlproduces a visible connection error.🤖 Generated with Claude Code