Files
sqlite/CLAUDE.md

92 lines
3.9 KiB
Markdown
Raw Normal View History

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