sql

package
v0.19.5 Latest Latest
Warning

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

Go to latest
Published: Oct 5, 2026 License: MIT Imports: 3 Imported by: 4

README

sql

import "github.com/kitsunium/sdk/pkg/v1/data/sql"

Package sql is the public facade for the SDK's relational-database domain: transaction ownership, a pool policy, a health check and a migration runner, over the standard library's database/sql.

It is NOT an ORM, and it never will be. There is no entity mapping, no query builder, no lazy loading and no repository generation — you write SQL, the SDK owns the transaction around it (ADR 0055).

It also ships no driver. Import the one you want, open the pool yourself, and hand it over:

import _ "github.com/jackc/pgx/v5/stdlib"

pool, err := stdsql.Open("pgx", dsn)
dialect, err := sql.ParseDialect("postgres")

tm, err := sql.NewTransactor(sql.Config{
	DB:      pool,
	Dialect: dialect,
	Pool:    sql.PoolConfig{MaxOpen: 20},
})
Who is allowed to commit

Nobody but the manager. Transact hands your unit of work an Executor — Exec, Query, QueryRow, and deliberately nothing else. A helper that receives one CANNOT commit or roll back the transaction it was lent, because those methods do not exist on the type it was given:

err := sql.Transact(ctx, tm, func(ctx context.Context, ex sql.Executor) error {
	if _, err := ex.ExecContext(ctx, "UPDATE accounts SET cents = cents - $1 WHERE id = $2", n, from); err != nil {
		return err
	}
	return credit(ctx, ex, to, n) // credit cannot commit; it has no way to
})

Returning nil commits. Returning an error rolls back and hands the error back to you unchanged, so errors.Is keeps working.

Nesting is a savepoint

database/sql has no nested transactions. Call Transact with a context that already carries one and you get a SAVEPOINT of that transaction rather than a second connection deadlocking against the first:

_ = sql.Transact(ctx, tm, func(ctx context.Context, ex sql.Executor) error {
	if err := mustHappen(ctx, ex); err != nil {
		return err
	}
	// best-effort: if this fails, only ITS work is undone
	if err := sql.Transact(ctx, tm, optional); err != nil {
		log.Warn("optional step skipped", "err", err)
	}
	return nil
})

Three consequences worth knowing before you rely on it:

  • Only postgres, mysql and sqlite are supported, because those three spell savepoints identically. SQL Server, Oracle and Db2 are refused BY NAME at ParseDialect — their nesting is a different algorithm, not a different string, and generating SQL that fails at the first nested transaction in production is not an option.
  • A failed nested scope does NOT condemn the outer transaction. ROLLBACK TO SAVEPOINT is precisely the statement that clears PostgreSQL's aborted state, so catching the inner error leaves you a usable transaction. If that statement itself fails, the transaction is poisoned: every later operation is refused and the commit never happens.
  • A nested call must pass the ZERO TxOptions. A savepoint cannot change isolation or read-only mode, and being handed a weaker transaction than you asked for under a nil error is worse than being told no.
Joining the caller's transaction, and waiting for its commit

The transactor NewTransactor returns has two more capabilities, each an interface of its own beside Transactor, found by type assertion (ADR 0139):

ex, inTx := tm.(sql.Joiner).Join(ctx)       // where a statement under ctx runs
held := tm.(sql.Deferrer).Defer(ctx, notify) // notify runs once ctx's transaction commits

Joiner answers the executor of the transaction ctx carries — so a repository called inside its caller's transaction reads what that transaction wrote — or the pool when it carries none. A context that outlived its transaction still names it, and its executor refuses rather than fall back to the pool. Deferrer holds a function until that transaction commits: a rollback drops it, and so does the rollback of the savepoint it was held in. It is how a message, a mail or a store's write hook waits for the work it announces to be committed. The SDK's document store over SQL is built on both.

Migrations

NewMigrator applies an ordered, versioned set under a lock that excludes every other runner and dies with its holder, so two instances starting together cannot apply the same migration twice and a runner killed mid-run leaves nothing held. On PostgreSQL and MySQL it is the session's advisory lock, which the server drops with the connection. On SQLite it is the database file's write lock (ADR 0140): the run is ONE transaction that takes it with its first statement, each migration a savepoint of it, and the operating system drops a dead process's file locks. A SQLite run needs a single connection, and a migration there cannot run what SQLite refuses inside a transaction — VACUUM, or PRAGMA journal_mode.

Migrations are VALUES you build. The SDK ships no directory, no file format and no naming convention, because the moment it reads a directory it has invented one you did not choose:

m := sql.Migration{
	Version: 20260910143000,
	Name:    "create accounts",
	Up:      sql.Statements("CREATE TABLE accounts (id BIGINT PRIMARY KEY, cents BIGINT NOT NULL)"),
	Down:    sql.Statements("DROP TABLE accounts"),
}

Each migration runs in its own transaction together with its version-table row, so on PostgreSQL and SQLite a failed migration rolls back BOTH halves and the database sits exactly at the previous version.

On MySQL and MariaDB that guarantee does not hold, and it is not something this SDK can fix: DDL causes an IMPLICIT COMMIT, so a migration whose second statement fails leaves the first one applied while its version row is rolled back. Write ONE DDL statement per migration on MySQL and the failure is atomic by construction. See ADR 0055 D8.

Migrator.Plan is the dry run: it reports what Migrator.Up would apply, applies nothing and takes no lock. Irreversible is how a migration says out loud that it cannot be rolled back — a nil Down is refused, because "I forgot the reversal" and "there is no reversal" are the same nil.

Errors

Every failure is a typed SDK error joined with the driver's own, side by side, so neither question loses its answer:

errors.Is(err, sql.CommitFailed)              // the SDK's verdict
errs.HasCode(err, sql.CommitFailed.Code())    // the same, by code
errors.Is(err, driver.ErrBadConn)             // the driver's own

The sentinels above are the matchable values; their Code() is the ADR 0005 dotted quad. No Public message in this domain ever carries a connection string, a query or a bound argument.

Index

Constants

const DialectMySQL Dialect = 2

DialectMySQL is MySQL 5.7+ / MariaDB 10.x with InnoDB.

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.DialectMySQL.

const DialectPostgres Dialect = 1

DialectPostgres is PostgreSQL 9.1+.

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.DialectPostgres.

const DialectSQLite Dialect = 3

DialectSQLite is SQLite 3.6.8+. It has no advisory lock, so NewMigrator serialises a run on the database file's write lock instead (ADR 0140).

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.DialectSQLite.

Variables

var BeginFailed *errs.Error

BeginFailed reports a transaction the driver would not open.

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.BeginFailed.

Code 0.3.54.3 · reason BEGIN_FAILED · public "The database refused to open a transaction"

var CommitFailed *errs.Error

CommitFailed reports a transaction that had NO effect.

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.CommitFailed.

Code 0.3.54.4 · reason COMMIT_FAILED · public "The transaction could not be committed and had no effect"

var ConfigInvalid *errs.Error

ConfigInvalid is returned by every constructor for a Config that could never produce a working port.

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.ConfigInvalid.

Code 0.3.54.1 · reason CONFIG_INVALID · public "The SQL configuration is incomplete and was refused"

var DialectRefused *errs.Error

DialectRefused is returned by ParseDialect for an engine this SDK recognises and declines. The reason travels in a field.

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.DialectRefused.

Code 0.2.24.2 · reason DIALECT_REFUSED · public "That SQL engine is recognised and deliberately not supported"

var DuplicateMigration *errs.Error

DuplicateMigration refuses two migrations declaring the same version.

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.DuplicateMigration.

Code 0.3.54.17 · reason DUPLICATE_MIGRATION · public "Two migrations declare the same version"

var HealthCheckFailed *errs.Error

HealthCheckFailed reports a database that answered the probe with an error.

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.HealthCheckFailed.

Code 0.3.54.9 · reason HEALTH_CHECK_FAILED · public "The database is not reachable"

var HealthCheckTimeout *errs.Error

HealthCheckTimeout reports a database that did not answer in time.

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.HealthCheckTimeout.

Code 0.3.54.10 · reason HEALTH_CHECK_TIMEOUT · public "The database did not answer the health check in time"

var InvalidMigration *errs.Error

InvalidMigration is returned for a migration with version 0, an empty name, a nil Up or a nil Down.

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.InvalidMigration.

Code 0.2.24.4 · reason INVALID_MIGRATION · public "The migration is not runnable and was refused"

var MigrationFailed *errs.Error

MigrationFailed reports a migration that did not apply; it was rolled back.

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.MigrationFailed.

Code 0.3.54.11 · reason MIGRATION_FAILED · public "A migration did not apply and was rolled back"

var MigrationIrreversible *errs.Error

MigrationIrreversible is what the Irreversible step returns.

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.MigrationIrreversible.

Code 0.2.24.5 · reason MIGRATION_IRREVERSIBLE · public "That migration was declared irreversible and cannot be rolled back"

var MigrationLockTimeout *errs.Error

MigrationLockTimeout reports another process holding the migration lock for the whole budget. Nothing was applied.

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.MigrationLockTimeout.

Code 0.3.54.14 · reason MIGRATION_LOCK_TIMEOUT · public "Another process is running migrations and did not finish in time"

var MigrationLockUnsupported *errs.Error

MigrationLockUnsupported refuses a Migrator on a dialect with no lock that dies with its holder. No dialect this SDK speaks answers it since SQLite's runner serialises on its file's write lock (ADR 0140).

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.MigrationLockUnsupported.

Code 0.3.54.13 · reason MIGRATION_LOCK_UNSUPPORTED · public "That dialect has no advisory lock, so migrations cannot be serialised"

var MigrationOutOfOrder *errs.Error

MigrationOutOfOrder refuses a pending migration older than one already applied.

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.MigrationOutOfOrder.

Code 0.3.54.12 · reason MIGRATION_OUT_OF_ORDER · public "A pending migration is older than one already applied"

var MigrationUnknownVersion *errs.Error

MigrationUnknownVersion refuses a Down over a version this build does not carry.

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.MigrationUnknownVersion.

Code 0.3.54.15 · reason MIGRATION_UNKNOWN_VERSION · public "The database records a migration this build does not carry"

var NestedIsolation *errs.Error

NestedIsolation is returned when a nested Transact asks for isolation or read-only mode a savepoint cannot provide.

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.NestedIsolation.

Code 0.2.24.3 · reason NESTED_ISOLATION · public "A nested transaction cannot change isolation or read-only mode"

var PoolMisconfigured *errs.Error

PoolMisconfigured refuses a pool policy whose zero value database/sql would read as a decision nobody made — a non-positive MaxOpen.

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.PoolMisconfigured.

Code 0.3.54.2 · reason POOL_MISCONFIGURED · public "The connection-pool policy is not one this SDK will apply"

var RollbackFailed *errs.Error

RollbackFailed travels alongside the error that triggered the rollback.

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.RollbackFailed.

Code 0.3.54.5 · reason ROLLBACK_FAILED · public "Rolling the transaction back did not complete cleanly"

var SavepointFailed *errs.Error

SavepointFailed reports a rejected savepoint statement; the "statement" field names which of the three.

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.SavepointFailed.

Code 0.3.54.6 · reason SAVEPOINT_FAILED · public "A savepoint statement was rejected by the database"

var TxClosed *errs.Error

TxClosed reports an Executor used after the scope that lent it returned.

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.TxClosed.

Code 0.3.54.8 · reason TX_CLOSED · public "That transaction handle is no longer valid"

var TxPoisoned *errs.Error

TxPoisoned refuses every operation on a transaction whose savepoint rollback failed. It is never committed.

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.TxPoisoned.

Code 0.3.54.7 · reason TX_POISONED · public "The transaction is no longer usable and will not be committed"

var UnknownDialect *errs.Error

UnknownDialect is returned by ParseDialect for a name this SDK has never heard of.

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.UnknownDialect.

Code 0.2.24.1 · reason UNKNOWN_DIALECT · public "The SQL dialect is not one this SDK supports"

var VersionTableInvalid *errs.Error

VersionTableInvalid refuses a version-table name that is not a plain SQL identifier.

Initialised with github.com/kitsunium/sdk/internal/core/data/sql.VersionTableInvalid.

Code 0.3.54.16 · reason VERSION_TABLE_INVALID · public "The version-table name is not a plain SQL identifier"

func Irreversible

func Irreversible(ctx context.Context, ex Executor) error

Irreversible is the Step a migration assigns to Down to declare, out loud, that it cannot be reversed.

func NewChecker

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

NewChecker returns a liveness probe bounded by cfg.CheckTimeout.

func NewMigrator

func NewMigrator(cfg Config, mig MigrateConfig) (runner Migrator, err error)

NewMigrator returns the migration runner. It refuses a dialect with no session-scoped advisory lock, by name and at construction.

func NewTransactor

func NewTransactor(cfg Config) (manager Transactor, err error)

NewTransactor returns the transaction manager for cfg.DB, applying cfg.Pool to it.

func ParseDialect

func ParseDialect(name string) (dialect Dialect, err error)

ParseDialect resolves a dialect name and never guesses. A recognised but unsupported engine returns DialectRefused carrying why; an unrecognised one returns UnknownDialect.

func Statements

func Statements(stmts ...string) Step

Statements returns a Step that runs the given statements in order on the transaction's executor.

It is the smallest useful helper and deliberately not a file loader: it invents no directory layout, no naming convention and no parser. Where the text comes from — a literal, an embed.FS, a generator — stays yours.

func Transact

func Transact(ctx context.Context, tm Transactor, fn TxFunc) error

Transact runs fn inside a transaction with the driver's default isolation.

It is the ergonomic form of Transactor.Transact for the common case. The port itself keeps the options parameter so it never needs a second method (ADR 0039); this helper keeps the call site short.

type Checker

Checker is the public alias for the bounded liveness probe.

type Checker = coresql.Checker

Checker is an alias of github.com/kitsunium/sdk/internal/core/data/sql.Checker, declared as:

type Checker interface {
	// Check probes the database under a bounded budget and returns nil when
	// it answered. A non-nil error is always typed and never carries the
	// connection string.
	Check(ctx context.Context) error
}

func (Checker) Check
func (Checker) Check(ctx context.Context) error

Check probes the database under a bounded budget and returns nil when it answered. A non-nil error is always typed and never carries the connection string.

type Config

Config is the public alias for the parameters every port is built from.

type Config = svcsql.Config

Config is an alias of github.com/kitsunium/sdk/internal/service/data/sql.Config, declared as:

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
}

type Deferrer

Deferrer is the public alias for the ADR 0039 sibling of Transactor that holds a function until the transaction a context carries has committed, and drops it with a rollback. Discover it by type assertion.

type Deferrer = coresql.Deferrer

Deferrer is an alias of github.com/kitsunium/sdk/internal/core/data/sql.Deferrer, declared as:

type Deferrer interface {
	// Defer holds fn until the transaction ctx carries for this transactor
	// commits, and reports true.
	//
	// The held functions run once, in the order they were held, on the
	// goroutine that commits, after the commit and before the outermost
	// Transact returns. A rollback drops them, and so does the rollback of
	// the savepoint they were held in; a savepoint that is released hands
	// them to the scope around it. A panic in one reaches the caller of the
	// outermost Transact after the commit stood, and the functions held after
	// it do not run.
	//
	// Defer reports false and holds nothing when ctx carries no open
	// transaction of this transactor. There is no commit to wait for, so fn
	// is the caller's again, usually to run at once. A nil fn holds nothing
	// and gets the same answer.
	Defer(ctx context.Context, fn func()) (held bool)
}

func (Deferrer) Defer
func (Deferrer) Defer(ctx context.Context, fn func()) (held bool)

Defer holds fn until the transaction ctx carries for this transactor commits, and reports true.

The held functions run once, in the order they were held, on the goroutine that commits, after the commit and before the outermost Transact returns. A rollback drops them, and so does the rollback of the savepoint they were held in; a savepoint that is released hands them to the scope around it. A panic in one reaches the caller of the outermost Transact after the commit stood, and the functions held after it do not run.

Defer reports false and holds nothing when ctx carries no open transaction of this transactor. There is no commit to wait for, so fn is the caller's again, usually to run at once. A nil fn holds nothing and gets the same answer.

type Dialect

Dialect is the public alias for the closed set of SQL engines this SDK can spell. For SQL you write yourself, it spells the engine's vocabulary — the tokens the SDK's own statements are spelled with, never a statement: Dialect.Placeholder renders a bind marker ($1 on PostgreSQL, ? on MySQL and SQLite), Dialect.QuoteIdent a delimited name, its delimiter doubled inside, and Dialect.ForUpdate and Dialect.ForUpdateSkipLocked a row-lock clause, which SQLite — whose exclusion is its one write lock — renders as nothing. QuoteIdent validates nothing: check a name before you interpolate it. An unset Dialect renders no marker and no name, so a statement built from it fails rather than passing for another engine's.

