feat(sqlite): initial stable release v0.9.0

Pure-Go CGO-free SQLite client with launcher lifecycle, write-mutex serialisation, health check, unit-of-work via context injection, and structured error mapping.

What's included:
- Executor / Tx / Client / Component interfaces using database/sql native types
- Tx.Commit() / Tx.Rollback() without ctx, matching the honest database/sql contract
- New(logger, cfg) constructor; database opened in OnInit
- Config struct with env-tag support; default Pragmas: WAL + 5s busy timeout + FK enforcement
- PRAGMA foreign_keys = ON enforced explicitly in OnInit
- writeMu sync.Mutex acquired by UnitOfWork.Do to serialise writes and prevent SQLITE_BUSY
- UnitOfWork via context injection; GetExecutor(ctx) returns active Tx or *sql.DB
- HandleError mapping SQLite extended error codes to xerrors codes (unique/primary-key → AlreadyExists, foreign-key → InvalidInput, ErrNoRows → NotFound)
- health.Checkable at LevelCritical; pure-Go modernc.org/sqlite driver (CGO_ENABLED=0 compatible)

Tested-via: todo-api POC integration
Reviewed-against: docs/adr/
This commit is contained in:
2026-03-19 13:25:31 +00:00
commit 237cba9bad
16 changed files with 1053 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
{
"name": "Go",
"image": "mcr.microsoft.com/devcontainers/go:2-1.25-trixie",
"features": {
"ghcr.io/devcontainers-extra/features/claude-code:1": {}
},
"forwardPorts": [],
"postCreateCommand": "go version",
"customizations": {
"vscode": {
"settings": {
"files.autoSave": "afterDelay",
"files.autoSaveDelay": 1000,
"explorer.compactFolders": false,
"explorer.showEmptyFolders": true
},
"extensions": [
"golang.go",
"eamodio.golang-postfix-completion",
"quicktype.quicktype",
"usernamehw.errorlens"
]
}
},
"remoteUser": "vscode"
}

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with go test -c
*.test
# Output of go build
*.out
# Dependency directory
vendor/
# Go workspace file
go.work
go.work.sum
# Environment files
.env
.env.*
# Editor / IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# VCS files
COMMIT.md
RELEASE.md

33
CHANGELOG.md Normal file
View File

@@ -0,0 +1,33 @@
# Changelog
All notable changes to this module will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.9.0] - 2026-03-18
### Added
- `Executor` interface: `ExecContext`, `QueryContext`, `QueryRowContext` using `database/sql` types (`sql.Result`, `*sql.Rows`, `*sql.Row`).
- `Tx` interface: embeds `Executor` and adds `Commit() error` and `Rollback() error` (no context argument, matching `database/sql` semantics).
- `Client` interface: `GetExecutor(ctx context.Context) Executor`, `Begin(ctx context.Context) (Tx, error)`, `Ping(ctx context.Context) error`, `HandleError(err error) error`.
- `Component` interface: composes `launcher.Component`, `health.Checkable`, and `Client`.
- `UnitOfWork` interface: `Do(ctx context.Context, fn func(ctx context.Context) error) error`.
- `Config` struct: fields `Path` (`SQLITE_PATH`), `MaxOpenConns` (default `1`), `MaxIdleConns` (default `1`), `Pragmas` (default `?_journal=WAL&_timeout=5000&_fk=true`); settable via `SQLITE_*` environment variables.
- `New(logger logz.Logger, cfg Config) Component`: returns a pure-Go SQLite component backed by `modernc.org/sqlite`; no CGO required.
- Lifecycle hooks: `OnInit` opens the database, sets connection limits, and enforces `PRAGMA foreign_keys = ON`; startup fails if the pragma cannot be set. `OnStart` pings with a 5-second timeout. `OnStop` closes the connection.
- `health.Checkable` implementation: `HealthCheck` delegates to `Ping`; `Name()` returns `"sqlite"`; `Priority()` returns `health.LevelCritical`.
- `NewUnitOfWork(logger logz.Logger, client Client) UnitOfWork`: wraps a `Client` to provide transactional `Do` semantics. When the client is the concrete `*sqliteComponent`, the write mutex is acquired for the duration of `Do` to serialise concurrent write transactions and prevent `SQLITE_BUSY`.
- `HandleError(err error) error` (package-level function): maps SQLite extended error codes via a duck-typed `coder` interface — code `2067` (unique constraint) and `1555` (primary key constraint) → `ErrAlreadyExists`; code `787` (foreign key constraint) → `ErrInvalidInput`; `sql.ErrNoRows``ErrNotFound`; all others → `ErrInternal`.
- Transaction context injection: the active `*sql.Tx` is stored under an unexported `ctxTxKey{}` context key; `GetExecutor` returns it when found, otherwise returns `*sql.DB`.
- WAL journal mode, 5-second busy timeout, and foreign key enforcement enabled by default via the `Pragmas` DSN suffix.
- Support for in-memory databases via `Config{Path: ":memory:"}` for test isolation.
### Design Notes
- `modernc.org/sqlite` (pure Go, no CGO) is used instead of `mattn/go-sqlite3`, enabling cross-compilation with `CGO_ENABLED=0` and no system library dependency.
- A `sync.Mutex` (`writeMu`) on the component serialises all `UnitOfWork.Do` calls, preventing `SQLITE_BUSY` errors that arise from SQLite's single-writer constraint without requiring callers to manage locking.
- Foreign key enforcement is applied both via the `_fk=true` DSN pragma and an explicit `PRAGMA foreign_keys = ON` statement in `OnInit`, ensuring enforcement is active regardless of driver-level pragma handling.
[0.9.0]: https://com.nochebuena.dev/go/sqlite/releases/tag/v0.9.0

91
CLAUDE.md Normal file
View File

@@ -0,0 +1,91 @@
# sqlite
Pure-Go SQLite client with launcher lifecycle, health check, unit-of-work, and structured error mapping.
## Purpose
Provides a `database/sql`-backed SQLite client that integrates with the launcher and health
modules. Designed for single-process, embedded database use cases where CGO-free builds and
cross-compilation matter. Serialises writes through a mutex to prevent `SQLITE_BUSY` errors.
## Tier & Dependencies
**Tier 3 (infrastructure)** — depends on:
- `code.nochebuena.dev/go/health` (Tier 2)
- `code.nochebuena.dev/go/launcher` (Tier 2)
- `code.nochebuena.dev/go/logz` (Tier 1)
- `code.nochebuena.dev/go/xerrors` (Tier 0)
- `modernc.org/sqlite` (pure-Go SQLite driver, no CGO)
## Key Design Decisions
- **Pure-Go driver**: `modernc.org/sqlite` is used instead of `mattn/go-sqlite3`. No CGO,
no system library required. Cross-compilation works with `CGO_ENABLED=0`. See ADR-001.
- **Write mutex**: `writeMu sync.Mutex` in `sqliteComponent` is acquired by `UnitOfWork.Do`
before every write transaction. Eliminates `SQLITE_BUSY` under concurrent goroutines. See ADR-002.
- **Foreign key enforcement**: Enabled in both the default DSN Pragmas (`_fk=true`) and via
an explicit `PRAGMA foreign_keys = ON` in `OnInit`. Startup fails if the PRAGMA cannot be
set. See ADR-003.
- **Honest Tx contract**: `Tx.Commit()` and `Tx.Rollback()` accept no `ctx` argument,
matching the `database/sql` limitation. The interface documents this explicitly.
- **Context injection for UoW**: `GetExecutor(ctx)` checks for a `ctxTxKey{}` value in the
context. When inside `UnitOfWork.Do`, the context carries the active transaction, so
repositories call `client.GetExecutor(ctx)` and automatically participate in the transaction
without knowing about it.
- **WAL + busy timeout defaults**: `?_journal=WAL&_timeout=5000&_fk=true` are the default
Pragmas. Callers can override via `Config.Pragmas`, but the `OnInit` FK PRAGMA always runs.
- **Health check**: `Priority()` returns `health.LevelCritical`. A failed ping prevents the
service from being marked healthy.
## Patterns
**Lifecycle registration:**
```go
db := sqlite.New(logger, cfg)
lc.Append(db) // registers OnInit / OnStart / OnStop
```
**Repository pattern using GetExecutor:**
```go
func (r *repo) FindByID(ctx context.Context, id int) (*Thing, error) {
exec := r.db.GetExecutor(ctx) // returns Tx if inside UoW, pool otherwise
row := exec.QueryRowContext(ctx, "SELECT ... WHERE id = ?", id)
...
}
```
**Unit of Work:**
```go
uow := sqlite.NewUnitOfWork(logger, db)
err := uow.Do(ctx, func(ctx context.Context) error {
// all calls to db.GetExecutor(ctx) return the same Tx
return repo.Save(ctx, thing)
})
```
**Error handling:**
```go
if err := db.HandleError(err); err != nil {
// err is a *xerrors.Err with code ErrNotFound, ErrAlreadyExists, ErrInvalidInput, or ErrInternal
}
```
## What to Avoid
- Do not set `MaxOpenConns > 1` without understanding the implications. SQLite allows only one
writer at a time; a larger pool increases `SQLITE_BUSY` risk for callers that bypass `UnitOfWork`.
- Do not use `Begin`/`Commit`/`Rollback` directly for concurrent writes. Use `UnitOfWork.Do`
to get write-mutex protection.
- Do not rely on `Tx.Commit(ctx)` — the `Tx` interface intentionally has no ctx on Commit
and Rollback, matching `database/sql` behaviour.
- Do not wrap `HandleError` output with additional error types that would obscure the
`*xerrors.Err` for callers using `errors.As`.
## Testing Notes
- All tests use `Config{Path: ":memory:"}`. No filesystem or teardown needed.
- `compliance_test.go` (package `sqlite_test`) asserts that `New(...)` satisfies the
`Component` interface at compile time.
- `TestUnitOfWork_WriteMutex` spawns 5 concurrent goroutines to verify serialisation.
- `TestHandleError_ForeignKey` and `TestHandleError_UniqueConstraint` exercise real SQLite
error code mapping; they require `_fk=true` in Pragmas (set in `newMemDB`).

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 NOCHEBUENADEV
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

49
README.md Normal file
View File

@@ -0,0 +1,49 @@
# sqlite
Pure-Go SQLite client (`modernc.org/sqlite`, no CGO) with launcher lifecycle and health check integration.
## Install
```
go get code.nochebuena.dev/go/sqlite
```
## Usage
```go
db := sqlite.New(logger, cfg)
lc.Append(db)
r.Get("/health", health.NewHandler(logger, db))
```
## Testing with :memory:
```go
db := sqlite.New(logger, sqlite.Config{Path: ":memory:"})
db.OnInit()
```
## Unit of Work
```go
uow := sqlite.NewUnitOfWork(logger, db)
err := uow.Do(ctx, func(ctx context.Context) error {
exec := db.GetExecutor(ctx) // returns the active Tx
_, err := exec.ExecContext(ctx, "INSERT INTO orders ...")
return err
})
```
The `UnitOfWork` serialises writes via an internal mutex to prevent `SQLITE_BUSY` errors.
## Configuration
| Env var | Default | Description |
|---|---|---|
| `SQLITE_PATH` | required | File path or `:memory:` |
| `SQLITE_MAX_OPEN_CONNS` | `1` | Max open connections |
| `SQLITE_MAX_IDLE_CONNS` | `1` | Max idle connections |
| `SQLITE_PRAGMAS` | `?_journal=WAL&_timeout=5000&_fk=true` | DSN pragmas |
Foreign key enforcement (`PRAGMA foreign_keys = ON`) is always enabled in `OnInit`.

8
compliance_test.go Normal file
View File

@@ -0,0 +1,8 @@
package sqlite_test
import (
"code.nochebuena.dev/go/logz"
"code.nochebuena.dev/go/sqlite"
)
var _ sqlite.Component = sqlite.New(logz.New(logz.Options{}), sqlite.Config{Path: ":memory:"})

8
doc.go Normal file
View File

@@ -0,0 +1,8 @@
// Package sqlite provides a pure-Go SQLite client (via modernc.org/sqlite) with launcher
// lifecycle and health check integration.
//
// All tests can use Config{Path: ":memory:"} for fully isolated, self-contained databases.
//
// WAL mode, foreign key enforcement, and a busy timeout are enabled by default via the
// Pragmas config field.
package sqlite

View File

@@ -0,0 +1,43 @@
# ADR-001: Pure-Go SQLite Driver via modernc.org/sqlite
**Status:** Accepted
**Date:** 2026-03-18
## Context
SQLite requires a C library on the host system. The standard `mattn/go-sqlite3` driver wraps the
C library via cgo. This means:
- CGO must be enabled at build time (`CGO_ENABLED=1`).
- A C toolchain must be present in every build and CI environment.
- Cross-compilation is significantly harder (requires a cross-compiling C toolchain).
- Static binaries are complicated to produce without additional linker flags.
For a micro-lib that should work in minimal container environments and cross-compile without
ceremony, this is a poor baseline.
## Decision
Use `modernc.org/sqlite` as the SQLite driver. This is a transpilation of the official SQLite
amalgamation from C to Go, producing a pure-Go implementation with no CGO dependency. It is
registered under the driver name `"sqlite"` and is otherwise compatible with `database/sql`.
The import is a blank import in `sqlite.go`:
```go
import _ "modernc.org/sqlite" // register sqlite driver
```
## Consequences
**Positive:**
- `CGO_ENABLED=0` builds work out of the box.
- Cross-compilation requires no special toolchain setup.
- CI environments need only the Go toolchain.
- Minimal container images (scratch, distroless) are straightforward targets.
**Negative:**
- `modernc.org/sqlite` lags slightly behind the official SQLite release cadence.
- Transpiled code is harder to debug at the C level than `mattn/go-sqlite3`.
- The driver name is `"sqlite"` not `"sqlite3"`, which would conflict with any project that
also imports `mattn/go-sqlite3`.

View File

@@ -0,0 +1,53 @@
# ADR-002: Write Mutex to Prevent SQLITE_BUSY Under Concurrent Load
**Status:** Accepted
**Date:** 2026-03-18
## Context
SQLite uses file-level locking. When multiple goroutines attempt write transactions
concurrently, SQLite cannot acquire the write lock immediately and returns `SQLITE_BUSY`.
Although the default Pragmas configure a 5-second busy timeout (`_timeout=5000`), this is a
passive wait that still allows competing transactions to collide and fail under sustained
concurrent write pressure.
WAL mode (`_journal=WAL`) improves read concurrency but does not eliminate write contention:
SQLite still allows only one writer at a time.
## Decision
The `sqliteComponent` holds a `writeMu sync.Mutex` field. `NewUnitOfWork` detects when its
`Client` argument is the concrete `*sqliteComponent` type and extracts a pointer to that mutex.
`unitOfWork.Do` acquires the mutex before beginning a transaction and releases it after
commit or rollback:
```go
func (u *unitOfWork) Do(ctx context.Context, fn func(ctx context.Context) error) error {
if u.writeMu != nil {
u.writeMu.Lock()
defer u.writeMu.Unlock()
}
tx, err := u.client.Begin(ctx)
...
}
```
This serialises all write transactions at the application level, guaranteeing that only one
writer reaches SQLite at a time and eliminating `SQLITE_BUSY` errors entirely.
The mutex is only applied when using `NewUnitOfWork`. Callers who manage transactions manually
via `Begin`/`Commit`/`Rollback` are not protected and must handle contention themselves.
## Consequences
**Positive:**
- `SQLITE_BUSY` is eliminated for all write workloads going through `UnitOfWork`.
- Behaviour is deterministic and testable (see `TestUnitOfWork_WriteMutex`).
- Reads are unaffected; the mutex only wraps writes.
**Negative:**
- Write throughput is bounded to one goroutine at a time. This is acceptable for SQLite's
typical deployment profile (embedded, single-process, modest write rates).
- The type assertion `client.(*sqliteComponent)` couples `NewUnitOfWork` to the concrete type.
When a mock or alternative `Client` is supplied, `writeMu` is `nil` and serialisation is
skipped silently. This is intentional for testing flexibility.

View File

@@ -0,0 +1,54 @@
# ADR-003: Foreign Key Enforcement via PRAGMA and DSN
**Status:** Accepted
**Date:** 2026-03-18
## Context
SQLite disables foreign key constraint enforcement by default for backwards compatibility.
Applications that define `REFERENCES` clauses in their schema will silently insert orphaned
rows unless they explicitly enable enforcement. This is a common source of data integrity bugs.
## Decision
Foreign key enforcement is enabled at two points:
1. **DSN parameter** — The default `Pragmas` config value includes `_fk=true`:
```
?_journal=WAL&_timeout=5000&_fk=true
```
This sets `PRAGMA foreign_keys = ON` for every connection opened via the DSN.
2. **OnInit explicit PRAGMA** — After opening the database pool, `OnInit` executes an
additional `PRAGMA foreign_keys = ON` call:
```go
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
_ = db.Close()
return fmt.Errorf("sqlite: enable foreign keys: %w", err)
}
```
If this call fails, `OnInit` returns an error and the pool is closed, preventing startup
with an unsafe configuration.
The redundancy is deliberate: the DSN parameter may be overridden by callers who supply a
custom `Pragmas` value, but the `OnInit` PRAGMA call always runs and fails loudly if it cannot
enforce foreign keys.
`HandleError` maps `SQLITE_CONSTRAINT_FOREIGNKEY` (error code 787) to
`xerrors.ErrInvalidInput` so that foreign key violations surface as validation errors to
callers rather than opaque internal errors.
## Consequences
**Positive:**
- Foreign key constraints are always active when using the default configuration.
- Failure to enable them is a startup error, not a silent misconfiguration.
- Violations produce a structured `xerrors.ErrInvalidInput` error.
**Negative:**
- Callers who deliberately omit `_fk=true` from a custom `Pragmas` string still get the
enforcement applied by the `OnInit` PRAGMA. There is no opt-out without modifying the source.
- `PRAGMA foreign_keys = ON` must be set per-connection; `database/sql` connection pooling
means this implicit approach (via DSN) can behave differently under pool pressure. The
explicit `OnInit` PRAGMA mitigates this for the initial connection but cannot guarantee it
for all pooled connections when `MaxOpenConns > 1`.

38
errors.go Normal file
View File

