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

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`).