type Dialect = coresql.Dialect

Dialect is an alias of github.com/kitsunium/sdk/internal/core/data/sql.Dialect, declared as:

type Dialect uint8

func (Dialect) ForUpdate
func (Dialect) ForUpdate() string

ForUpdate renders the clause that ends a read by locking the rows it returns until the transaction ends, leading space included: " FOR UPDATE" on PostgreSQL and MySQL. On MySQL it is also a locking read, which reads the latest committed rows rather than a REPEATABLE READ transaction's snapshot.

SQLite has no row lock and no such clause, and renders the empty string: a SQLite transaction excludes every other writer with the database's one write lock, which it takes at its first WRITE, so a transaction that must read under exclusion writes first (ADR 0140). A dialect that is not Dialect.Valid renders the clause: an engine without one refuses the statement, which is louder than a lock dropped in silence.

func (Dialect) ForUpdateSkipLocked
func (Dialect) ForUpdateSkipLocked() string

ForUpdateSkipLocked renders the clause that ends a read by locking the rows it returns and SKIPPING those another transaction holds, leading space included: " FOR UPDATE SKIP LOCKED" on PostgreSQL and MySQL. Two readers then never wait on each other's rows and never take the same one.

It needs PostgreSQL 9.5, MySQL 8.0.1 or MariaDB 10.6 — later than the floors DialectPostgres and DialectMySQL name — and an older server refuses the statement. SQLite renders the empty string and a dialect that is not Dialect.Valid the clause, for the reasons Dialect.ForUpdate gives: a SQLite transaction that has written holds the database's only write lock, so no row it reads is held by anybody else.

func (Dialect) Placeholder
func (Dialect) Placeholder(n int) string

Placeholder renders the bind marker of a statement's n-th argument, counted from 1.

This is the divergence that makes a "portable" hand-written query a fiction: PostgreSQL numbers its markers — $1, $2 — while MySQL and SQLite share the positional ?, whose position is the argument's order. Every statement the SDK binds an argument to takes its markers from here, so a fourth engine is spelled in one place or not at all.

A dialect that is not Dialect.Valid — an unset field — has no marker and renders the empty string, so a statement built from it parses on no engine rather than on whichever one a guess favoured (ADR 0031).

func (Dialect) QuoteIdent
func (Dialect) QuoteIdent(name string) string

QuoteIdent renders name as an identifier of the dialect: delimited, so a table called "order" or "user" is a name rather than a keyword, and with the delimiter doubled wherever the name holds it, which is how every engine spells a delimiter inside a delimited identifier. MySQL delimits with backticks, whatever its ANSI_QUOTES mode says; PostgreSQL and SQLite with the standard's double quotes.

It validates nothing. An identifier cannot be a bound argument, so the SDK interpolates only names it validated at construction, and that check is the whole defence: what a name may hold, and how long it may be — PostgreSQL TRUNCATES a longer one, into what may be another table's name — is the caller's to refuse before quoting it. A dialect that is not Dialect.Valid renders the empty string, for the reason Dialect.Placeholder gives.

func (Dialect) String
func (Dialect) String() string

String returns the dialect's canonical lowercase name.

func (Dialect) SupportsAdvisoryLock
func (Dialect) SupportsAdvisoryLock() bool

SupportsAdvisoryLock reports whether the engine offers a SESSION-SCOPED advisory lock — one the server releases when the connection holding it drops.

SQLite does not: it has no advisory-lock function, only the file lock that serialises writers. That distinction decides HOW Migrator promises mutual exclusion across processes, so it is a property of the dialect and not a runtime discovery (ADR 0055 §D7). On SQLite the runner holds the database file's write lock for the whole run instead, which the operating system releases when its holder dies, as a server releases an advisory lock (ADR 0140).

func (Dialect) Valid
func (Dialect) Valid() bool

Valid reports whether d is a dialect this domain can actually spell.

type Executor

Executor is the public alias for the read/write surface a statement runs against. *sql.DB, *sql.Tx and *sql.Conn all satisfy it with no adapter, and it deliberately carries no Commit, Rollback or Begin.

type Executor = coresql.Executor

Executor is an alias of github.com/kitsunium/sdk/internal/core/data/sql.Executor, declared as:

type Executor interface {
	// ExecContext runs a statement that returns no rows.
	ExecContext(ctx context.Context, query string, args ...any) (stdsql.Result, error)
	// QueryContext runs a statement that returns rows. The caller closes them.
	QueryContext(ctx context.Context, query string, args ...any) (*stdsql.Rows, error)
	// QueryRowContext runs a statement expected to return at most one row.
	QueryRowContext(ctx context.Context, query string, args ...any) *stdsql.Row
}

func (Executor) ExecContext
func (Executor) ExecContext(ctx context.Context, query string, args ...any) (stdsql.Result, error)

ExecContext runs a statement that returns no rows.

func (Executor) QueryContext
func (Executor) QueryContext(ctx context.Context, query string, args ...any) (*stdsql.Rows, error)

QueryContext runs a statement that returns rows. The caller closes them.

func (Executor) QueryRowContext
func (Executor) QueryRowContext(ctx context.Context, query string, args ...any) *stdsql.Row

QueryRowContext runs a statement expected to return at most one row.

type Joiner

Joiner is the public alias for the ADR 0039 sibling of Transactor that says where a statement issued under a context runs: in the transaction the context carries, or on the pool. Discover it by type assertion.

