sqlitekit

package module
v0.2.1 Latest Latest
Warning

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

Go to latest
Published: Oct 7, 2026 License: MIT Imports: 17 Imported by: 0

README

sqlitekit

A small SQLite wrapper for Go services: a split read/write connection pool, transactions that follow the context, and embedded forward-only migrations.

It is deliberately thin. It does not build queries, scan rows into structs, or hide SQL — you write SQL against a *sqlite.Conn inside a callback, and the package looks after the pool, the transaction boundary, cancellation, and the schema version. The full reference is the package documentation: go doc 0body.de/sqlitekit. This file is the front door.

go get 0body.de/sqlitekit@latest

What it gives you

  • A read/write pool split. One read-write connection, so writers serialise without application-level locks, plus a pool of read-only connections for concurrent readers. Both open in WAL mode.
  • Transactions with nesting. WithRW and WithRO take a callback and a context. Nested calls reuse the connection — a SAVEPOINT for a nested write — instead of deadlocking on the single writer.
  • Cancellation that holds up. A cancelled context interrupts running SQL, and the statements the package issues itself run with the interrupt cleared, so a cancellation cannot leave a connection mid-transaction.
  • Forward-only migrations from an fs.FS, applied at startup in one transaction.
  • Diagnostics. Slow-operation logging with a stack sample, and a Stats snapshot.

Quickstart

db, err := sqlitekit.New(ctx,
    sqlitekit.WithURI("file:/var/lib/sqlitekit/app.db"),
    sqlitekit.WithMigrationsFS(migrations.FS),
)
if err != nil {
    return err
}
defer db.Close(context.Background())

// A write transaction: return nil to commit, an error to roll back.
err = db.WithRW(ctx, func(ctx context.Context, c *sqlite.Conn) error {
    return sqlitex.Execute(c, "INSERT INTO notes(body) VALUES (?1);",
        &sqlitex.ExecOptions{Args: []any{"hello"}})
})

// A read transaction: a consistent snapshot, always rolled back.
err = db.WithRO(ctx, func(ctx context.Context, c *sqlite.Conn) error {
    return sqlitex.Execute(c, "SELECT body FROM notes;", &sqlitex.ExecOptions{
        ResultFunc: func(s *sqlite.Stmt) error {
            fmt.Println(s.ColumnText(0))
            return nil
        },
    })
})

The concurrency model

pool size used by
read-write 1 WithRW
read-only 10, or WithROPoolSize WithRO

The single read-write connection is what keeps writers from fighting: callers queue in the pool rather than meeting each other at SQLITE_BUSY. Read-only connections are opened with read-only flags, so a write through WithRO fails at the database rather than relying on discipline.

Write transactions are BEGIN IMMEDIATE

WithRW takes the write lock before your callback runs, rather than at its first write. That matters as soon as more than one process opens the same file: a deferred transaction takes the write lock lazily, so two processes starting at once can both read schema version N, both conclude that migration N+1 is pending, and only discover the conflict when the second fails mid-transaction with table already exists — a failed startup instead of a no-op.

The cost is that beginning the transaction waits when another process holds the write lock. That wait is bounded by busy_timeout (5s, set on the read-write pool) and is not currently interruptible by the context.

Migrations

WithMigrationsFS takes an fs.FS holding *.up.sql files at its root, named <version>_<description>.up.sql. They are applied in version order and recorded in a schema_migration table with a single version counter.

They run inside New — every pending migration in one write transaction, together with its version bump — so a failure leaves the schema untouched rather than half-migrated. There are no down migrations: schema changes are forward-only, which removes the class of bug where a down script has drifted out of sync with data already written. The package documentation covers the remaining rules — gaps, malformed names, and the version-zero rule.

Nested calls

WithRW and WithRO may be called again from inside a callback. The connection is reused; a nested write gets its own SAVEPOINT, and a nested call naming a different *DB is refused with ErrWrongDB rather than quietly running against the wrong file. The package documentation has the full matrix, including why WithRW inside WithRO is an error rather than a promotion.

Checks

make check            # gofmt, go vet, go test -race
make check-release    # the same with GOWORK=off

check-release exists because a local go.work points the assertion library at a checkout beside this repository; without it, a broken go.mod or go.sum would still build here and fail for everyone else. make help lists the rest.