@@ -0,0 +1,38 @@
package sqlite
import (
"database/sql"
"errors"
"code.nochebuena.dev/go/xerrors"
)
// coder is the duck-type interface for SQLite extended error codes.
// modernc.org/sqlite errors implement this interface.
type coder interface{ Code() int }
const (
sqliteConstraintPrimaryKey = 1555
sqliteConstraintUnique = 2067
sqliteConstraintForeignKey = 787
)
// HandleError maps SQLite and database/sql errors to xerrors types.
func HandleError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, sql.ErrNoRows) {
return xerrors.New(xerrors.ErrNotFound, "record not found").WithError(err)
}
var ce coder
if errors.As(err, &ce) {
switch ce.Code() {
case sqliteConstraintUnique, sqliteConstraintPrimaryKey:
return xerrors.New(xerrors.ErrAlreadyExists, "record already exists").WithError(err)
case sqliteConstraintForeignKey:
return xerrors.New(xerrors.ErrInvalidInput, "data integrity violation").WithError(err)
}
}
return xerrors.New(xerrors.ErrInternal, "unexpected database error").WithError(err)
}

24
go.mod Normal file
View File

@@ -0,0 +1,24 @@
module code.nochebuena.dev/go/sqlite
go 1.25
require (
code.nochebuena.dev/go/health v0.9.0
code.nochebuena.dev/go/launcher v0.9.0
code.nochebuena.dev/go/logz v0.9.0
code.nochebuena.dev/go/xerrors v0.9.0
modernc.org/sqlite v1.37.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/sys v0.33.0 // indirect
modernc.org/libc v1.65.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

55
go.sum Normal file
View File

@@ -0,0 +1,55 @@
code.nochebuena.dev/go/health v0.9.0 h1:x0UKjC7CHAE3AgwyFzCyjmGJIjoLBBxeOHxXuqpbKwI=
code.nochebuena.dev/go/health v0.9.0/go.mod h1:f3IsNtU60JSn5yXmBBh9XOvr5pRyEah5+wS4tjDQZso=
code.nochebuena.dev/go/launcher v0.9.0 h1:dJHonA9Xm03AQKK0919FJaQn9ZKHZ+RZfB9yxjnx3TA=
code.nochebuena.dev/go/launcher v0.9.0/go.mod h1:IBtntmbnyddukjEhxlc7Ysdzz9nZsnd9+8FzAIHt77g=
code.nochebuena.dev/go/logz v0.9.0 h1:wfV7vtI4V/8ED7Hm31Fbql7Y5iOGrlHN4X8Z5ajTZZE=
code.nochebuena.dev/go/logz v0.9.0/go.mod h1:qODhSbKb+tWE7rdhHLcKweiP5CgwIaWoZxadCT3bQV8=
code.nochebuena.dev/go/xerrors v0.9.0 h1:8wrDto7e44ZW1YPOnT6JrxYXTqnvNuKpAO1/5bcT4TE=
code.nochebuena.dev/go/xerrors v0.9.0/go.mod h1:mtXo7xscBreCB7w7smlBP5Onv8H1HVohCvF0I/VXbAY=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

242
sqlite.go Normal file
View File

@@ -0,0 +1,242 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"sync"
"time"
_ "modernc.org/sqlite" // register sqlite driver
"code.nochebuena.dev/go/health"
"code.nochebuena.dev/go/launcher"
"code.nochebuena.dev/go/logz"
)
// Executor defines operations shared by the connection and transaction.
type Executor interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
// Tx extends Executor with commit/rollback.
// Honest contract: database/sql Tx does not accept ctx on Commit/Rollback.
type Tx interface {
Executor
Commit() error
Rollback() error
}
// Client is the primary interface for consumers.
type Client interface {
GetExecutor(ctx context.Context) Executor
Begin(ctx context.Context) (Tx, error)
Ping(ctx context.Context) error
HandleError(err error) error
}
// Component bundles lifecycle + health + client.
type Component interface {
launcher.Component
health.Checkable
Client
}
// UnitOfWork manages the transaction lifecycle via context injection.
type UnitOfWork interface {
Do(ctx context.Context, fn func(ctx context.Context) error) error
}
// Config holds connection parameters.
type Config struct {
// Path is the SQLite file path. Use ":memory:" for in-memory databases.
Path string `env:"SQLITE_PATH,required"`
// MaxOpenConns limits concurrent connections. Default: 1.
MaxOpenConns int `env:"SQLITE_MAX_OPEN_CONNS" envDefault:"1"`
// MaxIdleConns is the number of idle connections kept in the pool.
MaxIdleConns int `env:"SQLITE_MAX_IDLE_CONNS" envDefault:"1"`
// Pragmas are appended to the DSN. Default: WAL + 5s busy timeout + FK enforcement.
Pragmas string `env:"SQLITE_PRAGMAS" envDefault:"?_journal=WAL&_timeout=5000&_fk=true"`
}
func (c Config) dsn() string {
return c.Path + c.Pragmas
}
// ctxTxKey is the context key for the active transaction.
type ctxTxKey struct{}
// --- sqliteComponent ---
type sqliteComponent struct {
logger logz.Logger
cfg Config
db *sql.DB
mu sync.RWMutex
writeMu sync.Mutex // serialises writes to prevent SQLITE_BUSY
}
// New returns a sqlite Component. Call lc.Append(db) to manage its lifecycle.
func New(logger logz.Logger, cfg Config) Component {
return &sqliteComponent{logger: logger, cfg: cfg}
}
func (c *sqliteComponent) OnInit() error {
db, err := sql.Open("sqlite", c.cfg.dsn())
if err != nil {
return fmt.Errorf("sqlite: open: %w", err)
}
maxOpen := c.cfg.MaxOpenConns
if maxOpen == 0 {
maxOpen = 1
}
db.SetMaxOpenConns(maxOpen)
db.SetMaxIdleConns(c.cfg.MaxIdleConns)
// Enforce foreign keys per-connection (SQLite disables them by default).
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
_ = db.Close()
return fmt.Errorf("sqlite: enable foreign keys: %w", err)
}
c.mu.Lock()
c.db = db
c.mu.Unlock()
return nil
}
func (c *sqliteComponent) OnStart() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := c.Ping(ctx); err != nil {
return fmt.Errorf("sqlite: ping failed: %w", err)
}
c.logger.Info("sqlite: ready", "path", c.cfg.Path)
return nil
}
func (c *sqliteComponent) OnStop() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.db != nil {
c.logger.Info("sqlite: closing")
_ = c.db.Close()
c.db = nil
}
return nil
}
func (c *sqliteComponent) Ping(ctx context.Context) error {
c.mu.RLock()
db := c.db
c.mu.RUnlock()
if db == nil {
return fmt.Errorf("sqlite: not initialized")
}
return db.PingContext(ctx)
}
func (c *sqliteComponent) GetExecutor(ctx context.Context) Executor {
if tx, ok := ctx.Value(ctxTxKey{}).(Executor); ok {
return tx
}
c.mu.RLock()
db := c.db
c.mu.RUnlock()
return db
}
func (c *sqliteComponent) Begin(ctx context.Context) (Tx, error) {
c.mu.RLock()
db := c.db
c.mu.RUnlock()
if db == nil {
return nil, fmt.Errorf("sqlite: not initialized")
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
return &sqliteTx{Tx: tx}, nil
}
func (c *sqliteComponent) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
c.mu.RLock()
db := c.db
c.mu.RUnlock()
return db.ExecContext(ctx, query, args...)
}
func (c *sqliteComponent) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
c.mu.RLock()
db := c.db
c.mu.RUnlock()
return db.QueryContext(ctx, query, args...)
}
func (c *sqliteComponent) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row {
c.mu.RLock()
db := c.db
c.mu.RUnlock()
return db.QueryRowContext(ctx, query, args...)
}
func (c *sqliteComponent) HandleError(err error) error { return HandleError(err) }
// health.Checkable
func (c *sqliteComponent) HealthCheck(ctx context.Context) error { return c.Ping(ctx) }
func (c *sqliteComponent) Name() string { return "sqlite" }
func (c *sqliteComponent) Priority() health.Level { return health.LevelCritical }
// --- sqliteTx ---
type sqliteTx struct{ *sql.Tx }
func (t *sqliteTx) ExecContext(ctx context.Context, q string, args ...any) (sql.Result, error) {
return t.Tx.ExecContext(ctx, q, args...)
}
func (t *sqliteTx) QueryContext(ctx context.Context, q string, args ...any) (*sql.Rows, error) {
return t.Tx.QueryContext(ctx, q, args...)
}
func (t *sqliteTx) QueryRowContext(ctx context.Context, q string, args ...any) *sql.Row {
return t.Tx.QueryRowContext(ctx, q, args...)
}
func (t *sqliteTx) Commit() error { return t.Tx.Commit() }
func (t *sqliteTx) Rollback() error { return t.Tx.Rollback() }
// --- UnitOfWork ---
type unitOfWork struct {
logger logz.Logger
client Client
writeMu *sync.Mutex
}
// NewUnitOfWork returns a UnitOfWork backed by the given client.
// If client is a *sqliteComponent, the write mutex is used to serialise transactions.
func NewUnitOfWork(logger logz.Logger, client Client) UnitOfWork {
var mu *sync.Mutex
if sc, ok := client.(*sqliteComponent); ok {
mu = &sc.writeMu
}
return &unitOfWork{logger: logger, client: client, writeMu: mu}
}
func (u *unitOfWork) Do(ctx context.Context, fn func(ctx context.Context) error) error {
if u.writeMu != nil {
u.writeMu.Lock()
defer u.writeMu.Unlock()
}
tx, err := u.client.Begin(ctx)
if err != nil {
return fmt.Errorf("sqlite: begin transaction: %w", err)
}
ctx = context.WithValue(ctx, ctxTxKey{}, tx)
if err := fn(ctx); err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
u.logger.Error("sqlite: rollback failed", rbErr)
}
return err
}
return tx.Commit()
}

270
sqlite_test.go Normal file
View File

@@ -0,0 +1,270 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"sync"
"testing"
"code.nochebuena.dev/go/health"
"code.nochebuena.dev/go/logz"
"code.nochebuena.dev/go/xerrors"
)
func newLogger() logz.Logger { return logz.New(logz.Options{}) }
func newMemDB(t *testing.T) Component {
t.Helper()
c := New(newLogger(), Config{Path: ":memory:", MaxOpenConns: 1, MaxIdleConns: 1,
Pragmas: "?_fk=true"})
if err := c.OnInit(); err != nil {
t.Fatalf("OnInit: %v", err)
}
return c
}
// --- New / name / priority ---
func TestNew(t *testing.T) {
if New(newLogger(), Config{Path: ":memory:"}) == nil {
t.Fatal("New returned nil")
}
}
func TestComponent_Name(t *testing.T) {
c := New(newLogger(), Config{Path: ":memory:"}).(health.Checkable)
if c.Name() != "sqlite" {
t.Errorf("want sqlite, got %s", c.Name())
}
}
func TestComponent_Priority(t *testing.T) {
c := New(newLogger(), Config{Path: ":memory:"}).(health.Checkable)
if c.Priority() != health.LevelCritical {
t.Error("Priority() != LevelCritical")
}
}
func TestComponent_OnStop_NilDB(t *testing.T) {
c := &sqliteComponent{logger: newLogger()}
if err := c.OnStop(); err != nil {
t.Errorf("OnStop with nil db: %v", err)
}
}
func TestComponent_FullLifecycle(t *testing.T) {
c := New(newLogger(), Config{Path: ":memory:", Pragmas: ""})
if err := c.OnInit(); err != nil {
t.Fatalf("OnInit: %v", err)
}
if err := c.OnStart(); err != nil {
t.Fatalf("OnStart: %v", err)
}
if err := c.OnStop(); err != nil {
t.Fatalf("OnStop: %v", err)
}
}
// --- Exec / Query / QueryRow ---
func TestComponent_Exec(t *testing.T) {
c := newMemDB(t)
exec := c.GetExecutor(context.Background())
_, err := exec.ExecContext(context.Background(),
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
if err != nil {
t.Fatalf("create table: %v", err)
}
res, err := exec.ExecContext(context.Background(), "INSERT INTO t VALUES (1, 'hello')")
if err != nil {
t.Fatalf("insert: %v", err)
}
n, _ := res.RowsAffected()
if n != 1 {
t.Errorf("want 1 row affected, got %d", n)
}
}
func TestComponent_Query(t *testing.T) {
c := newMemDB(t)
exec := c.GetExecutor(context.Background())
_, _ = exec.ExecContext(context.Background(), "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
_, _ = exec.ExecContext(context.Background(), "INSERT INTO t VALUES (1, 'hello')")
rows, err := exec.QueryContext(context.Background(), "SELECT name FROM t")
if err != nil {
t.Fatalf("query: %v", err)
}
defer rows.Close()
if !rows.Next() {
t.Fatal("expected a row")
}
var name string
if err := rows.Scan(&name); err != nil {
t.Fatalf("scan: %v", err)
}
if name != "hello" {
t.Errorf("want hello, got %s", name)
}
}
func TestComponent_QueryRow(t *testing.T) {
c := newMemDB(t)
exec := c.GetExecutor(context.Background())
_, _ = exec.ExecContext(context.Background(), "CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)")
_, _ = exec.ExecContext(context.Background(), "INSERT INTO t VALUES (1, 'world')")
var val string
if err := exec.QueryRowContext(context.Background(), "SELECT val FROM t WHERE id=1").Scan(&val); err != nil {
t.Fatalf("scan: %v", err)
}
if val != "world" {
t.Errorf("want world, got %s", val)
}
}
// --- HandleError ---
func TestHandleError_Nil(t *testing.T) {
if err := HandleError(nil); err != nil {
t.Errorf("want nil, got %v", err)
}
}
func TestHandleError_NoRows(t *testing.T) {
assertCode(t, HandleError(sql.ErrNoRows), xerrors.ErrNotFound)
}
func TestHandleError_Generic(t *testing.T) {
assertCode(t, HandleError(errors.New("oops")), xerrors.ErrInternal)
}
func TestHandleError_UniqueConstraint(t *testing.T) {
c := newMemDB(t)
exec := c.GetExecutor(context.Background())
_, _ = exec.ExecContext(context.Background(), "CREATE TABLE t (id INTEGER PRIMARY KEY)")
_, _ = exec.ExecContext(context.Background(), "INSERT INTO t VALUES (1)")
_, err := exec.ExecContext(context.Background(), "INSERT INTO t VALUES (1)")
if err == nil {
t.Fatal("expected unique constraint error")
}
mapped := HandleError(err)
assertCode(t, mapped, xerrors.ErrAlreadyExists)
}
func TestHandleError_ForeignKey(t *testing.T) {
c := newMemDB(t)
exec := c.GetExecutor(context.Background())
_, _ = exec.ExecContext(context.Background(),
"CREATE TABLE parent (id INTEGER PRIMARY KEY)")
_, _ = exec.ExecContext(context.Background(),
"CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id))")
_, err := exec.ExecContext(context.Background(),
"INSERT INTO child VALUES (1, 999)")
if err == nil {
t.Fatal("expected foreign key error")
}
mapped := HandleError(err)
assertCode(t, mapped, xerrors.ErrInvalidInput)
}
// --- UnitOfWork ---
func TestUnitOfWork_Commit(t *testing.T) {
c := newMemDB(t)
exec := c.GetExecutor(context.Background())
_, _ = exec.ExecContext(context.Background(), "CREATE TABLE t (id INTEGER PRIMARY KEY)")
uow := NewUnitOfWork(newLogger(), c)
err := uow.Do(context.Background(), func(ctx context.Context) error {
e := c.GetExecutor(ctx)
_, err := e.ExecContext(ctx, "INSERT INTO t VALUES (42)")
return err
})
if err != nil {
t.Fatalf("Do: %v", err)
}
// verify committed
var id int
_ = exec.QueryRowContext(context.Background(), "SELECT id FROM t WHERE id=42").Scan(&id)
if id != 42 {
t.Error("row not committed")
}
}
func TestUnitOfWork_Rollback(t *testing.T) {
c := newMemDB(t)
exec := c.GetExecutor(context.Background())
_, _ = exec.ExecContext(context.Background(), "CREATE TABLE t (id INTEGER PRIMARY KEY)")
uow := NewUnitOfWork(newLogger(), c)
_ = uow.Do(context.Background(), func(ctx context.Context) error {
e := c.GetExecutor(ctx)
_, _ = e.ExecContext(ctx, "INSERT INTO t VALUES (99)")
return errors.New("abort")
})
var count int
_ = exec.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM t").Scan(&count)
if count != 0 {
t.Error("row should have been rolled back")
}
}
func TestUnitOfWork_InjectsExecutor(t *testing.T) {
c := newMemDB(t)
uow := NewUnitOfWork(newLogger(), c)
var got Executor
_ = uow.Do(context.Background(), func(ctx context.Context) error {
got = c.GetExecutor(ctx)
return nil
})
if got == nil {
t.Error("GetExecutor should return a Tx inside Do")
}
// Outside Do, should return the pool
pool := c.GetExecutor(context.Background())
if got == pool {
t.Error("inside Do should be a Tx, not the pool")
}
}
func TestUnitOfWork_WriteMutex(t *testing.T) {
c := newMemDB(t)
exec := c.GetExecutor(context.Background())
_, _ = exec.ExecContext(context.Background(), "CREATE TABLE t (id INTEGER PRIMARY KEY)")
uow := NewUnitOfWork(newLogger(), c)
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
i := i
go func() {
defer wg.Done()
_ = uow.Do(context.Background(), func(ctx context.Context) error {
e := c.GetExecutor(ctx)
_, err := e.ExecContext(ctx, "INSERT INTO t VALUES (?)", i)
return err
})
}()
}
wg.Wait()
var count int
_ = exec.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM t").Scan(&count)
if count != 5 {
t.Errorf("expected 5 rows, got %d", count)
}
}
// --- helpers ---
func assertCode(t *testing.T, err error, want xerrors.Code) {
t.Helper()
var xe *xerrors.Err
if !errors.As(err, &xe) {
t.Fatalf("expected *xerrors.Err, got %T: %v", err, err)
}
if xe.Code() != want {
t.Errorf("want %s, got %s", want, xe.Code())
}
}