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:
43
docs/adr/ADR-001-modernc-pure-go-driver.md
Normal file
43
docs/adr/ADR-001-modernc-pure-go-driver.md
Normal 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`.
|
||||
53
docs/adr/ADR-002-write-mutex-busy-prevention.md
Normal file
53
docs/adr/ADR-002-write-mutex-busy-prevention.md
Normal 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.
|
||||
54
docs/adr/ADR-003-fk-enforcement-pragma.md
Normal file
54
docs/adr/ADR-003-fk-enforcement-pragma.md
Normal 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`.
|
||||
Reference in New Issue
Block a user