There is no push-triggered CI. The git server's post-receive hook is deliberately a pure signal emitter — Git state may not trigger work — so make check is run by hand. See TODO.md if that ever needs enforcing.

Requirements

Go 1.25 or newer. No cgo: CGO_ENABLED=0 go build ./... works, because the SQLite library underneath is the pure-Go translation.

Status

Pre-1.0, so the API may still change. Releases are tags on main. TODO.md lists the known gaps.

Documentation

Overview

Package sqlitekit provides a wrapper around SQLite connection pools and the embedded SQL migrations used by the application.

Overview

The package provides a single read-write pool for mutations, a read-only pool for concurrent readers, transactional helpers, and an embedded migration runner.

The DB type manages two pools:

  • RW pool — always size 1, so writes are serialised automatically.
  • RO pool — defaults to size 10; used for concurrent read-only queries.

Connections are obtained and returned via the DB.WithRW / DB.WithRO helpers. DB also co-ordinates shutdown so that DB.Close waits for borrowed connections to be returned before tearing down the pools. Callers should supply contexts with deadlines or timeouts to bound how long DB.Close will wait.

Connection rules

*sqlite.Conn instances are borrowed from internal pools and must only be used inside the DB.WithRW / DB.WithRO callback where they were provided. Do NOT retain, reuse, or pass a borrowed *sqlite.Conn to other goroutines. The connection is not safe for concurrent use and doing so may cause data races or corruption-like behaviour.

Constructing a DB

db, err := sqlitekit.New(ctx,
    sqlitekit.WithURI("file::memory:?mode=memory&cache=shared"),
    sqlitekit.WithMigrationsFS(migrationsFS),
)

New accepts zero or more DBOption values. If no URI is provided via WithURI, the value of the SQLITEKIT_DB environment variable is used; if that is also unset the default path "/var/lib/sqlitekit/sqlitekit.db" is used.

Useful options:

  • WithURI — sqlite connection string (default: env SQLITEKIT_DB or "/var/lib/sqlitekit/sqlitekit.db").
  • WithROPoolSize — read-only pool size (default: 10).
  • WithMigrationsFS — filesystem of *.up.sql migration files (see below).
  • WithSlowOpThreshold — log a warning when WithRW/WithRO takes longer than this (default: 500ms; 0 disables).
  • WithLogger — slog.Logger for diagnostics (default: slog.Default()).
  • WithRWFlags / WithROFlags — override sqlite open flags.

Nested calls

DB.WithRW and DB.WithRO can be called from within an active callback. The active connection is stored in the context; nested calls detect it and reuse the same connection rather than borrowing a new one.

  • Nested DB.WithRW inside DB.WithRW: a SQLite SAVEPOINT is used instead of a new transaction. Returning nil from the inner callback releases the savepoint (folding the inner changes into the outer transaction). Returning an error rolls back only to that savepoint; the outer transaction remains open and usable.

    If rolling back or releasing that savepoint itself fails, the failure is recorded and the outer transaction is rolled back instead of committed, even when the outer callback returned nil. The nested call returns an error joining the callback and cleanup failures, and the outer call returns one joining the cleanup failure with any outer callback error. A transaction whose savepoint cleanup failed is never committed.

  • Nested DB.WithRO inside DB.WithRO, or DB.WithRO inside DB.WithRW: the same connection is reused with no additional transaction wrapping.

  • Nested DB.WithRW inside DB.WithRO: not allowed — returns ErrRWInsideRO. A read-only connection cannot be promoted to read-write.

  • A nested call naming a DB other than the one owning the active transaction returns ErrWrongDB without invoking the callback, since the active connection belongs to the outer database. This is checked before ErrRWInsideRO.

This means nested calls never deadlock on the size-1 RW pool and never create conflicting transactions.

The context passed to a callback carries that transaction's mutable state. The context itself is safe to hold and pass around, but a nested DB.WithRW or DB.WithRO call made with it runs against that transaction's single connection and shares its cleanup state, so such calls must not be made from multiple goroutines at once: a connection, like the transaction it belongs to, is single-threaded. The context must not be retained after the callback returns either — the connection has gone back to its pool, so a late call would run on a pooled connection without borrowing one, bypassing ErrDBAlreadyClosed and the pool's bookkeeping.