type Joiner = coresql.Joiner

Joiner is an alias of github.com/kitsunium/sdk/internal/core/data/sql.Joiner, declared as:

type Joiner interface {
	// Join returns the executor a statement issued under ctx runs on.
	//
	// When ctx carries a transaction THIS transactor opened, that is the
	// executor of the innermost scope ctx names, and inTx is true. It is
	// valid only while that scope runs and refuses afterwards: a context
	// that outlived its transaction must not fall back to the pool, where
	// its statements would run outside the transaction they were written
	// for.
	//
	// When ctx carries none — no transaction, or only another transactor's —
	// it is the pool, outside any transaction, and inTx is false.
	Join(ctx context.Context) (ex Executor, inTx bool)
}

func (Joiner) Join
func (Joiner) Join(ctx context.Context) (ex Executor, inTx bool)

Join returns the executor a statement issued under ctx runs on.

When ctx carries a transaction THIS transactor opened, that is the executor of the innermost scope ctx names, and inTx is true. It is valid only while that scope runs and refuses afterwards: a context that outlived its transaction must not fall back to the pool, where its statements would run outside the transaction they were written for.

When ctx carries none — no transaction, or only another transactor's — it is the pool, outside any transaction, and inTx is false.

type MigrateConfig

MigrateConfig is the public alias for the migration runner's parameters.

type MigrateConfig = svcsql.MigrateConfig

MigrateConfig is an alias of github.com/kitsunium/sdk/internal/service/data/sql.MigrateConfig, declared as:

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
}

type Migration

Migration is the public alias for one versioned schema change and its reversal.

type Migration = coresql.MigrationValue

Migration is an alias of github.com/kitsunium/sdk/internal/core/data/sql.MigrationValue, declared as:

type Migration struct {
	// Version orders the migration. It must be non-zero and unique within one
	// set: zero is reserved as "before every migration", the value Down takes
	// to reverse everything.
	//
	// Any strictly-increasing scheme works; a UTC timestamp (20260910143000)
	// is the conventional one because two branches developed in parallel then
	// merge without colliding.
	Version uint64
	// Name is the human label recorded in the version table. It must be
	// non-empty: a schema history whose rows say only "42" is a schema
	// history nobody can read during an incident.
	Name    string
	// Up applies the change. Required.
	Up      Step
	// Down reverses it. Required — and [Irreversible] is how a caller says
	// "this one cannot be reversed" OUT LOUD.
	//
	// A nil Down is refused rather than accepted as "irreversible", because
	// "I forgot the reversal" and "there is no reversal" are the same nil and
	// only one of them is a defect. The same rule core/app/lifecycle applies to a
	// nil Stop, for the same reason.
	Down    Step
}

func (Migration) Validate
func (Migration) Validate() error

Validate reports why the migration could never run, or nil.

It is a method on the value rather than a check inside the runner so a caller can validate a set at start-up — before any connection exists — which is where a wiring fault should be found.

type Migrator

Migrator is the public alias for the schema-migration runner.

type Migrator = coresql.Migrator

Migrator is an alias of github.com/kitsunium/sdk/internal/core/data/sql.Migrator, declared as:

type Migrator interface {
	// Down reverses every applied migration whose version is STRICTLY GREATER
	// than target, in descending order, each in its own transaction, under the
	// migration lock. Down(ctx, 0) reverses everything.
	Down(ctx context.Context, target uint64) error
	// Plan reports what Up would apply, in the order it would apply it,
	// WITHOUT applying anything and without taking the migration lock. It is
	// the dry run: a read of the version table and a set difference.
	Plan(ctx context.Context) ([]MigrationValue, error)
	// Up applies every pending migration in ascending version order, each in
	// its own transaction, under the migration lock.
	Up(ctx context.Context) error
}

func (Migrator) Down
func (Migrator) Down(ctx context.Context, target uint64) error

Down reverses every applied migration whose version is STRICTLY GREATER than target, in descending order, each in its own transaction, under the migration lock. Down(ctx, 0) reverses everything.

func (Migrator) Plan
func (Migrator) Plan(ctx context.Context) ([]MigrationValue, error)

Plan reports what Up would apply, in the order it would apply it, WITHOUT applying anything and without taking the migration lock. It is the dry run: a read of the version table and a set difference.

func (Migrator) Up
func (Migrator) Up(ctx context.Context) error

Up applies every pending migration in ascending version order, each in its own transaction, under the migration lock.

type PoolConfig

PoolConfig is the public alias for the connection-pool policy.

type PoolConfig = svcsql.PoolConfig

PoolConfig is an alias of github.com/kitsunium/sdk/internal/service/data/sql.PoolConfig, declared as:

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
}

type Preparer

Preparer is the public alias for the ADR 0039 sibling of Executor: an executor that can also prepare a statement. Discover it by type assertion.

type Preparer = coresql.Preparer

Preparer is an alias of github.com/kitsunium/sdk/internal/core/data/sql.Preparer, declared as:

type Preparer interface {
	// PrepareContext compiles a statement. The caller closes it.
	PrepareContext(ctx context.Context, query string) (*stdsql.Stmt, error)
}

func (Preparer) PrepareContext
func (Preparer) PrepareContext(ctx context.Context, query string) (*stdsql.Stmt, error)

PrepareContext compiles a statement. The caller closes it.

type Step

Step is the public alias for one direction of a migration.

type Step = coresql.Step

Step is an alias of github.com/kitsunium/sdk/internal/core/data/sql.Step, declared as:

type Step func(ctx context.Context, ex Executor) error

type Transactor

Transactor is the public alias for the transaction manager.

type Transactor = coresql.Transactor

