sql

package
v0.18.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 4, 2026 License: MIT Imports: 15 Imported by: 0

Documentation

Overview

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 this domain that renders dialect-specific SQL. Every statement it sends is built here, spelled with the vocabulary core/data/sql's Dialect owns: the bind markers, the quoting and the row-lock clauses.

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 transactor's two ADR 0039 siblings: where a statement issued under a context runs, and what waits for a commit.

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 of an engine with no advisory lock: SQLite's own write lock on the database file, held for the whole run.

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/data/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/data/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/data/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/data/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 coresql.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.

Joining a transaction, and holding what waits for its commit

The manager is also a core/data/sql Joiner and a Deferrer (ADR 0139). Join answers the executor of the innermost scope of the transaction a context carries for THIS manager — a retired one, which refuses, when that scope has returned, and never the pool in its place — or the pool when the context carries none. Defer holds a function until that transaction commits; each held function carries the id of the scope that held it, a failed savepoint drops what was held at or above its own id, and the root runs what is left after its COMMIT.

SQLite's migrations

SQLite has no advisory lock, and its runner holds the database file's write lock instead (ADR 0140): the run is ONE transaction that takes the lock with a write that writes nothing, and each migration is a savepoint of it.

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/data/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.

Package sql — hosts Withheld, a driver's error carried beside a verdict and kept out of every rendering.

Index

Constants

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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

This section is empty.

Functions

func FileLockSQL

func FileLockSQL(table string) string

FileLockSQL renders the statement that makes a SQLite transaction take the database file's write lock at once, where BEGIN — which database/sql sends as SQLite's deferred form — takes none.

It is a DELETE that matches no row. SQLite starts the write transaction a write statement needs when the statement STARTS, before its WHERE clause is weighed, so the lock is taken and nothing is changed (ADR 0140). Two transactions take it: the migration runner's, naming its version table — the one table it knows exists at that point, since it creates it first in the same transaction — and the queue's lease, naming its queue's table before its first read, so the read cannot answer from a snapshot the update after it would be refused for.

table is interpolated exactly as given — bare, as the runner's validated version table is, or already quoted, as the queue's is — and must have been validated at construction, which is the whole defence of an interpolated identifier.

func NewChecker

func NewChecker(cfg Config) (probe coresql.Checker, err error)

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.

Every run is mutually exclusive across processes, and the lock is one its holder's death releases: the session's advisory lock on PostgreSQL and MySQL (ADR 0055 §D7), the database file's write lock on SQLite (ADR 0140). Running unlocked is never 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

func Statements(stmts ...string) coresql.Step

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/data/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/data/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).

type Withheld

type Withheld struct {
	// contains filtered or unexported fields
}

Withheld carries a driver's error for errors.Is and errors.As while keeping its text out of Error().

A driver describes the row a statement touched. PostgreSQL's unique violation says "Key (doc_key)=(…) already exists" and MySQL's says "Duplicate entry '…'": a document store's key is routinely an e-mail address, and a queue's row holds the caller's payload. So a package whose rows hold a caller's data joins the driver's error through Withheld, beside a verdict of its own — errors.Join, never errs.Wrap, so the verdict's code stays the origin (CLAUDE.md rule 6) — and no rendering of the joined error quotes a row (ADR 0139 §D7, ADR 0151 §D6). The cause stays one errors.As away for a caller who knows its driver, and what it may log.

Error() says only what names no row: a context's end, in the context's own words; otherwise the driver error's Go type, with its SQLSTATE or its result code when a method offers one — a class of failure, never its subject.

The verdict stays each package's own — docstore's STATEMENT_FAILED, queue's QUEUE_BACKEND_FAILED — since each package's errors are its own; what they share is only this rendering of somebody else's.

func NewWithheld

func NewWithheld(cause error) Withheld

NewWithheld returns cause withheld: reachable by errors.Is and errors.As, absent from Error().

func (Withheld) Error

func (w Withheld) Error() string

Error renders what can be said without the driver's words: a context's end in its own words, since those hold no data; otherwise the driver error's Go type, with its SQLSTATE or its result code when a method offers one.

func (Withheld) Unwrap

func (w Withheld) Unwrap() error

Unwrap hands errors.Is and errors.As the driver's own error.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL