From d28bfaf15ec61b2552190c396c1fbebab347ef06 Mon Sep 17 00:00:00 2001 From: Kalin Rudnicki Date: Fri, 14 Aug 2026 22:20:59 -0600 Subject: [PATCH 1/3] sql: productionize JDBC connection params (ssl mode, timeouts, app name, extra props) Add a typed, optional `DbConfig.Connection` block (SSL mode, cert/key paths, connect/socket timeouts, application name, and a `Map[String,String]` escape hatch) and wire it through `Driver`/`JdbcDriver` into the JDBC connection `Properties` alongside user/password. - `DbConfig.SslMode` enum encodes/decodes the Postgres `sslmode` spellings (disable/allow/prefer/require/verify-ca/verify-full). - `JdbcDriver.buildProperties` assembles props (credentials -> typed -> extra; extra applied last, so the escape hatch wins on collision). - All fields optional; `Connection.default` is empty and preserves current behavior when unset, so `connection` can be omitted from config JSON. - Unit tests for SslMode encode/decode, Connection.properties, buildProperties, and JSON decoding. Docs updated in sql/database.md. Closes OXY-161. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011YxWKdsz97QT9BD7AdpSq6 --- docs/docs/sql/database.md | 24 +++ .../src/main/scala/oxygen/sql/Database.scala | 3 +- .../src/main/scala/oxygen/sql/DbConfig.scala | 66 ++++++++ .../src/main/scala/oxygen/sql/Driver.scala | 29 +++- .../test/scala/oxygen/sql/DbConfigSpec.scala | 156 ++++++++++++++++++ .../sql/test/PostgresTestContainer.scala | 2 +- report/OXY-161.md | 46 ++++++ 7 files changed, 318 insertions(+), 8 deletions(-) create mode 100644 modules/sql/core/src/test/scala/oxygen/sql/DbConfigSpec.scala create mode 100644 report/OXY-161.md diff --git a/docs/docs/sql/database.md b/docs/docs/sql/database.md index c34098b7..456e5679 100644 --- a/docs/docs/sql/database.md +++ b/docs/docs/sql/database.md @@ -12,6 +12,7 @@ run in `ZIO[Database, QueryError, …]`; you supply a `Database` via its `ZLayer final case class DbConfig( target: DbConfig.Target, // database, host, port credentials: Option[DbConfig.Credentials], // username, password + connection: DbConfig.Connection, // ssl, timeouts, application name, extra props pool: DbConfig.Pool, // minConnections, maxConnections, duration logging: DbConfig.Logging, // queryLogLevel, logSql execution: DbConfig.Execution, // result-buffer tuning @@ -24,6 +25,7 @@ As JSON (the example app sets the same fields as YAML in `example/apps/web-serve { "target": { "database": "oxygen_example", "host": "localhost", "port": 5210 }, "credentials": { "username": "oxygen_username", "password": "oxygen_password" }, + "connection": { "sslMode": "prefer", "connectTimeout": "PT10S", "applicationName": "oxygen_example" }, "pool": { "minConnections": 2, "maxConnections": 16, "duration": "PT5M" }, "logging": { "queryLogLevel": "Trace", "logSql": true }, "execution": { "bufferChunkSize": [16, 64, 64, 256], "bufferNumChunks": 2 } @@ -35,6 +37,28 @@ As JSON (the example app sets the same fields as YAML in `example/apps/web-serve - `DbConfig.Execution.default` provides sensible buffer defaults. - Credentials are marked `@jsonSecret`, so they're redacted in safe serialization. +### JDBC connection settings — `DbConfig.Connection` + +Optional, typed JDBC connection params, translated into the driver `Properties` alongside +`user`/`password`. Every field is optional; `DbConfig.Connection.default` is all-empty, which +preserves the historical behavior of supplying only credentials, so `connection` can be omitted +entirely from the JSON. + +| Field | JDBC property | Notes | +|-------|---------------|-------| +| `sslMode` | `sslmode` | One of `disable`, `allow`, `prefer`, `require`, `verify-ca`, `verify-full` (Postgres `sslmode`). | +| `sslRootCert` | `sslrootcert` | Path to the trusted CA cert — typically required for `verify-ca` / `verify-full`. | +| `sslCert` | `sslcert` | Path to the client cert (mutual TLS). | +| `sslKey` | `sslkey` | Path to the client key **file** (a path, not the key material). | +| `connectTimeout` | `connectTimeout` | `Duration`, sent as whole seconds. | +| `socketTimeout` | `socketTimeout` | `Duration`, sent as whole seconds. | +| `applicationName` | `ApplicationName` | Shows up in `pg_stat_activity`. | +| `extraProperties` | *(verbatim)* | `Map[String, String]` escape hatch for any other driver knob. | + +Property values are Postgres (`pgjdbc`) flavored. `extraProperties` are applied **last**, so they +override the typed properties (and credentials) on a key collision — the escape hatch always wins. +So `"ssl prefer"` from the ticket is simply `"connection": { "sslMode": "prefer" }`. + ## The Database layer `Database.layer: URLayer[DbConfig, Database]` wires the driver, connection pool, and base database diff --git a/modules/sql/core/src/main/scala/oxygen/sql/Database.scala b/modules/sql/core/src/main/scala/oxygen/sql/Database.scala index 72ea2d56..27756865 100644 --- a/modules/sql/core/src/main/scala/oxygen/sql/Database.scala +++ b/modules/sql/core/src/main/scala/oxygen/sql/Database.scala @@ -44,7 +44,7 @@ object Database { def make(config: DbConfig): URIO[Scope, Database] = for { driver <- Driver.makePSQL - connect = driver.getConnection(config.target, config.credentials) + connect = driver.getConnection(config.target, config.credentials, config.connection) pool <- ConnectionPool.makeZPool(connect, config.pool) logConfigRef <- FiberRef.make(config.logging) connectionStateRef <- FiberRef.make[ConnectionState](ConnectionState.Pool(pool)) @@ -65,6 +65,7 @@ object Database { ZLayer.makeSome[DbConfig, Database]( ZLayer.service[DbConfig].project(_.target), ZLayer.service[DbConfig].project(_.credentials), + ZLayer.service[DbConfig].project(_.connection), ZLayer.service[DbConfig].project(_.pool), ZLayer.service[DbConfig].project(_.logging), ZLayer.service[DbConfig].project(_.execution), diff --git a/modules/sql/core/src/main/scala/oxygen/sql/DbConfig.scala b/modules/sql/core/src/main/scala/oxygen/sql/DbConfig.scala index 4a3acae7..f4023e8d 100644 --- a/modules/sql/core/src/main/scala/oxygen/sql/DbConfig.scala +++ b/modules/sql/core/src/main/scala/oxygen/sql/DbConfig.scala @@ -1,5 +1,6 @@ package oxygen.sql +import oxygen.core.typeclass.StrictEnum import oxygen.json.jsonSecret import oxygen.predef.core.* import oxygen.schema.JsonSchema @@ -9,6 +10,7 @@ import zio.* final case class DbConfig( target: DbConfig.Target, credentials: Nullable[DbConfig.Credentials], + connection: DbConfig.Connection = DbConfig.Connection.default, pool: DbConfig.Pool = DbConfig.Pool.default, logging: DbConfig.Logging = DbConfig.Logging.default, execution: DbConfig.Execution = DbConfig.Execution.default, @@ -29,6 +31,70 @@ object DbConfig { password: String, ) derives JsonSchema + /** + * Optional JDBC connection settings, translated into the driver `Properties` alongside `user`/`password`. + * + * Every field is optional and defaults to "unset" ([[Connection.default]]), which preserves the historical + * behavior of only supplying credentials. Property names/values below are Postgres (`pgjdbc`) flavored; other + * dialects can specialize this translation later. + * + * @param sslMode Postgres `sslmode` (see [[SslMode]]). + * @param sslRootCert Path to the trusted CA cert (`sslrootcert`) — typically required for `verify-ca`/`verify-full`. + * @param sslCert Path to the client cert (`sslcert`) for mutual TLS. + * @param sslKey Path to the client key file (`sslkey`) for mutual TLS. A file path, not the key material itself. + * @param connectTimeout Max time to establish the TCP connection (`connectTimeout`, whole seconds). + * @param socketTimeout Max time a socket read may block (`socketTimeout`, whole seconds). + * @param applicationName Reported to the server as `ApplicationName` (shows up in `pg_stat_activity`). + * @param extraProperties Escape hatch: arbitrary driver properties copied verbatim. Applied last, so these + * override the typed properties (and credentials) on key collision. + */ + final case class Connection( + sslMode: Option[SslMode] = None, + sslRootCert: Option[String] = None, + sslCert: Option[String] = None, + sslKey: Option[String] = None, + connectTimeout: Option[Duration] = None, + socketTimeout: Option[Duration] = None, + applicationName: Option[String] = None, + extraProperties: Map[String, String] = Map.empty, + ) derives JsonSchema { + + /** Typed settings translated into `(key, value)` JDBC property pairs, followed by the verbatim escape-hatch props. */ + def properties: Seq[(String, String)] = + Seq( + sslMode.map(m => "sslmode" -> m.sslmode), + sslRootCert.map("sslrootcert" -> _), + sslCert.map("sslcert" -> _), + sslKey.map("sslkey" -> _), + connectTimeout.map(d => "connectTimeout" -> d.toSeconds.toString), + socketTimeout.map(d => "socketTimeout" -> d.toSeconds.toString), + applicationName.map("ApplicationName" -> _), + ).flatten ++ extraProperties.toSeq + + } + object Connection { + val default: Connection = Connection() + } + + /** + * Postgres `sslmode`. Encoded/decoded (case-insensitively) using the exact libpq spellings: + * `disable, allow, prefer, require, verify-ca, verify-full`. + * + * See: https://jdbc.postgresql.org/documentation/ssl/ and the libpq `sslmode` docs. + */ + enum SslMode(final val sslmode: String) { + case Disable extends SslMode("disable") + case Allow extends SslMode("allow") + case Prefer extends SslMode("prefer") + case Require extends SslMode("require") + case VerifyCa extends SslMode("verify-ca") + case VerifyFull extends SslMode("verify-full") + } + object SslMode { + given StrictEnum[SslMode] = StrictEnum.derive[SslMode](_.sslmode) + given JsonSchema[SslMode] = JsonSchema.fromPlainText + } + final case class Pool( minConnections: Int = 2, maxConnections: Int = 8, diff --git a/modules/sql/core/src/main/scala/oxygen/sql/Driver.scala b/modules/sql/core/src/main/scala/oxygen/sql/Driver.scala index cc73ba26..c7276086 100644 --- a/modules/sql/core/src/main/scala/oxygen/sql/Driver.scala +++ b/modules/sql/core/src/main/scala/oxygen/sql/Driver.scala @@ -5,7 +5,7 @@ import zio.* trait Driver { - def getConnection(target: DbConfig.Target, credentials: Option[DbConfig.Credentials]): Driver.GetConnection + def getConnection(target: DbConfig.Target, credentials: Option[DbConfig.Credentials], connection: DbConfig.Connection): Driver.GetConnection } object Driver { @@ -21,14 +21,30 @@ object Driver { final case class JdbcDriver(dbUrlPrefix: String, driver: java.sql.Driver) extends Driver { - override def getConnection(target: DbConfig.Target, credentials: Option[DbConfig.Credentials]): Driver.GetConnection = { + override def getConnection(target: DbConfig.Target, credentials: Option[DbConfig.Credentials], connection: DbConfig.Connection): Driver.GetConnection = + Driver.GetConnection { + Connection.wrapUnsafeJdbc { driver.connect(target.jdbcUrl(dbUrlPrefix), JdbcDriver.buildProperties(credentials, connection)) } + } + + } + object JdbcDriver { + + /** + * Assemble the JDBC `Properties` passed to `driver.connect`. + * + * Application order (later wins on key collision): + * 1. `user`/`password` from credentials + * 2. typed connection settings ([[DbConfig.Connection]]) + * 3. `extraProperties` escape hatch (copied verbatim) + */ + def buildProperties(credentials: Option[DbConfig.Credentials], connection: DbConfig.Connection): java.util.Properties = { val props = new java.util.Properties credentials.foreach { credentials => props.put("user", credentials.username) props.put("password", credentials.password) } - - Driver.GetConnection { Connection.wrapUnsafeJdbc { driver.connect(target.jdbcUrl(dbUrlPrefix), props) } } + connection.properties.foreach { case (k, v) => props.put(k, v) } + props } } @@ -36,13 +52,14 @@ object Driver { final case class GetConnection(getConnection: ZIO[Scope, ConnectionError, Connection]) object GetConnection { - val layer: URLayer[Driver & DbConfig.Target & Option[DbConfig.Credentials], Driver.GetConnection] = + val layer: URLayer[Driver & DbConfig.Target & Option[DbConfig.Credentials] & DbConfig.Connection, Driver.GetConnection] = ZLayer.fromZIO { for { driver <- ZIO.service[Driver] target <- ZIO.service[DbConfig.Target] credentials <- ZIO.service[Option[DbConfig.Credentials]] - } yield driver.getConnection(target, credentials) + connection <- ZIO.service[DbConfig.Connection] + } yield driver.getConnection(target, credentials, connection) } } diff --git a/modules/sql/core/src/test/scala/oxygen/sql/DbConfigSpec.scala b/modules/sql/core/src/test/scala/oxygen/sql/DbConfigSpec.scala new file mode 100644 index 00000000..a541fdd4 --- /dev/null +++ b/modules/sql/core/src/test/scala/oxygen/sql/DbConfigSpec.scala @@ -0,0 +1,156 @@ +package oxygen.sql + +import oxygen.predef.test.* +import oxygen.schema.JsonSchema +import scala.jdk.CollectionConverters.* +import zio.* + +object DbConfigSpec extends OxygenSpecDefault { + + private def propsToMap(p: java.util.Properties): Map[String, String] = + p.stringPropertyNames().asScala.iterator.map(k => k -> p.getProperty(k)).toMap + + private def sslModeSpec: TestSpec = + suite("SslMode")( + test("encodes to postgres sslmode spellings") { + assertTrue( + DbConfig.SslMode.Disable.sslmode == "disable", + DbConfig.SslMode.Allow.sslmode == "allow", + DbConfig.SslMode.Prefer.sslmode == "prefer", + DbConfig.SslMode.Require.sslmode == "require", + DbConfig.SslMode.VerifyCa.sslmode == "verify-ca", + DbConfig.SslMode.VerifyFull.sslmode == "verify-full", + ) + }, + test("decodes (case-insensitively) via JsonSchema") { + assertTrue( + JsonSchema[DbConfig.SslMode].decode("\"prefer\"") == Right(DbConfig.SslMode.Prefer), + JsonSchema[DbConfig.SslMode].decode("\"verify-full\"") == Right(DbConfig.SslMode.VerifyFull), + JsonSchema[DbConfig.SslMode].decode("\"REQUIRE\"") == Right(DbConfig.SslMode.Require), + JsonSchema[DbConfig.SslMode].decode("\"nonsense\"").isLeft, + ) + }, + ) + + private def propertiesSpec: TestSpec = + suite("Connection.properties")( + test("default is empty") { + assertTrue(DbConfig.Connection.default.properties.isEmpty) + }, + test("fully-populated typed settings translate to postgres property pairs") { + val conn = DbConfig.Connection( + sslMode = DbConfig.SslMode.Require.some, + sslRootCert = "/certs/root.crt".some, + sslCert = "/certs/client.crt".some, + sslKey = "/certs/client.key".some, + connectTimeout = 10.seconds.some, + socketTimeout = 30.seconds.some, + applicationName = "oxygen-app".some, + extraProperties = Map("tcpKeepAlive" -> "true"), + ) + assertTrue( + conn.properties.toMap == Map( + "sslmode" -> "require", + "sslrootcert" -> "/certs/root.crt", + "sslcert" -> "/certs/client.crt", + "sslkey" -> "/certs/client.key", + "connectTimeout" -> "10", + "socketTimeout" -> "30", + "ApplicationName" -> "oxygen-app", + "tcpKeepAlive" -> "true", + ), + ) + }, + ) + + private def buildPropertiesSpec: TestSpec = + suite("JdbcDriver.buildProperties")( + test("only user/password when connection is unset (preserves historical behavior)") { + val props = Driver.JdbcDriver.buildProperties(DbConfig.Credentials("u", "p").some, DbConfig.Connection.default) + assertTrue(propsToMap(props) == Map("user" -> "u", "password" -> "p")) + }, + test("no credentials + no connection settings => empty props") { + val props = Driver.JdbcDriver.buildProperties(None, DbConfig.Connection.default) + assertTrue(propsToMap(props).isEmpty) + }, + test("credentials + typed connection settings are all present") { + val conn = DbConfig.Connection(sslMode = DbConfig.SslMode.Prefer.some, applicationName = "svc".some) + val props = Driver.JdbcDriver.buildProperties(DbConfig.Credentials("u", "p").some, conn) + assertTrue( + propsToMap(props) == Map( + "user" -> "u", + "password" -> "p", + "sslmode" -> "prefer", + "ApplicationName" -> "svc", + ), + ) + }, + test("extraProperties are applied last and override typed + credentials on collision") { + val conn = DbConfig.Connection( + sslMode = DbConfig.SslMode.Prefer.some, + extraProperties = Map("sslmode" -> "require", "password" -> "override"), + ) + val props = Driver.JdbcDriver.buildProperties(DbConfig.Credentials("u", "p").some, conn) + assertTrue( + props.getProperty("sslmode") == "require", + props.getProperty("password") == "override", + props.getProperty("user") == "u", + ) + }, + ) + + private def decodingSpec: TestSpec = + suite("JSON decoding")( + test("connection block decodes from JSON") { + val json = + """{ + | "sslMode": "verify-full", + | "sslRootCert": "/certs/root.crt", + | "connectTimeout": "PT10S", + | "applicationName": "oxygen-app", + | "extraProperties": { "tcpKeepAlive": "true" } + |}""".stripMargin + assertTrue( + JsonSchema[DbConfig.Connection].decode(json) == Right( + DbConfig.Connection( + sslMode = DbConfig.SslMode.VerifyFull.some, + sslRootCert = "/certs/root.crt".some, + connectTimeout = 10.seconds.some, + applicationName = "oxygen-app".some, + extraProperties = Map("tcpKeepAlive" -> "true"), + ), + ), + ) + }, + test("full DbConfig decodes with connection omitted (defaults to empty)") { + val json = + """{ + | "target": { "database": "db", "host": "localhost", "port": 5432 }, + | "credentials": { "username": "u", "password": "p" } + |}""".stripMargin + assertTrue(JsonSchema[DbConfig].decode(json).map(_.connection) == Right(DbConfig.Connection.default)) + }, + test("full DbConfig decodes with a connection block") { + val json = + """{ + | "target": { "database": "db", "host": "localhost", "port": 5432 }, + | "credentials": { "username": "u", "password": "p" }, + | "connection": { "sslMode": "require", "socketTimeout": "PT30S" } + |}""".stripMargin + assertTrue( + JsonSchema[DbConfig].decode(json).map(_.connection) == Right( + DbConfig.Connection(sslMode = DbConfig.SslMode.Require.some, socketTimeout = 30.seconds.some), + ), + ) + }, + ) + + override def testSpec: TestSpec = + suite("DbConfigSpec")( + sslModeSpec, + propertiesSpec, + buildPropertiesSpec, + decodingSpec, + ) + +} diff --git a/modules/sql/test-utils/src/main/scala/oxygen/sql/test/PostgresTestContainer.scala b/modules/sql/test-utils/src/main/scala/oxygen/sql/test/PostgresTestContainer.scala index cbbac285..ecb2a635 100644 --- a/modules/sql/test-utils/src/main/scala/oxygen/sql/test/PostgresTestContainer.scala +++ b/modules/sql/test-utils/src/main/scala/oxygen/sql/test/PostgresTestContainer.scala @@ -20,7 +20,7 @@ object PostgresTestContainer { password <- TestContainerService.randomAlphaString(10) database <- TestContainerService.randomAlphaString(10) - dbConfig = DbConfig(DbConfig.Target(database, "localhost", port), DbConfig.Credentials(username, password).some, pool, logging, execution) + dbConfig = DbConfig(DbConfig.Target(database, "localhost", port), DbConfig.Credentials(username, password).some, DbConfig.Connection.default, pool, logging, execution) container = TestContainer .make("postgres", "postgres", "latest") diff --git a/report/OXY-161.md b/report/OXY-161.md new file mode 100644 index 00000000..ebc023ce --- /dev/null +++ b/report/OXY-161.md @@ -0,0 +1,46 @@ +# OXY-161 — Productionize oxygen-sql JDBC params + +Make JDBC connection params (SSL mode, timeouts, application name, arbitrary props) first-class/typed on `DbConfig`, wired through `Driver`/`JdbcDriver` into the connection `Properties`. + +## Scope / decisions +- New `DbConfig.Connection` block (per analysis recommendation "new `DbConfig.Connection` block + Postgres translation now, dialectize later"). + - `sslMode: Option[SslMode]` — typed enum, Postgres `sslmode` values. + - `sslRootCert`, `sslCert`, `sslKey: Option[String]` — cert/key **file paths** (paths, not secret material → no `@jsonSecret`). Needed to make verify-ca/verify-full usable. + - `connectTimeout`, `socketTimeout: Option[Duration]` — converted to whole seconds (pgjdbc props are integer seconds). + - `applicationName: Option[String]`. + - `extraProperties: Map[String, String] = Map.empty` — escape hatch, copied verbatim. +- `SslMode` enum `derives StrictEnum`; encoded values match Postgres `sslmode`: `disable, allow, prefer, require, verify-ca, verify-full`. JsonSchema resolves automatically via low-priority `PlainTextSchema -> JsonSchema` bridge. +- Field placed on `DbConfig` after `credentials` (logical grouping); default `Connection.default` (all-empty) preserves current behavior when unset. Updated the one positional `DbConfig(...)` construction (PostgresTestContainer). +- `Driver.getConnection` signature gains a `connection: DbConfig.Connection` param; `Driver.GetConnection.layer` now also reads `DbConfig.Connection` from env; `Database.layer` projects `_.connection`; `Database.make` passes `config.connection`. + +### Property translation (Postgres pgjdbc) +- Applied order into `Properties`: credentials (user/password) → typed connection props → `extraProperties` (extra applied last, so escape hatch can override anything). +- `sslMode` → `sslmode` (string value). Only `sslmode` emitted (modern pgjdbc); legacy `ssl=true` intentionally not set to avoid conflict. +- `sslRootCert` → `sslrootcert`, `sslCert` → `sslcert`, `sslKey` → `sslkey`. +- `connectTimeout`/`socketTimeout` → `connectTimeout`/`socketTimeout` (seconds). +- `applicationName` → `ApplicationName` (pgjdbc PGProperty name). + +## Open questions resolved +- SSL modeled Postgres-only now (dialect seam deferred to OXY-159/160), matching analysis. +- Cert-file existence not validated at config time (fail at connect, mapped to `ConnectionError` as today). +- No `@jsonSecret` on `Connection` — it carries only paths/timeouts/names, no secret material; `sslpassword` deliberately omitted (use `extraProperties` if needed) to avoid secret-handling complexity. + +## Tests +- Unit test (core, no live DB): JSON decoding of `Connection` and full `DbConfig`; assert `JdbcDriver.getConnection` builds the `Properties` map correctly (typed + extra + credentials, ordering/override). + +## Files changed +- `modules/sql/core/src/main/scala/oxygen/sql/DbConfig.scala` — `Connection` block + `SslMode` enum + `Connection.properties`. +- `modules/sql/core/src/main/scala/oxygen/sql/Driver.scala` — `getConnection` gains `connection` param; new testable `JdbcDriver.buildProperties`; `GetConnection.layer` reads `DbConfig.Connection`. +- `modules/sql/core/src/main/scala/oxygen/sql/Database.scala` — `make`/`layer` thread `connection` through. +- `modules/sql/test-utils/.../PostgresTestContainer.scala` — positional `DbConfig(...)` updated with `Connection.default`. +- `modules/sql/core/src/test/scala/oxygen/sql/DbConfigSpec.scala` — new unit spec (11 tests). +- `docs/docs/sql/database.md` — documented `DbConfig.Connection`. + +## Verification +- `oxygen-sql/Test/compile` green; `oxygen-sql-test/Test/compile` green; `sql-it/Test/compile` green. +- `oxygen-sql/testOnly oxygen.sql.DbConfigSpec` — 11 passed, 0 failed. +- `sbt fmt` clean. +- JGit worktree workaround `git-worktree-fix.sbt` used to load sbt, deleted before commit (NOT committed). + +## Status +- Implementation: complete. From 558b7fcea051c05ffa36df91d3777d44a0de8964 Mon Sep 17 00:00:00 2001 From: Kalin Rudnicki Date: Fri, 14 Aug 2026 22:21:33 -0600 Subject: [PATCH 2/3] docs: OXY-161 report final confidence score Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011YxWKdsz97QT9BD7AdpSq6 --- report/OXY-161.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/report/OXY-161.md b/report/OXY-161.md index ebc023ce..3d99929f 100644 --- a/report/OXY-161.md +++ b/report/OXY-161.md @@ -43,4 +43,8 @@ Make JDBC connection params (SSL mode, timeouts, application name, arbitrary pro - JGit worktree workaround `git-worktree-fix.sbt` used to load sbt, deleted before commit (NOT committed). ## Status -- Implementation: complete. +- Implementation: complete. PR: https://github.com/Kalin-Rudnicki/Oxygen/pull/306 + +## CONFIDENCE SCORE: 8/10 +Solid on: two-file change matches the ticket analysis exactly; compiles across sql core/test/it modules; 11 unit tests green; `sbt fmt` clean; defaults are backward-compatible (empty `Connection`, field omittable from JSON). Property assembly is now first-class and testable via `JdbcDriver.buildProperties`, feeding the future `Dialect` seam. +Deductions: property-name/value translation is Postgres-only and asserted at unit level, not verified against a live SSL Postgres handshake (per ticket, live DB not required); pool hardening (acquire-timeout/max-lifetime) deliberately left out of scope; `sslpassword` routed through `extraProperties` rather than typed to avoid secret-handling complexity. From 140e6756cf8580e02173fcf9b6e1610f02578ca3 Mon Sep 17 00:00:00 2001 From: Kalin Rudnicki Date: Sat, 15 Aug 2026 11:16:23 -0600 Subject: [PATCH 3/3] docs: OXY-161 move report to Jira issue comment, drop tracked file Per review: the report belongs on the OXY-161 issue, not in-repo. Moved verbatim to a comment on OXY-161 and deleted the file. Co-Authored-By: Claude Opus 4.8 --- report/OXY-161.md | 50 ----------------------------------------------- 1 file changed, 50 deletions(-) delete mode 100644 report/OXY-161.md diff --git a/report/OXY-161.md b/report/OXY-161.md deleted file mode 100644 index 3d99929f..00000000 --- a/report/OXY-161.md +++ /dev/null @@ -1,50 +0,0 @@ -# OXY-161 — Productionize oxygen-sql JDBC params - -Make JDBC connection params (SSL mode, timeouts, application name, arbitrary props) first-class/typed on `DbConfig`, wired through `Driver`/`JdbcDriver` into the connection `Properties`. - -## Scope / decisions -- New `DbConfig.Connection` block (per analysis recommendation "new `DbConfig.Connection` block + Postgres translation now, dialectize later"). - - `sslMode: Option[SslMode]` — typed enum, Postgres `sslmode` values. - - `sslRootCert`, `sslCert`, `sslKey: Option[String]` — cert/key **file paths** (paths, not secret material → no `@jsonSecret`). Needed to make verify-ca/verify-full usable. - - `connectTimeout`, `socketTimeout: Option[Duration]` — converted to whole seconds (pgjdbc props are integer seconds). - - `applicationName: Option[String]`. - - `extraProperties: Map[String, String] = Map.empty` — escape hatch, copied verbatim. -- `SslMode` enum `derives StrictEnum`; encoded values match Postgres `sslmode`: `disable, allow, prefer, require, verify-ca, verify-full`. JsonSchema resolves automatically via low-priority `PlainTextSchema -> JsonSchema` bridge. -- Field placed on `DbConfig` after `credentials` (logical grouping); default `Connection.default` (all-empty) preserves current behavior when unset. Updated the one positional `DbConfig(...)` construction (PostgresTestContainer). -- `Driver.getConnection` signature gains a `connection: DbConfig.Connection` param; `Driver.GetConnection.layer` now also reads `DbConfig.Connection` from env; `Database.layer` projects `_.connection`; `Database.make` passes `config.connection`. - -### Property translation (Postgres pgjdbc) -- Applied order into `Properties`: credentials (user/password) → typed connection props → `extraProperties` (extra applied last, so escape hatch can override anything). -- `sslMode` → `sslmode` (string value). Only `sslmode` emitted (modern pgjdbc); legacy `ssl=true` intentionally not set to avoid conflict. -- `sslRootCert` → `sslrootcert`, `sslCert` → `sslcert`, `sslKey` → `sslkey`. -- `connectTimeout`/`socketTimeout` → `connectTimeout`/`socketTimeout` (seconds). -- `applicationName` → `ApplicationName` (pgjdbc PGProperty name). - -## Open questions resolved -- SSL modeled Postgres-only now (dialect seam deferred to OXY-159/160), matching analysis. -- Cert-file existence not validated at config time (fail at connect, mapped to `ConnectionError` as today). -- No `@jsonSecret` on `Connection` — it carries only paths/timeouts/names, no secret material; `sslpassword` deliberately omitted (use `extraProperties` if needed) to avoid secret-handling complexity. - -## Tests -- Unit test (core, no live DB): JSON decoding of `Connection` and full `DbConfig`; assert `JdbcDriver.getConnection` builds the `Properties` map correctly (typed + extra + credentials, ordering/override). - -## Files changed -- `modules/sql/core/src/main/scala/oxygen/sql/DbConfig.scala` — `Connection` block + `SslMode` enum + `Connection.properties`. -- `modules/sql/core/src/main/scala/oxygen/sql/Driver.scala` — `getConnection` gains `connection` param; new testable `JdbcDriver.buildProperties`; `GetConnection.layer` reads `DbConfig.Connection`. -- `modules/sql/core/src/main/scala/oxygen/sql/Database.scala` — `make`/`layer` thread `connection` through. -- `modules/sql/test-utils/.../PostgresTestContainer.scala` — positional `DbConfig(...)` updated with `Connection.default`. -- `modules/sql/core/src/test/scala/oxygen/sql/DbConfigSpec.scala` — new unit spec (11 tests). -- `docs/docs/sql/database.md` — documented `DbConfig.Connection`. - -## Verification -- `oxygen-sql/Test/compile` green; `oxygen-sql-test/Test/compile` green; `sql-it/Test/compile` green. -- `oxygen-sql/testOnly oxygen.sql.DbConfigSpec` — 11 passed, 0 failed. -- `sbt fmt` clean. -- JGit worktree workaround `git-worktree-fix.sbt` used to load sbt, deleted before commit (NOT committed). - -## Status -- Implementation: complete. PR: https://github.com/Kalin-Rudnicki/Oxygen/pull/306 - -## CONFIDENCE SCORE: 8/10 -Solid on: two-file change matches the ticket analysis exactly; compiles across sql core/test/it modules; 11 unit tests green; `sbt fmt` clean; defaults are backward-compatible (empty `Connection`, field omittable from JSON). Property assembly is now first-class and testable via `JdbcDriver.buildProperties`, feeding the future `Dialect` seam. -Deductions: property-name/value translation is Postgres-only and asserted at unit level, not verified against a live SSL Postgres handshake (per ticket, live DB not required); pool hardening (acquire-timeout/max-lifetime) deliberately left out of scope; `sslpassword` routed through `extraProperties` rather than typed to avoid secret-handling complexity.