Transactor is an alias of github.com/kitsunium/sdk/internal/core/data/sql.Transactor, declared as:

type Transactor interface {
	// Transact runs fn inside a transaction and commits when fn returns nil.
	//
	// If ctx already carries a transaction opened by THIS Transactor, fn runs
	// inside a SAVEPOINT of that transaction instead of a new one, and opts
	// must be the zero value — a savepoint cannot change isolation or
	// read-only-ness, so asking for either is refused with [NestedIsolation]
	// rather than silently ignored (ADR 0055 §D5).
	//
	// A panic inside fn is NOT recovered: the deferred rollback still runs,
	// and the panic reaches the caller with its original stack.
	Transact(ctx context.Context, opts TxOptionsValue, fn TxFunc) error
}

func (Transactor) Transact
func (Transactor) Transact(ctx context.Context, opts TxOptionsValue, fn TxFunc) error

Transact runs fn inside a transaction and commits when fn returns nil.

If ctx already carries a transaction opened by THIS Transactor, fn runs inside a SAVEPOINT of that transaction instead of a new one, and opts must be the zero value — a savepoint cannot change isolation or read-only-ness, so asking for either is refused with NestedIsolation rather than silently ignored (ADR 0055 §D5).

A panic inside fn is NOT recovered: the deferred rollback still runs, and the panic reaches the caller with its original stack.

type TxFunc

TxFunc is the public alias for a unit of work run inside a transaction.

type TxFunc = coresql.TxFunc

TxFunc is an alias of github.com/kitsunium/sdk/internal/core/data/sql.TxFunc, declared as:

type TxFunc func(ctx context.Context, ex Executor) error

type TxOptions

TxOptions is the public alias for one transaction's isolation and read-only-ness. The zero value defers to the driver.

type TxOptions = coresql.TxOptionsValue

TxOptions is an alias of github.com/kitsunium/sdk/internal/core/data/sql.TxOptionsValue, declared as:

type TxOptions struct {
	// Isolation is the transaction isolation level. The zero value
	// (sql.LevelDefault) defers to the driver.
	Isolation stdsql.IsolationLevel
	// ReadOnly asks the engine to refuse writes. Not every driver enforces
	// it; those that do not report an error at Begin rather than accepting
	// writes silently, which is why this is passed through rather than
	// emulated.
	ReadOnly  bool
}

func (TxOptions) IsZero
func (TxOptions) IsZero() bool

IsZero reports whether the options ask for nothing beyond the driver's defaults.

It exists because a NESTED scope is a savepoint, and a savepoint can change neither the isolation level nor the read-only-ness of the transaction it sits inside. Non-zero options on a nested call are therefore refused with NestedIsolation rather than ignored — silently downgrading a caller's explicit `Serializable` to whatever the outer transaction happened to use is the kind of "helpful" behaviour that produces a data race nobody can find (ADR 0055 §D5).

func (TxOptions) StdOptions
func (TxOptions) StdOptions() *stdsql.TxOptions

StdOptions renders the value as the stdlib's own option struct, ready for (*sql.DB).BeginTx.

Generated from docs/api by tools/genindex.

Documentation

Overview

Package sql is the public facade for the SDK's relational-database domain: transaction ownership, a pool policy, a health check and a migration runner, over the standard library's database/sql.

It is NOT an ORM, and it never will be. There is no entity mapping, no query builder, no lazy loading and no repository generation — you write SQL, the SDK owns the transaction around it (ADR 0055).

It also ships no driver. Import the one you want, open the pool yourself, and hand it over:

import _ "github.com/jackc/pgx/v5/stdlib"

pool, err := stdsql.Open("pgx", dsn)
dialect, err := sql.ParseDialect("postgres")

tm, err := sql.NewTransactor(sql.Config{
	DB:      pool,
	Dialect: dialect,
	Pool:    sql.PoolConfig{MaxOpen: 20},
})

Who is allowed to commit

Nobody but the manager. Transact hands your unit of work an Executor — Exec, Query, QueryRow, and deliberately nothing else. A helper that receives one CANNOT commit or roll back the transaction it was lent, because those methods do not exist on the type it was given:

err := sql.Transact(ctx, tm, func(ctx context.Context, ex sql.Executor) error {
	if _, err := ex.ExecContext(ctx, "UPDATE accounts SET cents = cents - $1 WHERE id = $2", n, from); err != nil {
		return err
	}
	return credit(ctx, ex, to, n) // credit cannot commit; it has no way to
})

Returning nil commits. Returning an error rolls back and hands the error back to you unchanged, so errors.Is keeps working.

Nesting is a savepoint

database/sql has no nested transactions. Call Transact with a context that already carries one and you get a SAVEPOINT of that transaction rather than a second connection deadlocking against the first:

_ = sql.Transact(ctx, tm, func(ctx context.Context, ex sql.Executor) error {
	if err := mustHappen(ctx, ex); err != nil {
		return err
	}
	// best-effort: if this fails, only ITS work is undone
	if err := sql.Transact(ctx, tm, optional); err != nil {
		log.Warn("optional step skipped", "err", err)
	}
	return nil
})

Three consequences worth knowing before you rely on it:

  • Only postgres, mysql and sqlite are supported, because those three spell savepoints identically. SQL Server, Oracle and Db2 are refused BY NAME at ParseDialect — their nesting is a different algorithm, not a different string, and generating SQL that fails at the first nested transaction in production is not an option.
  • A failed nested scope does NOT condemn the outer transaction. ROLLBACK TO SAVEPOINT is precisely the statement that clears PostgreSQL's aborted state, so catching the inner error leaves you a usable transaction. If that statement itself fails, the transaction is poisoned: every later operation is refused and the commit never happens.
  • A nested call must pass the ZERO TxOptions. A savepoint cannot change isolation or read-only mode, and being handed a weaker transaction than you asked for under a nil error is worse than being told no.