Shutdown

DB.Close always marks the DB closed immediately (preventing new borrows) and then waits for outstanding connections to be returned. If the context expires before all connections drain, DB.Close returns context.Err and schedules pool teardown in a background goroutine that runs once the last connection is returned.

A subsequent DB.Close call with a fresh context will block until that background teardown completes, giving callers a reliable "fully closed" guarantee before process exit:

// Attempt graceful drain; accept timeout.
_ = db.Close(shortCtx)
// Wait for background teardown to finish.
_ = db.Close(context.Background())

Running work

err = db.WithRW(ctx, func(ctx context.Context, c *sqlite.Conn) error {
    // executed inside a transaction; return nil to commit, any error to roll back
    return nil
})

err = db.WithRO(ctx, func(ctx context.Context, c *sqlite.Conn) error {
    // read-only; transaction is always rolled back
    return nil
})

Slow-operation logging

When WithSlowOpThreshold is set (default: 500 ms), any DB.WithRW or DB.WithRO call that takes longer than the threshold emits a WARN log entry (via the configured logger) followed by an INFO completion entry. Each pair shares a monotonic sequence number for correlation. Set the threshold to 0 to disable slow-op logging entirely.

Migrations

Migration files must be placed at the root of the provided filesystem and named with a zero-padded numeric prefix, a descriptive name, and the ".up.sql" suffix:

000001_create_users_table.up.sql
000002_add_indexes.up.sql

Files are parsed for their numeric prefix and applied in numeric order. Those at or below the current database version are already applied and are not run again.

Before anything is applied, the runner compares the database's recorded version against the highest migration this binary carries. A database written by a NEWER binary — one that knew about migrations this binary does not have — is refused with ErrMigrationDatabaseAhead, because migrations run forward only and continuing would run this binary's queries against a schema it does not know. The failure would otherwise surface later, per request, as an unexplained SQL error.

The runner validates the pending migrations for contiguity before applying any of them. Each pending migration's version must be exactly one greater than the one before it (the first must be currentVersion+1). If a gap is found the runner returns ErrMigrationUnexpectedVersionError and no migration is applied.

Migration state is maintained in a single-row table:

CREATE TABLE schema_migration (
    id      INTEGER PRIMARY KEY CHECK(id = 1),
    version INTEGER NOT NULL
);

The CHECK constraint guarantees only one row ever exists. The version is incremented by one after each migration script executes successfully.

To apply embedded migrations at startup, pass WithMigrationsFS to New:

db, err := sqlitekit.New(ctx, sqlitekit.WithMigrationsFS(migrations.FS))

Migration duration is bounded by the context passed to New. Most migrations are DDL and complete in milliseconds, but if you need an explicit timeout wrap the context before calling New:

ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
db, err := sqlitekit.New(ctx, sqlitekit.WithMigrationsFS(migrations.FS))

Use WithMigrationsFS(nil) to skip migrations in tests.

Migration design: forward-only

This package intentionally supports only "up" migrations (*.up.sql). There are no rollback/down scripts. Schema changes are always forward-only: once a migration is applied it is permanent. This avoids the class of bugs where a down-migration is out of sync with the data already written by the up-migration.

Error sentinels

Connection and transaction lifecycle:

ErrDBAlreadyClosed // the DB was closed; returned by WithRW and WithRO
ErrRWInsideRO      // WithRW called inside a WithRO callback
ErrWrongDB         // a nested call targets a different DB than the active transaction

The last two are caller bugs rather than transient failures; they identify the enclosing database by pointer equality and do not invoke the callback.

Migrations:

ErrMigration                       // base sentinel for all migration errors
ErrMigrationScriptError            // a migration SQL script failed; joined with ErrMigration
ErrMigrationUnexpectedVersionError // version gap or bad filename; joined with ErrMigration

All three migration sentinels are joined using errors.Join, so callers can test with:

errors.Is(err, sqlitekit.ErrMigration)

In-memory databases

When opening multiple pools against the same in-memory database (e.g. for tests), use a shared-cache URI so both pools see the same data:

file::memory:?mode=memory&cache=shared

Using plain ":memory:" gives each connection a separate in-memory database.

