Documentation
¶
Overview ¶
Package sql — range 0.3.54.* (ADR 0055 service/sql block).
Package sql — hosts Config, the parameters every port in this package is built from, and the pool policy it applies.
Package sql — hosts the only place in the SDK that renders dialect-specific SQL. Every statement this domain sends is built here.
Package sql — declares the sentinel *errs.Error run outcomes. Each var's name equals its errs.Define Reason in SCREAMING_SNAKE form.
No Public in this file describes infrastructure ¶
A driver error is the most dangerous cause an SDK can wrap: a failed dial names the host, the port and often the user; a failed statement names the statement. Every Public below is a fixed literal naming the CLASS of failure, and the driver's own error is attached with errors.Join — reachable from a log, never from errs.PublicOf. No call site in this package puts a DSN, a query, or a bound argument into a Public (ADR 0055 §D9).
Package sql — hosts the scope-bound Executor handed to a TxFunc, and the two guards that stand between a stale handle and a live transaction.
Package sql — hosts the liveness probe and the budget that bounds it.
Package sql — hosts the migration runner: the order, the version table, the dry run, and the two directions.
Package sql — hosts MigrateConfig, the migration runner's parameters, and the validation that turns a caller's slice into an ordered plan.
Package sql — hosts the migration lock: why it is the engine's own advisory lock, and why it lives on a connection of its own.
Package sql — hosts the validated, sorted, clamped form of a MigrateConfig.
Package sql — hosts the version table: its portable shape, the reads and the two writes that keep it in step with the schema.
Package sql — hosts the validated, clamped form of a Config.
Package sql — hosts the three rendered statements of one savepoint.
Package sql — hosts the one context key this package stores under.
Package sql implements the relational-database ports declared in internal/core/sql (ADR 0055): the transaction manager, the pool policy, the health check and the migration runner.
It ships NO DRIVER and imports none. A driver is a connector to a third-party system, so pgx / go-sql-driver / the sqlite bindings belong under third-party/ by the rule that put the AWS writers there (ADR 0012). The caller opens its own *sql.DB and hands it over.
It is also NOT an ORM, has no query builder, no entity mapping and no lazy loading, and never will (ADR 0055 §D1).
Transaction ownership is structural ¶
NewTransactor hands the unit of work a core/sql.Executor — three methods, none of which is Commit or Rollback. A function that receives a transaction therefore cannot end it: the mistake does not compile. The manager commits when the function returns nil and rolls back otherwise, and it is the only thing that ever does either.
Nesting is a savepoint, and the traps are handled rather than avoided ¶
database/sql has no nested transactions. A core/sql.Transactor.Transact whose context already carries a transaction from THIS manager opens a SAVEPOINT instead:
- The savepoint NAME is generated by the SDK from a counter that never resets inside one transaction. It is never caller-supplied, because an identifier cannot be a bound parameter; and it is never reused, because a repeated name shadows rather than replaces, so RELEASE would release the wrong one.
- The three statements are IDENTICAL on postgres, mysql and sqlite, which is why those three are the supported set. Engines that spell nesting differently are refused BY NAME at ParseDialect (ADR 0055 §D4).
- Non-zero core/sql.TxOptionsValue on a nested call is REFUSED: a savepoint cannot change isolation or read-only mode, and silently downgrading a caller's Serializable is worse than saying no.
What a failed sub-transaction does ¶
It rolls back to its savepoint and returns the error to the caller of the inner Transact — verbatim, so errors.Is still answers. The OUTER transaction survives, and that is not optimism: ROLLBACK TO SAVEPOINT is exactly the statement that clears PostgreSQL's aborted state (SQLSTATE 25P02), so a caller who CATCHES the inner error has a usable transaction to continue in. A caller who does not catch it propagates it, and the outer scope rolls back in turn — the common case, and the safe default.
If the ROLLBACK TO SAVEPOINT statement itself fails, the transaction is POISONED: every later operation is refused with TxPoisoned, the commit never happens, and the whole transaction is rolled back. The engine's state is unknown at that point, and committing work the SDK believes it undid is the one outcome worse than failing.
A partially applied migration, and what MySQL does to the guarantee ¶
Each migration runs in ITS OWN transaction, and the version-table row is written by that SAME transaction on that SAME executor. On PostgreSQL and SQLite — where DDL is transactional — that is a real guarantee: a migration that fails rolls back both halves, the database sits exactly at version n-1, and re-running is safe.
On MySQL and MariaDB it is NOT, and the difference is not one this package can close. DDL causes an IMPLICIT COMMIT: the server has already committed the schema change before the runner's transaction reaches COMMIT. A migration whose second statement fails therefore leaves the first statement's ALTER TABLE applied and committed, while the version row — in the same transaction — is rolled back. The database is then in a state no version number describes.
No client-side code can make MySQL's DDL transactional, so this package does three things instead of pretending: it says so (here, in the package CLAUDE.md, in pkg/v1/sql's doc and in MigrationFailed's Private), it stops at the first failure rather than continuing, and it reports the version and the direction so the migration to inspect by hand is named.
The operational rule that follows is the CONSUMER's: one DDL statement per migration on MySQL, which makes the failure atomic by construction. This package cannot enforce it — it does not parse SQL — and does not pretend to. See ADR 0055 D8.
Nothing here waits on the wall clock ¶
Every budget — the health-check probe, the migration-lock retry — is read from an injected kernel/clock.Timed, so the suite advances a ManualClock instead of sleeping.
Package sql — hosts Statements, the one helper that turns SQL text into a migration step.
Package sql — hosts the transaction manager: the root transaction, the nested savepoint, and the single rule about who is allowed to commit.
Package sql — hosts the per-context transaction chain: which transactor opened a transaction, and how a nested call finds its own.
Package sql — hosts the per-transaction state every scope of one transaction shares, and the context chain that finds it.
Index ¶
- Constants
- Variables
- func NewChecker(cfg Config) (probe coresql.Checker, err error)
- func NewMigrator(cfg Config, mig MigrateConfig) (runner coresql.Migrator, err error)
- func NewTransactor(cfg Config) (manager coresql.Transactor, err error)
- func Statements(stmts ...string) coresql.Step
- type Config
- type MigrateConfig
- type PoolConfig
Constants ¶
const CodeBeginFailed errs.Code = 0x00_03_36_03 // 0.3.54.3
CodeBeginFailed identifies a transaction the driver would not open.
const CodeCommitFailed errs.Code = 0x00_03_36_04 // 0.3.54.4
CodeCommitFailed identifies a transaction whose COMMIT was rejected. The unit of work did NOT take effect.
const CodeConfigInvalid errs.Code = 0x00_03_36_01 // 0.3.54.1
CodeConfigInvalid identifies a constructor refused because the Config could never produce a working port: a nil *sql.DB or an unusable Dialect.
const CodeDuplicateMigration errs.Code = 0x00_03_36_11 // 0.3.54.17
CodeDuplicateMigration identifies two migrations declaring the same version.
const CodeHealthCheckFailed errs.Code = 0x00_03_36_09 // 0.3.54.9
CodeHealthCheckFailed identifies a liveness probe the database answered with an error.
const CodeHealthCheckTimeout errs.Code = 0x00_03_36_0A // 0.3.54.10
CodeHealthCheckTimeout identifies a liveness probe that did not answer within its budget.
const CodeMigrationFailed errs.Code = 0x00_03_36_0B // 0.3.54.11
CodeMigrationFailed identifies a migration step, or its version-table bookkeeping, that failed. The transaction was rolled back.
const CodeMigrationLockTimeout errs.Code = 0x00_03_36_0E // 0.3.54.14
CodeMigrationLockTimeout identifies a migration run abandoned because another holder kept the lock for the whole budget.
const CodeMigrationLockUnsupported errs.Code = 0x00_03_36_0D // 0.3.54.13
CodeMigrationLockUnsupported identifies a Migrator refused at construction because the dialect has no session-scoped advisory lock.
const CodeMigrationOutOfOrder errs.Code = 0x00_03_36_0C // 0.3.54.12
CodeMigrationOutOfOrder identifies a pending migration whose version is lower than one already applied — the two-branches-merged hazard.
const CodeMigrationUnknownVersion errs.Code = 0x00_03_36_0F // 0.3.54.15
CodeMigrationUnknownVersion identifies a version recorded in the database that this binary carries no migration for — so it cannot be reversed.
const CodePoolMisconfigured errs.Code = 0x00_03_36_02 // 0.3.54.2
CodePoolMisconfigured identifies a pool policy the SDK refuses to apply because the value database/sql would read is not the one the caller meant.
const CodeRollbackFailed errs.Code = 0x00_03_36_05 // 0.3.54.5
CodeRollbackFailed identifies a ROLLBACK the driver refused. It travels alongside the error that triggered the rollback, never instead of it.
const CodeSavepointFailed errs.Code = 0x00_03_36_06 // 0.3.54.6
CodeSavepointFailed identifies a SAVEPOINT, RELEASE SAVEPOINT or ROLLBACK TO SAVEPOINT statement the engine rejected.
const CodeTxClosed errs.Code = 0x00_03_36_08 // 0.3.54.8
CodeTxClosed identifies an Executor used after the scope that lent it returned.
const CodeTxPoisoned errs.Code = 0x00_03_36_07 // 0.3.54.7
CodeTxPoisoned identifies an operation refused because the transaction can no longer be trusted: its savepoint rollback failed, so the engine's state is unknown and nothing more may be committed on it.
const CodeVersionTableInvalid errs.Code = 0x00_03_36_10 // 0.3.54.16
CodeVersionTableInvalid identifies a version-table name that cannot be interpolated into SQL safely. Identifiers cannot be bound as parameters.
const DefaultCheckTimeout time.Duration = 5 * time.Second
DefaultCheckTimeout is the liveness-probe budget a non-positive Config.CheckTimeout clamps to. A health check that can hang forever is not a health check.
const DefaultLockRetryInterval time.Duration = 250 * time.Millisecond
DefaultLockRetryInterval is how often a blocked runner retries the non-blocking lock acquisition. A non-positive MigrateConfig.LockRetryInterval clamps to it.
const DefaultLockTimeout time.Duration = 2 * time.Minute
DefaultLockTimeout is the migration-lock budget a non-positive MigrateConfig.LockTimeout clamps to.
A migration run that waits forever for another holder is a deployment that hangs with no output, which is strictly worse than one that fails and says why. Two minutes is longer than almost every migration and shorter than every deployment timeout that would otherwise kill the pod first.
const DefaultMaxIdle int = 2
DefaultMaxIdle is the idle-connection ceiling a non-positive PoolConfig.MaxIdle clamps to — the same value database/sql itself uses, so the clamp changes nothing a caller could have observed.
const DefaultMaxLifetime time.Duration = 30 * time.Minute
DefaultMaxLifetime is the connection lifetime a non-positive PoolConfig.MaxLifetime clamps to.
database/sql reads 0 as "reuse a connection forever", and a connection that lives forever survives a failover, a DNS change and a credential rotation — it keeps talking to a replica that has been demoted, and nothing in the application ever notices. The exact number matters far less than its being FINITE, so this is a clamp rather than a refusal (ADR 0031): thirty minutes is long enough to be invisible on the hot path and short enough that a topology change is picked up without a restart.
const DefaultVersionTable string = "schema_migrations"
DefaultVersionTable is the table an empty MigrateConfig.VersionTable clamps to. The name matches the de-facto convention of every migration tool in the ecosystem, so an operator inspecting a database recognises it without being told.
Variables ¶
var ( // ConfigInvalid is returned by every constructor in this package for a // Config that could never produce a working port. ConfigInvalid = errs.Define(CodeConfigInvalid, "CONFIG_INVALID", "The SQL configuration is incomplete and was refused", "service/sql: Config has a nil DB or an unusable Dialect; the missing field names which", errs.WithExitCode(exitConfig)) // PoolMisconfigured is returned when a pool field's zero value would be // read by database/sql as a policy nobody chose. MaxOpen is the case that // matters: database/sql reads 0 as UNLIMITED, and "as many connections as // the application happens to want" is a decision made by forgetting a // line, not by taking one (ADR 0031's refusal side). PoolMisconfigured = errs.Define(CodePoolMisconfigured, "POOL_MISCONFIGURED", "The connection-pool policy is not one this SDK will apply", "service/sql: a non-positive MaxOpen means unlimited in database/sql; state the ceiling explicitly", errs.WithExitCode(exitConfig)) // BeginFailed is joined with the driver's error when a transaction could // not be opened. Nothing ran. BeginFailed = errs.Define(CodeBeginFailed, "BEGIN_FAILED", "The database refused to open a transaction", "service/sql: BeginTx failed; the driver error travels beside this one under errors.Join", errs.WithExitCode(exitTempFail), errs.WithHTTPStatus(httpUnavailable)) // CommitFailed is joined with the driver's error when COMMIT was // rejected. It says the unit of work did NOT take effect, which is the // one fact a caller must not have to infer. CommitFailed = errs.Define(CodeCommitFailed, "COMMIT_FAILED", "The transaction could not be committed and had no effect", "service/sql: COMMIT was rejected; the driver error travels beside this one under errors.Join", errs.WithExitCode(exitTempFail), errs.WithHTTPStatus(httpUnavailable)) // RollbackFailed travels ALONGSIDE the error that triggered the rollback, // never instead of it. A teardown that also breaks is a second defect, // and reporting only it would hide the first — the rule ADR 0050 wrote // for UnwindFailed, applied to the same shape one layer down. RollbackFailed = errs.Define(CodeRollbackFailed, "ROLLBACK_FAILED", "Rolling the transaction back did not complete cleanly", "service/sql: ROLLBACK was rejected; the connection is discarded by database/sql", errs.WithExitCode(exitTempFail)) // SavepointFailed is returned when SAVEPOINT, RELEASE SAVEPOINT or // ROLLBACK TO SAVEPOINT is rejected. The "statement" field names which of // the three, because the consequences differ: a failed SAVEPOINT means // the nested scope never started, while a failed ROLLBACK TO means the // engine's state is unknown and the transaction is poisoned. SavepointFailed = errs.Define(CodeSavepointFailed, "SAVEPOINT_FAILED", "A savepoint statement was rejected by the database", "service/sql: the statement field names which of SAVEPOINT / RELEASE / ROLLBACK TO failed", errs.WithExitCode(exitTempFail)) // TxPoisoned refuses every further operation on a transaction whose // savepoint rollback failed. // // This is the answer to "does a failed sub-transaction condemn the outer // one?". Normally NO: ROLLBACK TO SAVEPOINT is precisely the statement // that clears PostgreSQL's aborted state, so a caught inner failure // leaves a usable transaction. But when that statement ITSELF fails, the // engine's state is no longer known, and continuing would commit a // transaction containing work the SDK believes it undid. TxPoisoned = errs.Define(CodeTxPoisoned, "TX_POISONED", "The transaction is no longer usable and will not be committed", "service/sql: a savepoint rollback failed, so the engine state is unknown; the whole transaction is abandoned", errs.WithExitCode(exitTempFail)) // TxClosed is returned when an Executor is used after the scope that lent // it returned. database/sql would answer ErrTxDone for a finished // transaction and would EXECUTE for a nested scope whose outer // transaction is still open — the second case is the one worth catching. TxClosed = errs.Define(CodeTxClosed, "TX_CLOSED", "That transaction handle is no longer valid", "service/sql: the Executor outlived its Transact scope; it is valid only for the duration of the call", errs.WithExitCode(exitConfig)) // HealthCheckFailed is joined with the driver's error when the probe was // answered with a failure. HealthCheckFailed = errs.Define(CodeHealthCheckFailed, "HEALTH_CHECK_FAILED", "The database is not reachable", "service/sql: PingContext returned an error; the driver error travels beside this one under errors.Join", errs.WithExitCode(exitTempFail), errs.WithHTTPStatus(httpUnavailable)) // HealthCheckTimeout is returned when the probe did not answer inside its // budget. It is a separate code from HealthCheckFailed because a database // that refuses is a different operational fact from one that hangs, and a // readiness endpoint routes on the difference. HealthCheckTimeout = errs.Define(CodeHealthCheckTimeout, "HEALTH_CHECK_TIMEOUT", "The database did not answer the health check in time", "service/sql: the probe budget expired; the ping goroutine is abandoned, never killed", errs.WithExitCode(exitTempFail), errs.WithHTTPStatus(httpUnavailable)) // MigrationFailed is joined with the step's own error when a migration or // its bookkeeping row fails. The transaction was rolled back, so neither // the schema change nor the version row took effect — except on engines // with non-transactional DDL, which is stated in the package doc rather // than papered over. MigrationFailed = errs.Define(CodeMigrationFailed, "MIGRATION_FAILED", "A migration did not apply and was rolled back", "service/sql: version+direction fields; step error joined; on MySQL DDL commits implicitly, "+ "so a failed step can leave the schema change applied WITHOUT its version row (ADR 0055 D8)", errs.WithExitCode(exitTempFail)) // MigrationOutOfOrder refuses a pending migration whose version is lower // than one already applied. It is the two-branches-merged hazard: both // branches numbered from the same base, one shipped first, and the other // would now apply UNDER it — producing a schema that matches neither // branch's expectation and a version table that cannot express what // happened. MigrationOutOfOrder = errs.Define(CodeMigrationOutOfOrder, "MIGRATION_OUT_OF_ORDER", "A pending migration is older than one already applied", "service/sql: renumber the migration above the highest applied version; the fields carry both", errs.WithExitCode(exitConfig)) // MigrationLockUnsupported refuses a Migrator at CONSTRUCTION when the // dialect has no session-scoped advisory lock. Refusing here rather than // running unlocked is the whole point: a runner that silently drops // mutual exclusion is at its most dangerous exactly when two instances // start together, which is the case it exists for. MigrationLockUnsupported = errs.Define(CodeMigrationLockUnsupported, "MIGRATION_LOCK_UNSUPPORTED", "That dialect has no advisory lock, so migrations cannot be serialised", "service/sql: only postgres and mysql expose a session-scoped advisory lock; wire your own exclusion", errs.WithExitCode(exitConfig)) // MigrationLockTimeout is returned when another holder kept the migration // lock for the whole budget. Nothing was applied. MigrationLockTimeout = errs.Define(CodeMigrationLockTimeout, "MIGRATION_LOCK_TIMEOUT", "Another process is running migrations and did not finish in time", "service/sql: the advisory lock was held throughout the budget; no migration was applied", errs.WithExitCode(exitTempFail)) // MigrationUnknownVersion refuses a Down over a version the running // binary has no migration for. The database is ahead of the code, and // reversing a change whose Down is not present would be guessing. MigrationUnknownVersion = errs.Define(CodeMigrationUnknownVersion, "MIGRATION_UNKNOWN_VERSION", "The database records a migration this build does not carry", "service/sql: Down cannot reverse a version with no Step; deploy the build that owns it", errs.WithExitCode(exitConfig)) // VersionTableInvalid refuses a version-table name that is not a bare SQL // identifier. A table name cannot be a bound parameter, so it is // interpolated — which makes validating it the only thing between a // configuration string and an injection. VersionTableInvalid = errs.Define(CodeVersionTableInvalid, "VERSION_TABLE_INVALID", "The version-table name is not a plain SQL identifier", "service/sql: an identifier cannot be bound as a parameter, so only [A-Za-z_][A-Za-z0-9_]* is accepted", errs.WithExitCode(exitConfig)) // DuplicateMigration refuses two migrations declaring the same version. // The version IS the identity, so a duplicate makes "has this been // applied?" unanswerable. DuplicateMigration = errs.Define(CodeDuplicateMigration, "DUPLICATE_MIGRATION", "Two migrations declare the same version", "service/sql: a version identifies a migration; the field carries the duplicated value", errs.WithExitCode(exitConfig)) )
Functions ¶
func NewChecker ¶
NewChecker returns a bounded liveness probe over cfg.DB.
Like NewTransactor it applies cfg.Pool, so a process whose only SQL port is a health check still gets its pool policy installed.
func NewMigrator ¶
func NewMigrator(cfg Config, mig MigrateConfig) (runner coresql.Migrator, err error)
NewMigrator returns the migration runner for cfg.DB.
It REFUSES a dialect with no session-scoped advisory lock, at construction and by name. Running unlocked is not offered as a fallback: a runner that silently drops mutual exclusion is at its most dangerous in exactly the situation it exists for — two instances of a deployment starting together.
func NewTransactor ¶
func NewTransactor(cfg Config) (manager coresql.Transactor, err error)
NewTransactor returns the transaction manager for cfg.DB.
It applies cfg.Pool to the caller's *sql.DB. That is a side effect on a value the caller owns, and it is deliberate: a pool policy that has to be installed by a second call is a pool policy someone will forget, and the consequence of forgetting it (unlimited connections, connections that live forever) lands on the database rather than on this process.
func Statements ¶
Statements returns a coresql.Step that runs stmts in order on the transaction's executor, stopping at the first failure.
This is the smallest helper that is genuinely useful and deliberately not a file loader. Reading a directory would mean inventing a filename grammar (`0001_name.up.sql`? `V1__name.sql`?), a statement splitter, and a rule for what a `;` inside a string literal means — three conventions the SDK would impose on every consumer and none of them asked for (ADR 0055 §D8).
Each statement is sent as its own Exec rather than as one semicolon-joined string, because multi-statement execution is a per-driver capability that several disable by default, and because a failure then names the statement that failed by its position in the slice.
The text is the caller's own and is never bound: a migration's DDL cannot be parameterised on any engine. That is exactly why migrations must not be built from user input, and why this helper takes a variadic of literals rather than a template.
Types ¶
type Config ¶
type Config struct {
// DB is the caller's pool. REQUIRED — this package never opens one,
// because opening one means importing a driver (ADR 0055 §D2).
DB *stdsql.DB
// Dialect selects the SQL grammar. REQUIRED and must be a dialect this
// SDK can spell; resolve a configuration string through
// core/sql.ParseDialect, which refuses the engines it cannot support BY
// NAME instead of guessing.
Dialect coresql.Dialect
// Pool is the connection-pool policy, applied to DB at construction.
Pool PoolConfig
// Clock is the time source every budget in this package is read from. A
// nil Clock falls back to clock.System.
//
// clock.Timed rather than clock.Clock because this package both stamps
// (the version table's applied_at) and WAITS (the probe budget, the lock
// retry). Reading package time instead would make every budget assertion
// a sleep, and a sleep a tolerance, and a tolerance a flake.
Clock clock.Timed
// CheckTimeout is the budget one [core/sql.Checker.Check] gets. A
// non-positive value clamps to [DefaultCheckTimeout].
CheckTimeout time.Duration
}
Config parameterises every constructor in this package: the caller's pool, the dialect whose grammar the SDK will speak to it, the pool policy applied at construction, the time source every budget is read from, and the health probe's budget.
It is passed BY VALUE on purpose. A constructor must not be able to observe a later mutation of the caller's struct, and the copy happens once per port rather than once per statement.
type MigrateConfig ¶
type MigrateConfig struct {
// Migrations is the complete set this binary carries. Order does not
// matter — the runner sorts by Version — but every entry must be valid
// and no two may share a version.
//
// It is COPIED at construction, so a caller appending to their slice
// afterwards cannot change what the runner will apply.
Migrations []coresql.MigrationValue
// VersionTable names the bookkeeping table. Empty clamps to
// [DefaultVersionTable]; anything that is not a bare SQL identifier is
// REFUSED, because it is interpolated rather than bound.
VersionTable string
// LockTimeout bounds the wait for the advisory lock. Non-positive clamps
// to [DefaultLockTimeout].
LockTimeout time.Duration
// LockRetryInterval is how often the non-blocking acquisition is retried
// while another holder has the lock. Non-positive clamps to
// [DefaultLockRetryInterval].
LockRetryInterval time.Duration
}
MigrateConfig parameterises NewMigrator.
It carries no directory, no file pattern and no naming convention, and the SDK ships no example migration tree. Migrations are values the consumer builds, because the moment the SDK reads a directory it has invented a convention every consumer must adopt (ADR 0055 §D8).
type PoolConfig ¶
type PoolConfig struct {
// MaxOpen is the hard ceiling on open connections. It is REQUIRED and
// must be positive.
//
// database/sql reads 0 as UNLIMITED. "As many connections as the
// application happens to want" is a decision made by forgetting a line,
// and the consequence lands on the database rather than on the process
// that forgot — every replica racing to exhaust one server's
// max_connections. No value the SDK could invent is defensible either,
// because the right one depends on the server's ceiling divided by the
// number of replicas, which the SDK cannot see. So this is the REFUSAL
// side of ADR 0031.
MaxOpen int
// MaxIdle is the number of connections kept warm. A non-positive value
// clamps to [DefaultMaxIdle]; a value above MaxOpen clamps DOWN to
// MaxOpen, because database/sql does that reduction silently and a caller
// reading their own configuration back would otherwise be misled.
MaxIdle int
// MaxLifetime is how long a connection may be reused. A non-positive
// value clamps to [DefaultMaxLifetime]; see that constant for why an
// unbounded lifetime is not an option.
MaxLifetime time.Duration
// MaxIdleTime is how long an unused connection is kept. A non-positive
// value is passed through as "no idle deadline", and that is deliberate
// rather than an oversight: MaxLifetime is now always finite, so an idle
// connection is already retired on a bounded schedule, and a second
// timer would only decide how aggressively to shrink a warm pool — a
// tuning choice with no dangerous zero.
MaxIdleTime time.Duration
}
PoolConfig is the connection-pool policy. It is applied to the caller's *sql.DB at construction, and it is a POLICY rather than a set of hints: a field whose zero value database/sql would read as a decision nobody made is either clamped or refused, never passed through (ADR 0031).