Joining the caller's transaction, and waiting for its commit

The transactor NewTransactor returns has two more capabilities, each an interface of its own beside Transactor, found by type assertion (ADR 0139):

ex, inTx := tm.(sql.Joiner).Join(ctx)       // where a statement under ctx runs
held := tm.(sql.Deferrer).Defer(ctx, notify) // notify runs once ctx's transaction commits

Joiner answers the executor of the transaction ctx carries — so a repository called inside its caller's transaction reads what that transaction wrote — or the pool when it carries none. A context that outlived its transaction still names it, and its executor refuses rather than fall back to the pool. Deferrer holds a function until that transaction commits: a rollback drops it, and so does the rollback of the savepoint it was held in. It is how a message, a mail or a store's write hook waits for the work it announces to be committed. The SDK's document store over SQL is built on both.

Migrations

NewMigrator applies an ordered, versioned set under a lock that excludes every other runner and dies with its holder, so two instances starting together cannot apply the same migration twice and a runner killed mid-run leaves nothing held. On PostgreSQL and MySQL it is the session's advisory lock, which the server drops with the connection. On SQLite it is the database file's write lock (ADR 0140): the run is ONE transaction that takes it with its first statement, each migration a savepoint of it, and the operating system drops a dead process's file locks. A SQLite run needs a single connection, and a migration there cannot run what SQLite refuses inside a transaction — VACUUM, or PRAGMA journal_mode.

Migrations are VALUES you build. The SDK ships no directory, no file format and no naming convention, because the moment it reads a directory it has invented one you did not choose:

m := sql.Migration{
	Version: 20260910143000,
	Name:    "create accounts",
	Up:      sql.Statements("CREATE TABLE accounts (id BIGINT PRIMARY KEY, cents BIGINT NOT NULL)"),
	Down:    sql.Statements("DROP TABLE accounts"),
}

Each migration runs in its own transaction together with its version-table row, so on PostgreSQL and SQLite a failed migration rolls back BOTH halves and the database sits exactly at the previous version.

On MySQL and MariaDB that guarantee does not hold, and it is not something this SDK can fix: DDL causes an IMPLICIT COMMIT, so a migration whose second statement fails leaves the first one applied while its version row is rolled back. Write ONE DDL statement per migration on MySQL and the failure is atomic by construction. See ADR 0055 D8.

Migrator.Plan is the dry run: it reports what Migrator.Up would apply, applies nothing and takes no lock. Irreversible is how a migration says out loud that it cannot be rolled back — a nil Down is refused, because "I forgot the reversal" and "there is no reversal" are the same nil.

Errors

Every failure is a typed SDK error joined with the driver's own, side by side, so neither question loses its answer:

errors.Is(err, sql.CommitFailed)              // the SDK's verdict
errs.HasCode(err, sql.CommitFailed.Code())    // the same, by code
errors.Is(err, driver.ErrBadConn)             // the driver's own

The sentinels above are the matchable values; their Code() is the ADR 0005 dotted quad. No Public message in this domain ever carries a connection string, a query or a bound argument.

Index

Constants

This section is empty.

Variables

View Source
var (
	// UnknownDialect is returned by ParseDialect for a name this SDK has
	// never heard of.
	UnknownDialect = coresql.UnknownDialect
	// DialectRefused is returned by ParseDialect for an engine this SDK
	// recognises and declines. The reason travels in a field.
	DialectRefused = coresql.DialectRefused
	// NestedIsolation is returned when a nested Transact asks for isolation
	// or read-only mode a savepoint cannot provide.
	NestedIsolation = coresql.NestedIsolation
	// InvalidMigration is returned for a migration with version 0, an empty
	// name, a nil Up or a nil Down.
	InvalidMigration = coresql.InvalidMigration
	// MigrationIrreversible is what the Irreversible step returns.
	MigrationIrreversible = coresql.MigrationIrreversible
	// ConfigInvalid is returned by every constructor for a Config that could
	// never produce a working port.
	ConfigInvalid = coresql.ConfigInvalid
	// PoolMisconfigured refuses a pool policy whose zero value database/sql
	// would read as a decision nobody made — a non-positive MaxOpen.
	PoolMisconfigured = coresql.PoolMisconfigured
	// BeginFailed reports a transaction the driver would not open.
	BeginFailed = coresql.BeginFailed
	// CommitFailed reports a transaction that had NO effect.
	CommitFailed = coresql.CommitFailed
	// RollbackFailed travels alongside the error that triggered the rollback.
	RollbackFailed = coresql.RollbackFailed
	// SavepointFailed reports a rejected savepoint statement; the "statement"
	// field names which of the three.
	SavepointFailed = coresql.SavepointFailed
	// TxPoisoned refuses every operation on a transaction whose savepoint
	// rollback failed. It is never committed.
	TxPoisoned = coresql.TxPoisoned
	// TxClosed reports an Executor used after the scope that lent it
	// returned.
	TxClosed = coresql.TxClosed
	// HealthCheckFailed reports a database that answered the probe with an
	// error.
	HealthCheckFailed = coresql.HealthCheckFailed
	// HealthCheckTimeout reports a database that did not answer in time.
	HealthCheckTimeout = coresql.HealthCheckTimeout
	// MigrationFailed reports a migration that did not apply; it was rolled
	// back.
	MigrationFailed = coresql.MigrationFailed
	// MigrationOutOfOrder refuses a pending migration older than one already
	// applied.
	MigrationOutOfOrder = coresql.MigrationOutOfOrder
	// MigrationLockUnsupported refuses a Migrator on a dialect with no lock
	// that dies with its holder. No dialect this SDK speaks answers it since
	// SQLite's runner serialises on its file's write lock (ADR 0140).
	MigrationLockUnsupported = coresql.MigrationLockUnsupported
	// MigrationLockTimeout reports another process holding the migration lock
	// for the whole budget. Nothing was applied.
	MigrationLockTimeout = coresql.MigrationLockTimeout
	// MigrationUnknownVersion refuses a Down over a version this build does
	// not carry.
	MigrationUnknownVersion = coresql.MigrationUnknownVersion
	// VersionTableInvalid refuses a version-table name that is not a plain
	// SQL identifier.
	VersionTableInvalid = coresql.VersionTableInvalid
	// DuplicateMigration refuses two migrations declaring the same version.
	DuplicateMigration = coresql.DuplicateMigration
)