Migration runner and helpers for embedded SQL migrations.

Index

Examples

Constants

View Source
const EnvKeyDBName = "SQLITEKIT_DB"

EnvKeyDBName is the environment key used to configure the database path.

Variables

View Source
var (
	ErrMigration                       = errors.New("migration error")
	ErrMigrationScriptError            = errors.Join(ErrMigration, errors.New("bad migration contents"))
	ErrMigrationUnexpectedVersionError = errors.Join(ErrMigration, errors.New("unexpected version"))

	// ErrMigrationDatabaseAhead means the database was migrated by a binary that
	// knew about a higher schema version than this one carries. Migrations run
	// forward only, so this cannot be repaired from here, and continuing would
	// run this binary's queries against a schema it does not know about.
	//
	// Detected by comparing the database's recorded schema version against the
	// highest migration this binary carries. Nothing else is needed: the recorded
	// version is the schema actually in front of us.
	//
	// The condition is not repairable by the application, because opening the
	// database is what fails. Recovery is either the newer binary or an
	// intentional override, so the error names the operation that is safe:
	// opening the file with plain SQLite, or the newer code.
	ErrMigrationDatabaseAhead = errors.Join(ErrMigrationUnexpectedVersionError, errors.New("database schema is newer than this binary"))
)
View Source
var ErrDBAlreadyClosed = errors.New("database already closed")

ErrDBAlreadyClosed is returned by WithRW and WithRO when the DB has already been closed.

View Source
var ErrRWInsideRO = errors.New("WithRW called inside WithRO transaction")

ErrRWInsideRO is returned when WithRW is called from within a WithRO callback. Upgrading a read-only transaction to read-write is a logic error — restructure the caller so writes happen at the top level.

View Source
var ErrWrongDB = errors.New("nested transaction targets a different database")

ErrWrongDB is returned when a nested call targets a different DB than the one owning the active transaction. The active connection belongs to the outer database, so reusing it would run the nested work against the wrong database. The callback is not invoked. This is a caller bug — pass the context down only within the database that created it.

Functions

func IsDatabaseAhead added in v0.2.1

func IsDatabaseAhead(err error) bool

IsDatabaseAhead reports whether err is the condition described by ErrMigrationDatabaseAhead: the database's schema is newer than this binary can understand. Prefer errors.Is(err, ErrMigrationDatabaseAhead).

Types

type DB

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

DB manages two sqlite connection pools: a read-write pool (used for schema changes and writes) and a read-only pool for concurrent readers. Connections are borrowed via WithRW and WithRO and must not be retained, reused, or shared outside the callback — see package-level documentation for the full connection rules.

func New

func New(ctx context.Context, opts ...DBOption) (db *DB, err error)

New constructs a DB using functional options. The URI defaults to the SQLITEKIT_DB environment variable, falling back to /var/lib/sqlitekit/sqlitekit.db if unset.

func (*DB) Close

func (db *DB) Close(ctx context.Context) error

Close closes the database pools. Close will block until all outstanding connections have been returned (via Put) or until the provided context is done. If the context expires before all connections are returned, Close marks the DB closed (preventing new borrows), schedules pool teardown in a background goroutine that fires once the last outstanding connection is returned, and immediately returns ctx.Err().

A subsequent Close call on the same DB (idempotent path) will wait for that background teardown to complete, so callers can use a fresh context with a hard deadline to ensure pools are fully closed before process exit.

Close is idempotent.

func (*DB) Ping

func (db *DB) Ping(ctx context.Context) error

Ping verifies that the database is reachable by executing a lightweight read-only query. It is safe to call concurrently. Returns nil on success.

func (*DB) Stats

func (db *DB) Stats() Stats

Stats returns a point-in-time snapshot of runtime diagnostics. The values are collected without locking so they reflect a consistent but not necessarily atomic view of the DB state.

func (*DB) WithRO

func (db *DB) WithRO(ctx context.Context, fn func(context.Context, *sqlite.Conn) error) error

WithRO obtains a read-only connection and runs fn inside a deferred transaction, providing a consistent read snapshot in WAL mode. The transaction is always rolled back on completion — nothing is written. If called from within an existing WithRO or WithRW callback, it reuses the connection and skips opening a new transaction. Returns ErrWrongDB if the enclosing transaction belongs to another database.

