commit 237cba9bada84a7a3119dedcf5e87342aa522fba Author: Rene Nochebuena Date: Thu Mar 19 13:25:31 2026 +0000 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/ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..54f5aae --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -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" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..221da82 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0b89939 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..38083ff --- /dev/null +++ b/CLAUDE.md @@ -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`). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b33b48 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6d1d648 --- /dev/null +++ b/README.md @@ -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`. diff --git a/compliance_test.go b/compliance_test.go new file mode 100644 index 0000000..848baae --- /dev/null +++ b/compliance_test.go @@ -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:"}) diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..2071857 --- /dev/null +++ b/doc.go @@ -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 diff --git a/docs/adr/ADR-001-modernc-pure-go-driver.md b/docs/adr/ADR-001-modernc-pure-go-driver.md new file mode 100644 index 0000000..a2d5bd3 --- /dev/null +++ b/docs/adr/ADR-001-modernc-pure-go-driver.md @@ -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`. diff --git a/docs/adr/ADR-002-write-mutex-busy-prevention.md b/docs/adr/ADR-002-write-mutex-busy-prevention.md new file mode 100644 index 0000000..f5c44db --- /dev/null +++ b/docs/adr/ADR-002-write-mutex-busy-prevention.md @@ -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. diff --git a/docs/adr/ADR-003-fk-enforcement-pragma.md b/docs/adr/ADR-003-fk-enforcement-pragma.md new file mode 100644 index 0000000..1479b2f --- /dev/null +++ b/docs/adr/ADR-003-fk-enforcement-pragma.md @@ -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`. diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..7bb5583 --- /dev/null +++ b/errors.go @@ -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) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..826772b --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..be07674 --- /dev/null +++ b/go.sum @@ -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= diff --git a/sqlite.go b/sqlite.go new file mode 100644 index 0000000..2f76aa9 --- /dev/null +++ b/sqlite.go @@ -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() +} diff --git a/sqlite_test.go b/sqlite_test.go new file mode 100644 index 0000000..3f9385e --- /dev/null +++ b/sqlite_test.go @@ -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()) + } +}