Functions

func Irreversible

func Irreversible(ctx context.Context, ex Executor) error

Irreversible is the Step a migration assigns to Down to declare, out loud, that it cannot be reversed.

func Transact

func Transact(ctx context.Context, tm Transactor, fn TxFunc) error

Transact runs fn inside a transaction with the driver's default isolation.

It is the ergonomic form of Transactor.Transact for the common case. The port itself keeps the options parameter so it never needs a second method (ADR 0039); this helper keeps the call site short.

Types

type Checker

type Checker = coresql.Checker

Checker is the public alias for the bounded liveness probe.

func NewChecker

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

NewChecker returns a liveness probe bounded by cfg.CheckTimeout.

type Config

type Config = svcsql.Config

Config is the public alias for the parameters every port is built from.

type Deferrer

type Deferrer = coresql.Deferrer

Deferrer is the public alias for the ADR 0039 sibling of Transactor that holds a function until the transaction a context carries has committed, and drops it with a rollback. Discover it by type assertion.

type Dialect

type Dialect = coresql.Dialect

Dialect is the public alias for the closed set of SQL engines this SDK can spell. For SQL you write yourself, it spells the engine's vocabulary — the tokens the SDK's own statements are spelled with, never a statement: Dialect.Placeholder renders a bind marker ($1 on PostgreSQL, ? on MySQL and SQLite), Dialect.QuoteIdent a delimited name, its delimiter doubled inside, and Dialect.ForUpdate and Dialect.ForUpdateSkipLocked a row-lock clause, which SQLite — whose exclusion is its one write lock — renders as nothing. QuoteIdent validates nothing: check a name before you interpolate it. An unset Dialect renders no marker and no name, so a statement built from it fails rather than passing for another engine's.

const DialectMySQL Dialect = coresql.DialectMySQL

DialectMySQL is MySQL 5.7+ / MariaDB 10.x with InnoDB.

const DialectPostgres Dialect = coresql.DialectPostgres

DialectPostgres is PostgreSQL 9.1+.

const DialectSQLite Dialect = coresql.DialectSQLite

DialectSQLite is SQLite 3.6.8+. It has no advisory lock, so NewMigrator serialises a run on the database file's write lock instead (ADR 0140).

func ParseDialect

func ParseDialect(name string) (dialect Dialect, err error)

ParseDialect resolves a dialect name and never guesses. A recognised but unsupported engine returns DialectRefused carrying why; an unrecognised one returns UnknownDialect.

type Executor

type Executor = coresql.Executor

Executor is the public alias for the read/write surface a statement runs against. *sql.DB, *sql.Tx and *sql.Conn all satisfy it with no adapter, and it deliberately carries no Commit, Rollback or Begin.

type Joiner

type Joiner = coresql.Joiner

Joiner is the public alias for the ADR 0039 sibling of Transactor that says where a statement issued under a context runs: in the transaction the context carries, or on the pool. Discover it by type assertion.

type MigrateConfig

type MigrateConfig = svcsql.MigrateConfig

MigrateConfig is the public alias for the migration runner's parameters.

type Migration

type Migration = coresql.MigrationValue

Migration is the public alias for one versioned schema change and its reversal.

type Migrator

type Migrator = coresql.Migrator

Migrator is the public alias for the schema-migration runner.

func NewMigrator

func NewMigrator(cfg Config, mig MigrateConfig) (runner Migrator, err error)

NewMigrator returns the migration runner. It refuses a dialect with no session-scoped advisory lock, by name and at construction.

type PoolConfig

type PoolConfig = svcsql.PoolConfig

PoolConfig is the public alias for the connection-pool policy.

type Preparer

type Preparer = coresql.Preparer

Preparer is the public alias for the ADR 0039 sibling of Executor: an executor that can also prepare a statement. Discover it by type assertion.

type Step

type Step = coresql.Step

Step is the public alias for one direction of a migration.

func Statements

func Statements(stmts ...string) Step

Statements returns a Step that runs the given statements in order on the transaction's executor.

It is the smallest useful helper and deliberately not a file loader: it invents no directory layout, no naming convention and no parser. Where the text comes from — a literal, an embed.FS, a generator — stays yours.

type Transactor

type Transactor = coresql.Transactor

Transactor is the public alias for the transaction manager.

func NewTransactor

func NewTransactor(cfg Config) (manager Transactor, err error)

NewTransactor returns the transaction manager for cfg.DB, applying cfg.Pool to it.

type TxFunc

type TxFunc = coresql.TxFunc

TxFunc is the public alias for a unit of work run inside a transaction.

type TxOptions

type TxOptions = coresql.TxOptionsValue

TxOptions is the public alias for one transaction's isolation and read-only-ness. The zero value defers to the driver.

Jump to

Keyboard shortcuts

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