Example

ExampleDB_WithRO demonstrates a read-only operation. In this case we cancel the context immediately to show that WithRO respects cancellation and returns an error instead of blocking indefinitely.

ctx, cancel := context.WithCancel(context.Background())
cancel()

db, err := New(ctx,
	WithURI("file::memory:?mode=memory&cache=shared"),
	WithMigrationsFS(nil),
)
if err != nil {
	fmt.Println("open error:", err)
	return
}
defer func() { _ = db.Close(context.Background()) }()

err = db.WithRO(ctx, func(context.Context, *sqlite.Conn) error {
	return nil
})
if err != nil {
	fmt.Println("expected error:", err)
}
Output:
expected error: context canceled

func (*DB) WithRW

func (db *DB) WithRW(ctx context.Context, fn func(context.Context, *sqlite.Conn) error) error

WithRW obtains a read-write connection and runs fn inside a transaction. If called from within an existing WithRW callback (detected via context), it reuses the connection and wraps fn in a SAVEPOINT instead.

Returns ErrRWInsideRO if called from within a WithRO callback of the same database, and ErrWrongDB if the enclosing transaction belongs to another database (checked first).

If a nested savepoint cannot be cleaned up, the failure is recorded and the outer transaction is rolled back rather than committed, even if the outer callback returns nil.

Example

ExampleDB_WithRW demonstrates using WithRW to perform a simple write operation inside a transaction bounded by a context.

ctx := context.Background()
toCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer cancel()

db, err := New(toCtx,
	WithURI("file::memory:?mode=memory&cache=shared"),
	WithMigrationsFS(nil),
)
if err != nil {
	fmt.Println("open error:", err)
	return
}
defer func() { _ = db.Close(ctx) }()

if err := db.WithRW(toCtx, func(_ context.Context, c *sqlite.Conn) error {
	// create a table (this should succeed quickly)
	return sqlitex.Execute(c, "CREATE TABLE example(id INTEGER PRIMARY KEY);", nil)
}); err != nil {
	fmt.Println("rw error:", err)
	return
}

fmt.Println("rw example completed")
Output:
rw example completed

type DBOption

type DBOption func(*dbConfig)

DBOption configures a DB created by New. Options are applied in order.

func WithLogger

func WithLogger(l *slog.Logger) DBOption

WithLogger supplies a logger used for internal diagnostics (for example slow-operation logging). If omitted or nil, the package will use slog.Default().

func WithMigrationsFS

func WithMigrationsFS(fsys fs.FS) DBOption

WithMigrationsFS sets the migration filesystem used during startup. Pass nil to skip startup migrations.

func WithROFlags

func WithROFlags(f sqlite.OpenFlags) DBOption

WithROFlags sets sqlite open flags for the RO pool.

func WithROPoolSize

func WithROPoolSize(n int) DBOption

WithROPoolSize sets the read-only pool size.

func WithRWFlags

func WithRWFlags(f sqlite.OpenFlags) DBOption

WithRWFlags sets sqlite open flags for the RW pool.

func WithSlowOpThreshold

func WithSlowOpThreshold(d time.Duration) DBOption

WithSlowOpThreshold sets the duration after which a running WithRW or WithRO will emit a diagnostic log entry. Default is 500ms.

func WithURI

func WithURI(uri string) DBOption

WithURI sets the sqlite connection URI used to open pools.

type Stats

type Stats struct {
	// RWPoolSize is always 1; the read-write pool is deliberately single-
	// connection to serialise writes automatically.
	RWPoolSize int

	// ROPoolSize is the configured size of the read-only connection pool.
	ROPoolSize int

	// OutstandingBorrows is the number of connections currently checked out
	// via WithRW or WithRO.
	OutstandingBorrows int

	// SlowOpCount is the total number of WithRW/WithRO calls that have
	// exceeded the configured slow-op threshold and emitted a warning since
	// the DB was created.
	SlowOpCount int
}

Stats holds a snapshot of runtime diagnostics for a DB.

Source Files

  • close.go
  • conn.go
  • conn_ops.go
  • db_config.go
  • doc.go
  • migrate.go
  • slow.go
  • stats.go
  • tx.go

Jump to

Keyboard shortcuts

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