Author SHA1 Message Date
end d8f3176053 docs: reconcile lifecycle delivery with PR 13
Documentation / docs (pull_request) Successful in 23s
ci / check (pull_request) Successful in 1m29s
2026-10-27 12:15:32 -07:00
end f4abb626c6 fix(lifecycle): retain parent cancellation at every shutdown return
Documentation / docs (pull_request) Successful in 16s
ci / check (pull_request) Successful in 1m33s
2026-10-27 12:11:59 -07:00
end 64c7e6b65d fix(lifecycle): preserve mixed shutdown error identity 2026-10-27 12:04:13 -07:00
end b9a5fa8bd8 feat(lifecycle): supervise modules and managed workers 2026-10-27 11:58:24 -07:00
end ba9b29697a docs: clarify proposed lifecycle cancellation contracts 2026-10-27 11:47:56 -07:00
end cb93d33b9c docs: propose lifecycle and managed worker contract 2026-10-27 11:42:26 -07:00
26 changed files with 1 additions and 2810 deletions

No files matched your search

-16
View File
@@ -15,17 +15,6 @@ on:
jobs:
check:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_PASSWORD: page-test
POSTGRES_DB: page_test
options: >-
--health-cmd "pg_isready -U postgres -d page_test"
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- uses: actions/checkout@v7.0.1
with:
@@ -37,11 +26,6 @@ jobs:
run: test -z "$(gofmt -l .)"
- run: go vet ./...
- run: go test -race ./...
env:
CI: "true"
MIGRATE_POSTGRES_DSN: postgres://postgres:page-test@postgres:5432/page_test?sslmode=disable
PAGE_POSTGRES_DSN: postgres://postgres:page-test@postgres:5432/page_test?sslmode=disable
PAGE_REQUIRE_POSTGRES: "1"
- name: Require issue URLs in TODO comments
run: |
find . -type d \( -name .git -o -name node_modules -o -name vendor -o -name generated -o -name gen -o -name target -o -name dist -o -name build -o -name .svelte-kit -o -name .next \) -prune -o -type f \( -name '*.go' -o -name '*.ts' -o -name '*.tsx' -o -name '*.js' -o -name '*.svelte' -o -name '*.rs' -o -name '*.sql' -o -name '*.sh' -o -name '*.proto' -o -name '*.graphql' -o -name '*.yml' -o -name '*.yaml' -o -name '*.toml' \) -exec awk 'match($0, "((^|[^:])[/][/]|[/][*]|(^|[[:space:]])(#|--)|^[[:space:]]*([*]|<!--))") { comment=substr($0, RSTART + RLENGTH); todo=index(comment, "TO" "DO"); if (todo && substr(comment, todo + 4) !~ "https?:[/][/]") { print FILENAME ":" FNR ":" $0; bad=1 } } END { exit bad }' {} + # shortcut: line-based scan can flag comment-like strings and misses multiline issue links, use a parser when these need accurate coverage
+1 -40
View File
@@ -1,11 +1,6 @@
# corestack go
Shared Go building blocks. Includes `docs`, a checker for architecture decision records (ADRs) and implementation plans; `page`, Bob keyset pagination; and `lifecycle`, a service and worker coordinator.
## migrate
[`migrate`](migrate/README.md) configures Goose providers for embedded SQL and Go
migrations, with structured logging and Postgres startup locking.
Shared Go building blocks. Includes `docs`, a checker for architecture decision records (ADRs) and implementation plans, and `lifecycle`, a service and worker coordinator.
## docs
@@ -25,19 +20,6 @@ docs check [--root DIR] # prints path:line: messa
The format is specified in [docs/docs-format.md](docs/docs-format.md); that document is the complete rule set. Exit status is `0` when every record is valid, `1` when problems are reported and `2` for usage errors.
## page
[`page`](page/page.go) provides transport-neutral Bob keyset pagination with typed
items, mixed-direction sorts, opaque typed cursors, and a separate opt-in count.
Pass a trusted Sort and a typed row key in the same column order; sort columns
must be non-null and end in a unique column. See the [runnable example](page/example_test.go)
and [cursor decision](docs/adr/20261027T184351001Z-preserve-typed-sort-values-in-versioned-page-cursors.md).
SQLite runs in-process in the normal test suite (requires CGO). PostgreSQL runs in
CI; locally set `PAGE_POSTGRES_DSN` to an isolated test database to run the same
flow. Set `PAGE_REQUIRE_POSTGRES=1` to fail rather than skip when the DSN is absent.
MySQL mod composition compiles; its runtime behavior has not been verified.
## lifecycle
`go.auvem.com/corestack/lifecycle` sets up modules in registration order, runs
@@ -63,27 +45,6 @@ for at least two signals, and subscription cleanup. Nil signals disable receptio
There are no signal subscriptions, process exits, external dependencies or CLI
integration in this package.
## lifecycle/cli
`go.auvem.com/corestack/lifecycle/cli` adapts native `urfave/cli/v3` commands.
Bind `Serve(config, modules)` to run selected services, or `Action(config, modules,
fn)` for a one-off with selected setup/cleanup and module services suppressed.
See the [executable serve/inspect example](lifecycle/cli/example_test.go).
`Run(ctx, cliRoot, args)` accepts argv including the program name and returns the
exit code and inspectable error: success/help/graceful first signal is 0, usage is
2, and runtime/lifecycle failure is 1. Runtime failures dominate mixed errors.
Run prints diagnostics without exiting; the caller owns process exit. Nil signals
subscribe to SIGINT/SIGTERM during adapted actions; injected channels stay caller
owned. The private lifecycle App does not expose managed-worker admission.
Use fresh command trees and leave `ExitErrHandler`, `OnUsageError` and
`CommandNotFound` unset; Run owns those hooks. Other native hooks run outside the
adapted lifecycle action. Native environment/custom validation and generated-help
parse failures can return 1 with native diagnostics; custom pre-lifecycle
validators may return `urfavecli.Exit(err, 2)` for usage. Help and flags use native
urfave/cli declarations, without a configuration or environment facade.
## Status
No support is guaranteed. Breaking changes, including to the format rules, may happen before 1.0 and are listed in the release notes.
@@ -1,91 +0,0 @@
---
title: Preserve supported value types in versioned page cursors
date: 2026-10-27
status: accepted
implementation: implemented
summary: Pair a plain sort with a typed row key function and preserve supported concrete values with tagged cursors.
tags:
- pagination
- cursors
supersedes: []
superseded_by: []
related:
- /docs/adr/20261025T214616275Z-version-light-core-packages-in-the-root-module-and-nest-heavy-dependency-modules.md
tracking:
- https://gitea.auvem.com/corestack/go/issues/6
implemented_by:
- https://gitea.auvem.com/corestack/go/pulls/15
---
# Preserve supported value types in versioned page cursors
## Context and Problem Statement
[Issue 6](https://gitea.auvem.com/corestack/go/issues/6) requires transport-neutral, non-null keyset pagination through Bob query mods. Cursors may be retained by callers, so their format is a compatibility contract. The owner requires two plain query inputs: a Sort and a typed `func(T) []any` returning column values in sort order. No reflection, struct tags, field lookup or per-column getters may connect rows to columns. Future code generation may produce both values; v0 must work with handwritten values.
Type preservation here means retaining each supported concrete value across encoding/decoding and passing that value to Bob's argument binder. It does not mean statically proving the agreement between independent Sort and key values. JSON decoded into untagged `any` would lose integer precision and concrete types.
## Considered Options
* Plain Sort/key values with explicit tagged scalar encoding: meets the requested API and preserves a documented finite set without reflection.
* Untagged JSON decoded into `[]any`: smaller, but numbers become float64 and concrete type information disappears.
* Per-column generic extractors or caller-defined generic cursor tuples: can tie values to columns more strongly, but do not match the owner's chosen two-value API.
* Reflection, codec registration or arbitrary serialized Go objects: broadens the supported set at the cost of machinery and compatibility obligations not needed for v0.
## Decision Outcome
Use plain Sort/key inputs with tagged JSON, wrapped in unpadded base64url (`base64.RawURLEncoding`). The owner accepted this reviewed design on 2026-10-27, including the two-input API and continuation flags. [PR 15](https://gitea.auvem.com/corestack/go/pulls/15) delivers the implementation, SQLite/PostgreSQL outcome tests and package example. Local build, vet, race tests with real PostgreSQL, pinned lint, TODO and documentation checks passed; independent Standard review's Minor fixture finding was fixed, and fresh High review of `927594b` found no Blocking, Major or Minor findings. Published-head CI remains a merge-readiness gate reported in the PR; this implementation state does not assert CI success, merge or release.
Sort contains a stable caller-owned ID and an ordered list of columns, each with a Bob expression, direction and unique marker. Reject empty sorts, a missing or invalid UTF-8 Sort ID, nil expressions, invalid directions and a missing unique marker on the final column. Columns have no redundant ID field. The marker asserts that column alone is unique in the selected relation under database collation/comparison semantics, including joins. The library cannot prove schema truth. Composite-only uniqueness is outside the issue's final unique-column contract. Expressions and IDs come from trusted application declarations, never cursor-controlled SQL. The sort ID identifies the full immutable interpretation: ordered expressions, directions, key types, uniqueness and comparison semantics. Changing any of these requires a new ID. The backend selects the expected trusted Sort for the query; a decoded token ID must equal its ID. There is no global registry, descriptor fallback or hash subsystem. Local column metadata remains available for validation and query generation; it is not repeated on the wire.
A typed key function returns values in exactly that order. Check `len(key(row)) == len(sort.Columns)` for every returned row used to assemble a page, including the fetched extra row. A mismatch returns a clear programmer-error `KeyError` with expected/actual lengths; never truncate, zip a prefix or emit a bad cursor. Reject nil key functions. Column/value correspondence remains an explicit caller contract; no field discovery or hidden mapping is introduced.
Cursor v1 contains only version, sort ID and ordered typed values. Each value is exactly a two-element `[typeTag,payloadString]` array. Example decoded JSON:
```json
{"v":1,"sort":"orders/recent/v1","values":[["time","2026-10-27T12:00:00Z"],["int64","9007199254740993"]]}
```
For this exact example, compact JSON is 105 bytes and its unpadded base64url token is 140 bytes; the prior descriptor/object format was 242 and 323 bytes respectively. All tags and payloads are strings. Fixed type tags distinguish `int`, `int8`, `int16`, `int32`, `int64`, `string`, `time` and `uuid`. A type switch encodes exactly these concrete Go types; a fixed switch reconstructs them using range-checked integer parsing, valid UTF-8 strings, `time.Time` via RFC3339Nano, and standard-library `uuid.UUID` via canonical lowercase dashed text. Integer decimal text is canonical and never passes through float64. `int` must fit the consuming platform. Normalize timestamps to UTC before RFC3339Nano encoding, preserving the instant and nanoseconds, not monotonic metadata or named timezone identity; reject years unrepresentable in RFC3339 with a typed `ValueError`. Reject invalid UTF-8 rather than silently replacing bytes.
UUID support specifically means Go 1.27.1's standard `uuid.UUID`: its Parse/String methods support this representation and database/sql's default converter converts it to a string for drivers. Third-party UUID types require explicit caller conversion to this type. Named user-defined scalar types, unsigned integers, floats, booleans, pointers, nullable wrappers, byte slices and arbitrary custom types return a typed `ValueError`; no reflection, implicit coercion, custom binder or registry is proposed. A true Go type alias retains its underlying supported identity. Additional types require an explicit format/support decision. V0 sort columns must be non-null and ordered consistently by WHERE and ORDER BY; callers extract actual stored/scanned values, including timestamp precision.
Apply the same maximum of 16 KiB (16,384 bytes) to both emitted and received tokens, measured after final unpadded base64url encoding, including the full envelope. Check received token bytes before decoding, and decode before constructing a mod. Encoding a key or envelope that cannot fit returns a typed `CursorError` and no partial Page. Reject bad base64/JSON, unknown/duplicate/missing envelope fields (only `v`, `sort` and `values` are allowed, so `columns` is rejected), trailing data, version mismatch, a sort ID unequal to the expected Sort ID, wrong value count, nulls, non-array value entries, pairs of any arity other than two, non-string tags/payloads, unknown tags and invalid/range-overflowing payloads. The outer `values` array must contain exactly `len(sort.Columns)` pairs; the former type/value objects are rejected. An exported `CursorError`, inspectable with `errors.As`, distinguishes malformed, version, sort and unsupported-value failures. Encoding unsupported key values returns `ValueError` identifying the position and type, without leaking values. Keys and token shape are validated before producing a Page; errors return no partial page. Cursors are not authenticated: a well-formed edit to a supported value or tag remains a seek request, not a tamper-detection failure. Without expected type metadata in Sort, the library cannot prove an edited supported tag matches a SQL column; driver/database errors propagate normally. Authorization stays in the query.
Prepared pagination exposes one generic Bob `Mod[Q]` targeting native `AppendWhere(...any)`, `AppendOrder(bob.Expression)` and `SetLimit(any)` methods shared by PostgreSQL, SQLite and MySQL SelectQuery. Use Bob expressions and `clause.OrderDef`, fixed operators/directions, and an integer limit. Prepare validates requests/cursors first because ordinary Bob Apply has no error return. Cursor values only enter `expr.Arg(value)`; Bob's existing `any` boundary retains their concrete dynamic type. No custom SQL renderer is needed. Apply once to a query without ORDER BY, LIMIT, OFFSET or FETCH; callers may append their dynamic/authorization filters but must not override pagination/order.
For `(created DESC, id ASC)`, after means `created < c OR (created = c AND id > i)`, grouped as one WHERE condition. Before reverses each comparison. Backward also reverses each ORDER BY direction, fetches limit+1, trims overflow, then reverses retained rows into canonical order. Page[T] holds Items, StartCursor, EndCursor, HasNext and HasPrevious; cursors describe the canonical first/last returned items, or are empty when no items exist. Deleted anchors remain value boundaries. Writes or changed filters between requests may skip/repeat items; no snapshot is promised.
Forward maps first/after and Backward maps last/before to a transport-neutral Direction, Size and Cursor request. Request rules: zero direction is Forward, zero/absent size uses configured default, negative sizes and invalid directions fail, oversized sizes clamp to maximum; require `1 <= default <= maximum` and overflow-safe limit+1. Empty cursor means no anchor. The owner selected continuation semantics: forward HasNext=overflow and HasPrevious=nonempty after; backward HasPrevious=overflow and HasNext=nonempty before. This applies even to empty/stale results. An anchor-derived opposite flag does not prove rows exist; no extra existence probes are required.
Bob v0.50.0 distinguishes generated column references from generated typed WHERE helpers. Generated references such as `models.Orders.Columns.CreatedAt` (schematic; this repository has no generated models) satisfy `bob.Expression` and can replace handwritten `expr.Quote` identifiers in Sort. Their generated per-table column type embeds the dialect Expression and has no Go value-type parameter: neither reference nor `expr.Quote` statically links that expression to a `time.Time` key. In contrast, generated `models.SelectWhere.Orders.CreatedAt` uses `WhereMod[Q, time.Time]`; its `EQ`/`LT`/`GT` arguments must be assignable to `time.Time`, and Q must satisfy Bob's Filterable constraint. These helpers produce WHERE mods, not sortable column expressions, and expose no public expression accessor. They cannot be substituted directly for Sort expressions or consume a heterogeneous `[]any` element without recovering the expected concrete type. The chosen API preserves the concrete dynamic values through the codec/binder and checks lengths at runtime; it promises no compile-time column/key type or order agreement. The owner deferred stronger compile-time field correspondence to [code generation issue 11](https://gitea.auvem.com/corestack/go/issues/11); no code generation or stronger typed abstraction is implemented here.
A concrete call shape using schematic Bob-generated references is (this repository has no generated models):
```go
sort := page.Sort{ID: "orders/recent/v1", Columns: []page.Column{
{Expression: models.Orders.Columns.CreatedAt, Direction: page.Desc},
{Expression: models.Orders.Columns.ID, Direction: page.Asc, Unique: true},
}}
key := func(o Order) []any { return []any{o.CreatedAt, o.ID} } // time.Time, int64
prepared, err := page.Prepare(sort, key, page.Request{
Direction: page.Forward, Size: 25, Cursor: after,
}, page.Limits{Default: 25, Maximum: 100})
// Handle err before applying the mod to a normal Bob PostgreSQL SELECT q.
q.Apply(page.Mod[*pgdialect.SelectQuery](prepared))
q.Apply(sm.Where(psql.Quote("orders", "tenant_id").EQ(psql.Arg(tenantID))))
// Execute q with Bob's normal Order mapper, obtaining up to 26 []Order rows.
result, err := prepared.Page(rows)
```
Backward changes Direction and supplies the before cursor. `pgdialect`/`sm` are Bob's PostgreSQL dialect/select-mod packages; corresponding SQLite/MySQL select types use the same mod. `Page` retains the typed `Order` key function; `o.ID` is restored as int64, including above 2^53. A separate opt-in `Count(ctx, executor, callerBuiltCountQuery)` executes an explicitly counting, authorized unpaginated Bob query. It never guesses how to strip pagination or count grouped/distinct caller relations.
Bob v0.50.0 is source-feasible for all three dialects, with two reported limitations: generic `mods.OrderBy` expects a different AppendOrder signature from actual selects (native ModFunc matches); MySQL's select writer discards LIMIT expression arguments (use the normal native integer LIMIT API, while cursor WHERE values remain bound). SQLite runs in-process in the normal test suite. PostgreSQL uses the same pagination outcome suite and a required CI service container. MySQL has compile-time native mod composition coverage; runtime behavior remains unverified. Bob belongs in the root module under the accepted module-boundary ADR; no UUID dependency is needed. No persistence, writers, migrations, secrets, workers or timers are introduced.
### Consequences
* The owner-chosen Sort/key separation is simple and code-generation-ready, with runtime length errors instead of compile-time column/value agreement. Tests and package examples must demonstrate correct pairing and disagreement failures.
* Explicit tags retain supported concrete types without reflection, precision loss or a registry. Unsupported custom/third-party types require explicit caller conversion or a later support decision.
* Version and sort IDs become compatibility commitments; changes may deliberately reject retained cursors. No legacy decoder is needed before another version exists.
* Cursors expose values and permit valid edits; queries, counts and every request must preserve authorization. Concurrent writes have no snapshot guarantee. Nothing runs when unused.
* Implementation tests must cover tagged round trips (including large integers, time with an unusual second-offset timezone, and UUID), unrepresentable timestamp years, encoded-token boundaries at and above 16 KiB for both emission and receipt, oversized-string rejection with a typed error and no partial Page, unsupported/malformed/tampered/mismatched cursors (including removed `columns` fields, object-shaped values, incorrect pair arity and non-string entries), Sort/key disagreement, mixed-direction ties, both directions, boundaries and size clamping, plus a package Example of the key function. Execute our pagination and compliant binding/composition tests on in-process SQLite and PostgreSQL CI; do not assert Bob SQL formatting/rendering guarantees. MySQL remains source-feasible until runtime coverage exists.
@@ -1,103 +0,0 @@
---
title: "Run native urfave/cli commands inside selected lifecycle modules"
date: 2026-10-28
status: accepted
implementation: in-progress
summary: Use native urfave/cli commands and flags with thin lifecycle action adapters and an explicit exit boundary.
tags:
- lifecycle
- cli
supersedes: []
superseded_by: []
related:
- /docs/adr/20261025T214616275Z-version-light-core-packages-in-the-root-module-and-nest-heavy-dependency-modules.md
- /docs/adr/20261027T184038082Z-supervise-module-lifecycle-and-managed-workers-within-one-shutdown-budget.md
tracking:
- https://gitea.auvem.com/corestack/go/issues/8
implemented_by: []
---
# Run native urfave/cli commands inside selected lifecycle modules
## Context and Problem Statement
[Issue 8](https://gitea.auvem.com/corestack/go/issues/8) needs command-specific resource setup, signal-aware work, cleanup on completion, automatic help and distinct usage failures. The owner selected `github.com/urfave/cli/v3` v3.14.0 for every command, subcommand, flag and help surface; callers declare native flags. One-offs skip module services; serve runs them. The root-module boundary and lifecycle ordering, cancellation and shared shutdown budget already stand. This clean-room implementation uses no go-toolkit/appcli source.
The owner accepted the three-function API, adapter-owned signal subscription, private invocation App boundary, native validation limitations and generated-help diagnostic/exit exceptions after independent review. The corrected decision was verified at [commit 1b756511](https://gitea.auvem.com/corestack/go/commit/1b7565113c20757c8a049fe44e9a035cf72fd4b7), with R1–R3 addressed and no blocking or major findings; the implementation dispatch explicitly records owner acceptance. Implementation remains in progress pending independent implementation reviews and publication.
## Considered Options
* Native urfave/cli command tree with action adapters: shares native flags/help and existing lifecycle coordination; adds only selection, signal ownership and exit classification.
* A new command/flag schema wrapping urfave/cli: could centralize declarations, but duplicates its API and increases conversion and maintenance obligations without a requested capability.
* Standard-library flag parsing: smaller dependency graph, but would require our own subcommands/help and contradict the owner's dependency choice.
* Per-binary urfave/cli and lifecycle wiring: no library API to maintain, but repeats signal cleanup, one-off service suppression and exit policy in each consumer.
## Decision Outcome
Choose the first option in root package `go.auvem.com/corestack/lifecycle/cli`, with no lifecycle core change. Import urfave/cli as `urfavecli` to distinguish its native types from this package. The entire new public surface is:
```go
// Run executes a caller-owned native tree and returns its process code and error.
// It prints diagnostics but never exits the process; args includes argv[0].
func Run(ctx context.Context, cliRoot *urfavecli.Command, args []string) (int, error)
// Serve returns an action running exactly these modules, including their services.
func Serve(config lifecycle.Config, modules []lifecycle.Module) urfavecli.ActionFunc
// Action returns a one-off action using exactly these modules, without services.
func Action(config lifecycle.Config, modules []lifecycle.Module,
fn urfavecli.ActionFunc) urfavecli.ActionFunc
```
The functions capture module declarations in their supplied order; callers list every needed module explicitly. There is no registry, name lookup, implicit dependency expansion or DAG. Callbacks close over caller-owned resource handles populated by Setup; the one-off receives the selected native command for parsed flag access. There is no public App, worker method, configuration framework or flag/environment facade. Empty module lists are valid. Nil one-off functions, nil Run context/root, invalid lifecycle config and invalid selected modules are runtime errors before resource acquisition; validate every selected declaration before starting setup. Bindings do not start work until invoked. A command tree and its mutable resources have one execution owner; construct fresh trees for independent runs and do not mutate captured declarations concurrently.
`Serve` constructs a fresh lifecycle App, registers selected modules and calls its Run. `Action` copies selected modules with Run cleared, then registers an internal final module with no-op Setup/Teardown and the command function as Run. Choose its nonempty name by checking all selected names and varying a private suffix until unused; no public module name is reserved. All selected setups precede work; returning from the one-off leaves no selected services to keep the App alive. Lifecycle closes admission, cancels the callback context and performs reverse cleanup. No success sentinel, cancellation-error filtering, parent-context cancellation or second shutdown timer is added. Action failures are lifecycle failures and preserve their chains. Core setup failure/partial cleanup, parent cancellation, second signal, abandonment and joined-error behavior remain authoritative. Selected module services cannot run during one-offs. The invocation App is private: modules or callbacks that need its App.Go cannot obtain it through these adapters and are incompatible with this narrow API. Capturing another App does not confer this invocation's supervision. Supported modules manage resources and optional services without that access; raw goroutines remain caller-owned and outside managed-worker completion. Exposing worker access would require a separate owner decision.
Each invoked adapter subscribes to SIGINT/SIGTERM using a private channel buffered for two signals when Config.Signals is nil, and defers signal.Stop. With a supplied channel, subscription, filtering and cleanup remain caller-owned as in lifecycle; a closed supplied channel disables signals. The adapter never closes either channel. No subscription or relay goroutine is needed during parsing/help. Lifecycle alone consumes/counts signals and cancels command work; do not also use signal.NotifyContext, which would turn graceful Ctrl-C into parent cancellation. After adapter return, subscription cleanup restores the prior process signal behavior; the caller owns process exit and abandoned resources.
Run owns the tree's ExitErrHandler, OnUsageError and CommandNotFound hooks, rejecting conflicting caller hooks as runtime configuration errors rather than silently overriding them. It installs a non-exiting root handler and usage/not-found handling on the caller-declared tree before urfave/cli runs; no urfave/cli package globals are changed. CommandNotFound returns void, so its callback records a local usage cause for this invocation; Run combines that cause with the native result even when urfave/cli returns nil, retaining any simultaneous runtime error. Native actions, Before/After, ArgValidator, flags, arguments and help templates remain caller declarations. Hooks outside the lifecycle action are outside its signal/shutdown/resource guarantee: use them for prompt validation, and put resource work inside selected modules or the adapted action. Leave group Actions unset for native group help/unknown-command handling; positional arguments on actionable leaves follow native urfave/cli declarations and validators.
The exit mapping is:
| Result | Code |
| --- | --- |
| No returned or locally recorded error, including valid help, empty group help, normal completion or graceful first signal with successful cleanup | 0 |
| Lifecycle setup/action/service/teardown error, parent cancellation, budget/second-signal abandonment, or other unclassified native error | 1 |
| Native OnUsageError path on caller-declared commands (invalid/missing flag value, required flag/argument, mutually exclusive flags, native argument parsing), or locally recorded unknown command/help topic | 2 |
| Parse failure in a urfave/cli-generated help command, including its aliases | 1 |
| Explicit `urfavecli.Exit(err, 2)` from pre-lifecycle caller validation | 2 |
A lifecycle failure always remains code 1, including action-returned ExitCoder values and joins containing cleanup failures; do not infer success by matching context.Canceled. Non-2 ExitCoder values outside lifecycle normalize to 1; an error never becomes success through ExitCode 0. Use private provenance wrappers for usage and lifecycle failures. Native After hooks may aggregate failures in urfavecli.MultiError, whose Errors() children are invisible to standard traversal. A small private bridge traverses both standard Unwrap forms and native MultiError.Errors(), recursively exposing children through Unwrap() []error while also retaining each original error/wrapper as a traversal child. It delegates Error() to the original rather than duplicating its text. Do not flatten or replace native wrappers: errors.Is must still match their identity and errors.As must still find their types, alongside nested causes and private markers.
Classify every semantic branch, carrying its usage/lifecycle provenance through wrappers and aggregates; aggregate containers are not extra unclassified failures. Any lifecycle branch or unclassified runtime branch dominates usage branches, including an Exit(err, 2) action plus failing After, usage plus runtime After, and nested parent/leaf After failures. Looking up only the first ExitCoder or marker is insufficient. Run returns the traversable error after cleanup and emits at most one adapter-owned final diagnostic to cliRoot.ErrWriter. This is not a single total diagnostic guarantee: native help parse handling and caller hooks may also print. Usage hooks request relevant native help through urfave/cli's Show*Help functions. Native help goes to cliRoot.Writer and diagnostics to cliRoot.ErrWriter, defaulting to stdout/stderr; callers may supply writers there. No logger/writer registry or logging policy is added. Rendering retains urfave/cli's own writer-error behavior, without promising stronger delivery guarantees.
Accepted owner exception: urfave/cli creates generated help nodes lazily after the pre-run hook walk; their parse failures bypass OnUsageError. Accept code 1 with native diagnostics for malformed generated help invocations (root, nested and aliases); Run's final diagnostic may repeat the native error. Valid help remains code 0, and unknown help topics remain code 2 through the recorded CommandNotFound cause. Uniform code 2 and one total diagnostic would require separate feasibility evidence and owner approval. Do not initialize by reparsing, copy native parser/help code, or detect diagnostics through error strings.
urfave/cli v3.14.0 does not expose all validation errors through OnUsageError: invalid environment values in Flag.PostParse bypass it, as do ArgValidator and flag-action errors. Unclassified errors from those paths are code 1. Callers needing code 2 from custom validators return `urfavecli.Exit(err, 2)` before lifecycle entry; native environment-source failures remain code 1 unless the caller validates explicitly. Do not inspect error strings, reparse arguments, wrap every flag type or promise universal parse/runtime discrimination.
A schematic consumer uses ordinary Go wiring; the implementation Example must be executable and show both modes:
```go
cfg := lifecycle.Config{Logger: logger}
cliRoot := &urfavecli.Command{Name: "app", Commands: []*urfavecli.Command{
{Name: "serve", Action: cli.Serve(cfg, []lifecycle.Module{database, server})},
{Name: "inspect", Flags: []urfavecli.Flag{&urfavecli.BoolFlag{Name: "verbose"}},
Action: cli.Action(cfg, []lifecycle.Module{database},
func(ctx context.Context, cmd *urfavecli.Command) error {
return inspect(ctx, db, cmd.Bool("verbose"))
})},
}}
code, err := cli.Run(context.Background(), cliRoot, os.Args)
_ = err // already diagnosed; available for inspection by an embedding caller
os.Exit(code) // caller-owned process boundary, after all adapter cleanup
```
The dependency belongs in root go.mod/go.sum under the accepted boundary ADR. At v3.14.0 `go list -deps` reports no nonstandard production package except urfave/cli itself; its [module requirements](https://github.com/urfave/cli/blob/v3.14.0/go.mod) are for tests, with YAML already present here. This is a small CLI dependency, not a reason to introduce a nested module. The native exported types deliberately couple consumers to urfave/cli v3; we own integration tests when upgrading it.
### Consequences
* Scope is one PR, approximately 200–350 production lines plus focused tests and docs: new lifecycle/cli/cli.go (package/public comments and adapters), cli_test.go, example_test.go, root go.mod/go.sum and this ADR. No existing callers, fixtures, lifecycle core, migration package, release/config framework or GUI changes are anticipated. The reusable migration command belongs exclusively to issue 9.
* There are no persistence writers, migrations, remote services, new auth boundaries or persistent workers. Active work consists of lifecycle-owned callbacks, its existing shutdown deadline, and a temporary OS signal subscription only while an adapted action executes. Callers own command output, cooperative callbacks, partial setup cleanup and eventual process exit; importing the package starts nothing.
* Tests must prove selected-only setup and registration/reverse order, no one-off services, teardown on success/failure, serve services, fixed code mapping with preserved typed causes and mixed cleanup errors, native flags/subcommands/help without module setup, invalid environment classification, injected first/second signals, parent cancellation and the shared deadline. Test with injected channels and explicitly release blocked goroutines; do not send real signals. Exercise native callbacks returning ExitCoder without process exit. Add focused action/lifecycle-plus-After, usage-plus-runtime-After and nested After tests asserting code 1, original wrapper identity/types and all child causes. Cover root/nested generated help parse errors and aliases (code 1/native diagnostics), valid help (code 0), unknown commands/help topics returning native nil (code 2), and synthetic-name collisions without reserving names.
* Native [aggregation and parse handling](https://github.com/urfave/cli/blob/v3.14.0/command_run.go), [MultiError API](https://github.com/urfave/cli/blob/v3.14.0/errors.go) and [lazy help setup](https://github.com/urfave/cli/blob/v3.14.0/command_setup.go) establish integration obligations; they do not alone verify adapter behavior. Retain these cases in upgrade tests. The simpler alternative is narrower hook support; retaining native hooks with a small traversal bridge preserves the requested cause guarantees. Accepting the generated-help exception avoids a second parser/help subsystem.
* Independent ADR review and owner acceptance are complete. Implementation is in progress: the three adapters, focused tests and executable serve/inspect example are present. Standard and fresh high independent implementation reviews, repository checks and reconciliation against the latest target branch precede publication; records were reconciled against latest `origin/main` at `f0439000cbed0ffdccad0521748bcf1738af4a0c` plus this branch, with no PR, CI success, merge or release recorded yet.
-30
View File
@@ -3,36 +3,6 @@ module go.auvem.com/corestack
go 1.27.1
require (
github.com/jackc/pgx/v5 v5.11.0
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.32
github.com/pressly/goose/v3 v3.29.0
github.com/stephenafamo/bob v0.50.0
github.com/stephenafamo/scan v0.9.0
github.com/urfave/cli/v3 v3.14.0
github.com/yuin/goldmark v1.8.6
go.yaml.in/yaml/v3 v3.0.5
modernc.org/sqlite v1.60.1
)
require (
github.com/aarondl/opt v0.0.0-20250607033636-982744e1bd65 // indirect
github.com/dustin/go-humanize v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.3 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/ncruces/go-strftime v1.1.0 // indirect
github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sethvargo/go-retry v0.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.24.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect
modernc.org/libc v1.78.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)
-212
View File
@@ -1,216 +1,4 @@
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.3 h1:M/52tsoyTfwaxajLnXZm7PqkbMGRH8iSWP+8fcg9MQ4=
github.com/Microsoft/go-winio v0.6.3/go.mod h1:ma0QpxizD4fyyzJ1C7MIblymTD2Nxz5Mho/3CepwIZg=
github.com/aarondl/opt v0.0.0-20250607033636-982744e1bd65 h1:lbdPe4LBNmNDzeQFwNhEc88w90841qv737MI4+aXSYU=
github.com/aarondl/opt v0.0.0-20250607033636-982744e1bd65/go.mod h1:+xKBXrTAUOvrDXO5PRwIr4E1wciHY3Glgl+6OkCXknU=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw=
github.com/docker/docker v28.2.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc=
github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.3 h1:f8HoREISb3l5eO9coPgK7U3hKLhWGokOFVcThl5jrao=
github.com/jackc/puddle/v2 v2.2.3/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pganalyze/pg_query_go/v6 v6.1.0 h1:jG5ZLhcVgL1FAw4C/0VNQaVmX1SUJx71wBGdtTtBvls=
github.com/pganalyze/pg_query_go/v6 v6.1.0/go.mod h1:nvTHIuoud6e1SfrUaFwHqT0i4b5Nr+1rPWVds3B5+50=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pressly/goose/v3 v3.29.0 h1:dtEnBCT7CqkHIUCdgrXsrmeisaN8gFkRU4Y5OEwif4o=
github.com/pressly/goose/v3 v3.29.0/go.mod h1:plA4aP4+u+Q3FMTgbFbhF8TuAWpAdfWpoFGkJsIFWh0=
github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494 h1:wSmWgpuccqS2IOfmYrbRiUgv+g37W5suLLLxwwniTSc=
github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494/go.mod h1:yipyliwI08eQ6XwDm1fEwKPdF/xdbkiHtrU+1Hg+vc4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/sethvargo/go-retry v0.5.0 h1:JYfFKmVJ8ER06GReN1efzc5cm52BrVsFVzmcHiKJwfY=
github.com/sethvargo/go-retry v0.5.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg=
github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQUJAsc=
github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stephenafamo/bob v0.50.0 h1:CsG9JSfJt4RLZiuHdf39LK98Jx5QBkNAIymqtVwnCqY=
github.com/stephenafamo/bob v0.50.0/go.mod h1:pz+z6oJa2PU6jGo+uWvaWcbNlXPVwkt5d9nW8kHWfJ0=
github.com/stephenafamo/fakedb v0.0.0-20221230081958-0b86f816ed97 h1:XItoZNmhOih06TC02jK7l3wlpZ0XT/sPQYutDcGOQjg=
github.com/stephenafamo/fakedb v0.0.0-20221230081958-0b86f816ed97/go.mod h1:bM3Vmw1IakoaXocHmMIGgJFYob0vuK+CFWiJHQvz0jQ=
github.com/stephenafamo/scan v0.9.0 h1:11Rf0cW1KjMLoTEa5uNO9FTRFDFc4BgJJi9LIQz1cmQ=
github.com/stephenafamo/scan v0.9.0/go.mod h1:FhIUJ8pLNyex36xGFiazDJJ5Xry0UkAi+RkWRrEcRMg=
github.com/stephenafamo/sqlparser v0.0.0-20260122161205-e324475bd1fc h1:D/IjFvgl7MXcuKnXzGCOAMKTfzopgxdLYIy0HZx+CI4=
github.com/stephenafamo/sqlparser v0.0.0-20260122161205-e324475bd1fc/go.mod h1:2ATW++wFz7Mvc/N+nUtQnU+9VIGAxrn8m9JCLDSWMsQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/testcontainers/testcontainers-go v0.38.0 h1:d7uEapLcv2P8AvH8ahLqDMMxda2W9gQN1nRbHS28HBw=
github.com/testcontainers/testcontainers-go v0.38.0/go.mod h1:C52c9MoHpWO+C4aqmgSU+hxlR5jlEayWtgYrb8Pzz1w=
github.com/testcontainers/testcontainers-go/modules/postgres v0.38.0 h1:KFdx9A0yF94K70T6ibSuvgkQQeX1xKlZVF3hEagXEtY=
github.com/testcontainers/testcontainers-go/modules/postgres v0.38.0/go.mod h1:T/QRECND6N6tAKMxF1Za+G2tpwnGEHcODzHRsgIpw9M=
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/urfave/cli/v3 v3.14.0 h1:a8414NQlHJs0c/iBsulKLzlES0n/lEAskbL2LKpU4/s=
github.com/urfave/cli/v3 v3.14.0/go.mod h1:vXn6HxPNccJSzQr2QvwVncOKrgYGIHU0HY5h8B2nQj4=
github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfPe7z7go8Dvv1AJQDI3eQ/5xith3q2mFlo=
github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07/go.mod h1:Ak17IJ037caFp4jpCw/iQQ7/W74Sqpb1YuKJU6HTKfM=
github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4=
github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 h1:LxwW/9ctSCv+QkE/cLR7M91ZIkXNMqJtEMi1vCw9U8s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0/go.mod h1:tOsftB4SslBwwErVEPaenU2RpThXWPIU8DoJHEC4dyw=
go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM=
go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU=
go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI=
go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw=
go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY=
go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU=
go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM=
go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/exp v0.0.0-20261011100332-faecfb24eb49 h1:pQo5TwmxKJvI8k39uYS2Gqkk8OXWxX/TMSis2Twp1AQ=
golang.org/x/exp v0.0.0-20261011100332-faecfb24eb49/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.30.1 h1:ZXInYH2CD24GPWSwOFPwB6JrnFt4/6d7ceW1Ukd30Bk=
modernc.org/cc/v4 v4.30.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.36.2 h1:j6d5HuXtBxF00vEcI5RE0O6EIrG6RyRpgMiGV2IK9ys=
modernc.org/ccgo/v4 v4.36.2/go.mod h1:Wh8QlPZq/US2DrsCc31FxJVBOEEcN2BBfla6OQDHydA=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.78.0 h1:Lpm+e9K6ruVYEGwWfuBO1xXtYEaXDouFOG1SuWjFDP4=
modernc.org/libc v1.78.0/go.mod h1:wvxR55nOF2/66NkJoNV7/coXBTCvT3HR22rcqW6lOuA=
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.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/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.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
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=
-230
View File
@@ -1,230 +0,0 @@
// Package cli runs caller-owned urfave/cli trees inside selected lifecycle modules.
// It owns temporary signal subscriptions and exit classification, not process exit,
// configuration, resource handles or unsupervised goroutines.
package cli
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"slices"
"syscall"
urfavecli "github.com/urfave/cli/v3"
"go.auvem.com/corestack/lifecycle"
)
// Run executes cliRoot with args including argv[0], returning 0 for success/help,
// 2 for usage, and 1 for runtime failures. Lifecycle failures always take priority.
// It prints a final diagnostic to cliRoot.ErrWriter and never exits the process.
// Run reserves ExitErrHandler, OnUsageError and CommandNotFound throughout the
// declared tree. Construct fresh trees for independent runs; do not mutate them
// concurrently. Native generated-help parse and environment validation failures
// may return 1 and print native diagnostics in addition to the final diagnostic.
func Run(ctx context.Context, cliRoot *urfavecli.Command, args []string) (int, error) {
var err error
if ctx == nil || cliRoot == nil {
err = errors.New("cli: context and root command are required")
} else {
err = validateTree(cliRoot)
}
if err == nil {
var missing error
installHooks(cliRoot, &missing)
err = cliRoot.Run(ctx, args)
if missing != nil {
if err == nil {
err = missing
} else {
err = errors.Join(err, missing)
}
}
}
if err == nil {
return 0, nil
}
code := classify(err, 0)
err = expose(err)
if cliRoot != nil && cliRoot.ErrWriter != nil {
fmt.Fprintln(cliRoot.ErrWriter, err)
} else {
fmt.Fprintln(os.Stderr, err)
}
return code, err
}
func validateTree(cmd *urfavecli.Command) error {
if cmd == nil {
return errors.New("cli: nil command")
}
if cmd.ExitErrHandler != nil || cmd.OnUsageError != nil || cmd.CommandNotFound != nil {
return fmt.Errorf("cli: command %q sets a reserved error hook", cmd.Name)
}
for _, child := range cmd.Commands {
if err := validateTree(child); err != nil {
return err
}
}
return nil
}
func installHooks(cmd *urfavecli.Command, missing *error) {
cmd.ExitErrHandler = func(context.Context, *urfavecli.Command, error) {}
cmd.OnUsageError = func(_ context.Context, selected *urfavecli.Command, err error, _ bool) error {
_ = urfavecli.ShowSubcommandHelp(selected)
return &failure{err, 2}
}
cmd.CommandNotFound = func(_ context.Context, selected *urfavecli.Command, name string) {
*missing = errors.Join(*missing, &failure{fmt.Errorf("unknown command or help topic %q", name), 2})
_ = urfavecli.ShowSubcommandHelp(selected)
}
for _, child := range cmd.Commands {
installHooks(child, missing)
}
}
// Serve returns a native action running exactly modules, including their services,
// in registration order with reverse cleanup. Bindings capture declarations without
// starting work; resources and declarations must not be mutated concurrently.
// Nil config.Signals subscribes to SIGINT/SIGTERM for the invocation, buffered for
// two signals; an injected channel and its subscription remain caller-owned.
// The private invocation App is unavailable for managed-worker admission.
func Serve(config lifecycle.Config, modules []lifecycle.Module) urfavecli.ActionFunc {
return bind(config, modules, nil, false)
}
// Action returns a native one-off action using exactly modules with their Run
// callbacks suppressed. All setups precede fn; completion triggers reverse cleanup.
// fn receives lifecycle's signal-aware context and the selected native command.
// Nil fn is a runtime configuration error. Signal and ownership rules match Serve;
// hooks outside this action are outside the lifecycle shutdown guarantee.
func Action(config lifecycle.Config, modules []lifecycle.Module, fn urfavecli.ActionFunc) urfavecli.ActionFunc {
return bind(config, modules, fn, true)
}
func bind(config lifecycle.Config, modules []lifecycle.Module, fn urfavecli.ActionFunc, oneOff bool) urfavecli.ActionFunc {
modules = slices.Clone(modules)
return func(ctx context.Context, cmd *urfavecli.Command) error {
err := execute(ctx, cmd, config, modules, fn, oneOff)
if err != nil {
return &failure{err, 1}
}
return nil
}
}
func execute(ctx context.Context, cmd *urfavecli.Command, config lifecycle.Config, modules []lifecycle.Module, fn urfavecli.ActionFunc, oneOff bool) error {
if oneOff && fn == nil {
return errors.New("cli: one-off function is required")
}
var signals chan os.Signal
if config.Signals == nil {
signals = make(chan os.Signal, 2)
config.Signals = signals
}
app, err := lifecycle.New(config)
if err != nil {
return err
}
names := make(map[string]bool, len(modules))
for _, module := range modules {
names[module.Name] = true
if oneOff {
module.Run = nil
}
if err := app.Register(module); err != nil {
return err
}
}
if oneOff {
name := "cli-action"
for names[name] {
name += "-"
}
noop := func(context.Context) error { return nil }
if err := app.Register(lifecycle.Module{Name: name, Setup: noop, Teardown: noop,
Run: func(ctx context.Context) error { return fn(ctx, cmd) }}); err != nil {
return err
}
}
if signals != nil {
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(signals)
}
return app.Run(ctx)
}
type failure struct {
error
code int
}
func (e *failure) Unwrap() error { return e.error }
func children(err error) []error {
switch e := err.(type) {
case interface{ Unwrap() []error }:
return e.Unwrap()
case interface{ Unwrap() error }:
if child := e.Unwrap(); child != nil {
return []error{child}
}
case urfavecli.MultiError:
return e.Errors()
}
return nil
}
// invariant: runtime branches dominate usage; aggregate containers add no failure.
func classify(err error, inherited int) int {
if e, ok := err.(*failure); ok {
if e.code == 1 {
return 1
}
inherited = e.code
} else if e, ok := err.(urfavecli.ExitCoder); ok && inherited == 0 {
inherited = 1
if e.ExitCode() == 2 {
inherited = 2
}
}
code := 0
for _, child := range children(err) {
if child == nil {
continue
}
next := classify(child, inherited)
if next == 1 {
return 1
}
code = next
}
if code != 0 {
return code
}
if inherited != 0 {
return inherited
}
return 1
}
// exposed retains native identity and formatting while making opaque MultiError
// descendants reachable through standard errors.Is and errors.As traversal.
type exposed struct {
error
causes []error
}
func (e *exposed) Unwrap() []error { return e.causes }
func expose(err error) error {
causes := []error{err}
for _, child := range children(err) {
if child != nil {
causes = append(causes, expose(child))
}
}
return &exposed{err, causes}
}
-428
View File
@@ -1,428 +0,0 @@
package cli
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"reflect"
"strings"
"syscall"
"testing"
"time"
urfavecli "github.com/urfave/cli/v3"
"go.auvem.com/corestack/lifecycle"
)
func config() lifecycle.Config {
return lifecycle.Config{Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Signals: make(chan os.Signal, 2), ShutdownTimeout: time.Second}
}
func command(action urfavecli.ActionFunc) *urfavecli.Command {
return &urfavecli.Command{Name: "app", Writer: io.Discard, ErrWriter: io.Discard, Commands: []*urfavecli.Command{{Name: "inspect", Action: action, Flags: []urfavecli.Flag{&urfavecli.BoolFlag{Name: "verbose"}}}}}
}
func TestNativeLifecycleFlow(t *testing.T) {
for _, fail := range []bool{false, true} {
t.Run(fmt.Sprint(fail), func(t *testing.T) {
var order []string
boom := &os.PathError{Op: "inspect", Path: "db", Err: io.ErrUnexpectedEOF}
var selected []lifecycle.Module
for _, name := range []string{"cli-action", "cli-action-", "database"} {
selected = append(selected, lifecycle.Module{Name: name,
Setup: func(context.Context) error { order = append(order, "setup "+name); return nil },
Run: func(context.Context) error { t.Error("one-off service started"); return nil },
Teardown: func(context.Context) error { order = append(order, "close "+name); return nil }})
}
cliRoot := command(Action(config(), selected, func(_ context.Context, cmd *urfavecli.Command) error {
if !cmd.Bool("verbose") {
t.Error("native flag missing")
}
order = append(order, "inspect")
if fail {
return boom
}
return nil
}))
cliRoot.Commands = append(cliRoot.Commands, &urfavecli.Command{Name: "unused", Action: Serve(config(), []lifecycle.Module{{Name: "unused", Setup: func(context.Context) error { t.Error("unselected setup"); return nil }, Teardown: func(context.Context) error { return nil }}})})
code, err := Run(context.Background(), cliRoot, []string{"app", "inspect", "--verbose"})
if fail && (code != 1 || !errors.Is(err, boom)) || !fail && (code != 0 || err != nil) {
t.Fatalf("Run = %d, %v", code, err)
}
want := []string{"setup cli-action", "setup cli-action-", "setup database", "inspect", "close database", "close cli-action-", "close cli-action"}
if !reflect.DeepEqual(order, want) {
t.Fatalf("order %v", order)
}
})
}
}
func TestNativeUsageAndHelp(t *testing.T) {
for _, tc := range []struct {
args string
code int
}{
{"", 0}, {"--help", 0}, {"inspect --help", 0}, {"help inspect", 0}, {"group", 0}, {"group --help", 0},
{"missing", 2}, {"help missing", 2}, {"group missing", 2}, {"group help missing", 2},
{"--bad", 2}, {"inspect --bad", 2}, {"inspect --count", 2}, {"inspect --count nope", 2}, {"inspect", 2},
{"help --bad", 1}, {"h --bad", 1}, {"group help --bad", 1}, {"group h --bad", 1},
} {
t.Run(tc.args, func(t *testing.T) {
cliRoot := command(Action(config(), []lifecycle.Module{{Name: "db", Setup: func(context.Context) error { t.Error("setup during usage/help"); return nil }, Teardown: func(context.Context) error { return nil }}}, func(context.Context, *urfavecli.Command) error { t.Error("action during usage/help"); return nil }))
cliRoot.Commands[0].Flags = append(cliRoot.Commands[0].Flags, &urfavecli.IntFlag{Name: "count", Required: true})
cliRoot.Commands = append(cliRoot.Commands, &urfavecli.Command{Name: "group", Commands: []*urfavecli.Command{{Name: "child"}}})
var output, diagnostic bytes.Buffer
cliRoot.Writer = &output
cliRoot.ErrWriter = &diagnostic
code, err := Run(context.Background(), cliRoot, append([]string{"app"}, strings.Fields(tc.args)...))
if code != tc.code || (err != nil) != (tc.code != 0) {
t.Fatalf("Run=%d,%v; want %d", code, err, tc.code)
}
if output.Len() == 0 && tc.code != 1 {
t.Error("missing native help")
}
if (diagnostic.Len() > 0) != (tc.code != 0) {
t.Fatalf("diagnostic %q", diagnostic.String())
}
})
}
}
func TestNativeValidation(t *testing.T) {
for _, kind := range []string{"environment", "validator", "validator-exit", "flag-action", "required-argument", "argument-parse", "exclusive"} {
t.Run(kind, func(t *testing.T) {
cliRoot := command(func(context.Context, *urfavecli.Command) error {
t.Error("invalid invocation reached action")
return nil
})
cmd := cliRoot.Commands[0]
args := []string{"app", "inspect"}
want := 1
switch kind {
case "environment":
t.Setenv("CORESTACK_CLI_COUNT", "oops")
cmd.Flags = []urfavecli.Flag{&urfavecli.IntFlag{Name: "count", Sources: urfavecli.EnvVars("CORESTACK_CLI_COUNT")}}
case "validator", "validator-exit":
cmd.ArgValidator = func(context.Context, *urfavecli.Command) error {
if kind == "validator-exit" {
return urfavecli.Exit("invalid", 2)
}
return errors.New("invalid")
}
if kind == "validator-exit" {
want = 2
}
case "flag-action":
cmd.Flags = []urfavecli.Flag{&urfavecli.BoolFlag{Name: "bad", Action: func(context.Context, *urfavecli.Command, bool) error { return errors.New("invalid") }}}
args = append(args, "--bad")
case "required-argument":
cmd.Arguments = []urfavecli.Argument{&urfavecli.StringArg{Name: "target", Required: true}}
want = 2
case "argument-parse":
cmd.Arguments = []urfavecli.Argument{&urfavecli.IntArg{Name: "count"}}
args = append(args, "oops")
want = 2
case "exclusive":
cmd.MutuallyExclusiveFlags = []urfavecli.MutuallyExclusiveFlags{{Flags: [][]urfavecli.Flag{{&urfavecli.BoolFlag{Name: "a"}}, {&urfavecli.BoolFlag{Name: "b"}}}}}
args = append(args, "--a", "--b")
want = 2
}
code, err := Run(context.Background(), cliRoot, args)
if code != want || err == nil {
t.Fatalf("Run=%d,%v; want %d", code, err, want)
}
})
}
}
func TestFailureCategoriesAndCauses(t *testing.T) {
for _, kind := range []string{"native-exit2", "native-after", "native-exit0", "native-exit9", "action-exit2", "teardown", "setup", "action-after", "usage-after", "nested-after", "unknown-after"} {
t.Run(kind, func(t *testing.T) {
cause := &os.PathError{Op: "read", Path: "db", Err: io.ErrUnexpectedEOF}
after := errors.New("after")
parent := errors.New("parent after")
original := urfavecli.Exit(cause, 2)
cfg := config()
modules := []lifecycle.Module{{Name: "db", Setup: func(context.Context) error { return nil }, Teardown: func(context.Context) error { return nil }}}
action := func(context.Context, *urfavecli.Command) error { return original }
want := 1
args := []string{"app", "inspect"}
switch kind {
case "native-exit2":
want = 2
case "native-exit0":
original = urfavecli.Exit(cause, 0)
case "native-exit9":
original = urfavecli.Exit(cause, 9)
case "setup":
modules[0].Setup = func(context.Context) error { return cause }
case "teardown":
modules[0].Teardown = func(context.Context) error { return after }
}
cliRoot := command(action)
if !strings.HasPrefix(kind, "native-") {
cliRoot.Commands[0].Action = Action(cfg, modules, action)
}
if kind == "native-after" || kind == "action-after" || kind == "nested-after" || kind == "usage-after" {
cliRoot.Commands[0].After = func(context.Context, *urfavecli.Command) error { return after }
}
if kind == "nested-after" {
cliRoot.After = func(context.Context, *urfavecli.Command) error { return parent }
}
if kind == "usage-after" {
cliRoot.Commands[0].Flags = append(cliRoot.Commands[0].Flags, &urfavecli.StringFlag{Name: "needed", Required: true})
}
if kind == "unknown-after" {
args = []string{"app", "help", "missing"}
cliRoot.After = func(context.Context, *urfavecli.Command) error { return after }
}
code, err := Run(context.Background(), cliRoot, args)
if code != want || err == nil {
t.Fatalf("Run=%d,%v", code, err)
}
if kind != "usage-after" && kind != "unknown-after" {
var path *os.PathError
if !errors.Is(err, cause) || !errors.As(err, &path) || path != cause || !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("cause lost: %v", err)
}
if kind != "setup" && !errors.Is(err, original) {
t.Fatalf("original identity lost: %v", err)
}
}
if kind == "teardown" || strings.HasSuffix(kind, "-after") {
if !errors.Is(err, after) {
t.Fatalf("After/cleanup lost: %v", err)
}
}
if kind == "nested-after" && !errors.Is(err, parent) {
t.Fatal("parent cause lost")
}
if kind == "native-after" || kind == "action-after" || kind == "nested-after" || kind == "usage-after" {
var multi urfavecli.MultiError
if !errors.As(err, &multi) || !errors.Is(err, multi) {
t.Fatalf("native aggregate lost: %v", err)
}
if err.Error() != multi.Error() {
t.Fatalf("duplicated formatting: %v", err)
}
}
})
}
}
func TestValidationBeforeSetup(t *testing.T) {
for _, kind := range []string{"nil-context", "nil-root", "nil-function", "nil-logger", "negative-timeout", "invalid-module", "duplicate-module", "nil-child", "exit-hook", "usage-hook", "not-found-hook"} {
t.Run(kind, func(t *testing.T) {
cfg := config()
noop := func(context.Context) error { return nil }
modules := []lifecycle.Module{{Name: "db", Setup: func(context.Context) error { t.Error("setup before validation"); return nil }, Teardown: noop}}
var ctx context.Context = context.Background()
fn := urfavecli.ActionFunc(func(context.Context, *urfavecli.Command) error { t.Error("action before validation"); return nil })
switch kind {
case "nil-context":
ctx = nil
case "nil-function":
fn = nil
case "nil-logger":
cfg.Logger = nil
case "negative-timeout":
cfg.ShutdownTimeout = -1
case "invalid-module":
modules = append(modules, lifecycle.Module{Name: "invalid"})
case "duplicate-module":
modules = append(modules, modules[0])
}
cliRoot := command(Action(cfg, modules, fn))
switch kind {
case "nil-root":
cliRoot = nil
case "nil-child":
cliRoot.Commands = append(cliRoot.Commands, nil)
case "exit-hook":
cliRoot.Commands[0].ExitErrHandler = func(context.Context, *urfavecli.Command, error) {}
case "usage-hook":
cliRoot.Commands[0].OnUsageError = func(context.Context, *urfavecli.Command, error, bool) error { return nil }
case "not-found-hook":
cliRoot.Commands[0].CommandNotFound = func(context.Context, *urfavecli.Command, string) {}
}
code, err := Run(ctx, cliRoot, []string{"app", "inspect"})
if code != 1 || err == nil {
t.Fatalf("Run=%d,%v", code, err)
}
})
}
}
func await[T any](t *testing.T, ch <-chan T) T {
t.Helper()
select {
case value := <-ch:
return value
case <-time.After(3 * time.Second):
t.Fatal("callback blocked")
var zero T
return zero
}
}
func TestSignalsAndParent(t *testing.T) {
for _, mode := range []string{"first", "second", "parent", "serve"} {
t.Run(mode, func(t *testing.T) {
signals := make(chan os.Signal, 2)
cfg := config()
cfg.Signals = signals
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ready := make(chan struct{})
canceled := make(chan struct{})
exited := make(chan struct{})
cleanup := make(chan struct{})
release := make(chan struct{})
defer func() { close(release); await(t, exited) }()
callback := func(ctx context.Context) error {
close(ready)
defer close(exited)
<-ctx.Done()
close(canceled)
if mode == "second" {
<-release
}
return ctx.Err()
}
modules := []lifecycle.Module{{Name: "db", Setup: func(context.Context) error { return nil }, Teardown: func(context.Context) error { close(cleanup); return nil }, Run: callback}}
action := Action(cfg, modules, func(ctx context.Context, _ *urfavecli.Command) error { return callback(ctx) })
if mode == "serve" {
action = Serve(cfg, modules)
}
done := make(chan struct {
code int
err error
}, 1)
go func() {
code, err := Run(ctx, command(action), []string{"app", "inspect"})
done <- struct {
code int
err error
}{code, err}
}()
await(t, ready)
if mode == "parent" {
cancel()
} else {
signals <- syscall.SIGINT
}
await(t, canceled)
await(t, cleanup)
if mode == "second" {
signals <- syscall.SIGTERM
}
result := await(t, done)
want := 0
if mode == "second" || mode == "parent" {
want = 1
}
if result.code != want || (result.err != nil) != (want != 0) {
t.Fatalf("Run=%d,%v", result.code, result.err)
}
if want == 1 && !errors.Is(result.err, context.Canceled) {
t.Fatalf("cancellation lost: %v", result.err)
}
// The adapter must leave the injected channel open and reusable.
signals <- syscall.SIGTERM
})
}
}
func TestSharedShutdownDeadline(t *testing.T) {
cfg := config()
cfg.ShutdownTimeout = 100 * time.Millisecond
ready := make(chan struct{})
shutdown := make(chan time.Time, 1)
exited := make(chan struct{})
release := make(chan struct{})
defer func() { close(release); await(t, exited) }()
var first time.Time
noop := func(context.Context) error { return nil }
modules := []lifecycle.Module{
{Name: "blocked", Setup: noop, Teardown: func(ctx context.Context) error {
defer close(exited)
deadline, _ := ctx.Deadline()
shutdown <- deadline
<-release
return nil
}},
{Name: "first", Setup: noop, Teardown: func(ctx context.Context) error {
first, _ = ctx.Deadline()
time.Sleep(20 * time.Millisecond)
return nil
}},
}
done := make(chan error, 1)
go func() {
_, err := Run(context.Background(), command(Action(cfg, modules, func(context.Context, *urfavecli.Command) error { close(ready); return nil })), []string{"app", "inspect"})
done <- err
}()
await(t, ready)
deadline := await(t, shutdown)
if !first.Equal(deadline) {
t.Fatal("shutdown deadline reset between hooks")
}
if err := await(t, done); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("deadline lost: %v", err)
}
}
func TestEmptySelectionsAndOwnedSignals(t *testing.T) {
for _, oneOff := range []bool{false, true} {
cfg := config()
cfg.Signals = nil
action := Serve(cfg, nil)
if oneOff {
action = Action(cfg, nil, func(context.Context, *urfavecli.Command) error { return nil })
}
if code, err := Run(context.Background(), command(action), []string{"app", "inspect"}); code != 0 || err != nil {
t.Fatalf("Run=%d,%v", code, err)
}
}
cfg := config()
signals := make(chan os.Signal)
close(signals)
cfg.Signals = signals
if code, err := Run(context.Background(), command(Serve(cfg, nil)), []string{"app", "inspect"}); code != 0 || err != nil {
t.Fatalf("closed signals: %d,%v", code, err)
}
}
func TestPartialSetupAndMixedCleanup(t *testing.T) {
for _, setupFailure := range []bool{false, true} {
t.Run(fmt.Sprint(setupFailure), func(t *testing.T) {
initiating := urfavecli.Exit(io.ErrUnexpectedEOF, 2)
cleanup := errors.New("cleanup")
var order []string
modules := []lifecycle.Module{
{Name: "first", Setup: func(context.Context) error { order = append(order, "first setup"); return nil }, Teardown: func(context.Context) error { order = append(order, "first cleanup"); return cleanup }},
{Name: "second", Setup: func(context.Context) error {
order = append(order, "second setup")
if setupFailure {
return initiating
}
return nil
}, Teardown: func(context.Context) error { order = append(order, "second cleanup"); return nil }},
}
cliRoot := command(Action(config(), modules, func(context.Context, *urfavecli.Command) error { order = append(order, "action"); return initiating }))
code, err := Run(context.Background(), cliRoot, []string{"app", "inspect"})
if code != 1 || !errors.Is(err, initiating) || !errors.Is(err, cleanup) || !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("Run=%d,%v", code, err)
}
want := []string{"first setup", "second setup", "first cleanup"}
if !setupFailure {
want = []string{"first setup", "second setup", "action", "second cleanup", "first cleanup"}
}
if !reflect.DeepEqual(order, want) {
t.Fatalf("order %v", order)
}
})
}
}
-51
View File
@@ -1,51 +0,0 @@
package cli_test
import (
"context"
"fmt"
"io"
"log/slog"
urfavecli "github.com/urfave/cli/v3"
"go.auvem.com/corestack/lifecycle"
"go.auvem.com/corestack/lifecycle/cli"
)
func Example() {
cfg := lifecycle.Config{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
for _, args := range [][]string{{"app", "serve"}, {"app", "inspect", "--verbose"}} {
var database string
db := lifecycle.Module{Name: "database",
Setup: func(context.Context) error { database = "connected"; fmt.Println("open database"); return nil },
Teardown: func(context.Context) error { fmt.Println("close database"); database = ""; return nil },
}
server := lifecycle.Module{Name: "server",
Setup: func(context.Context) error { fmt.Println("open server"); return nil },
// A real server blocks until cancellation; this finite service keeps the example executable.
Run: func(context.Context) error { fmt.Println("serve:", database); return nil },
Teardown: func(context.Context) error { fmt.Println("close server"); return nil },
}
cliRoot := &urfavecli.Command{Name: "app", Commands: []*urfavecli.Command{
{Name: "serve", Action: cli.Serve(cfg, []lifecycle.Module{db, server})},
{Name: "inspect", Flags: []urfavecli.Flag{&urfavecli.BoolFlag{Name: "verbose"}},
Action: cli.Action(cfg, []lifecycle.Module{db}, func(_ context.Context, cmd *urfavecli.Command) error {
fmt.Println("inspect:", database, "verbose:", cmd.Bool("verbose"))
return nil
})},
}}
code, err := cli.Run(context.Background(), cliRoot, args)
fmt.Println("exit:", code, err)
// main may call os.Exit(code) here, after adapter cleanup.
}
// Output:
// open database
// open server
// serve: connected
// close server
// close database
// exit: 0 <nil>
// open database
// inspect: connected verbose: true
// close database
// exit: 0 <nil>
}
-97
View File
@@ -1,97 +0,0 @@
# migrate
`go.auvem.com/corestack/migrate` sets the house configuration for
[Goose's Provider API](https://pressly.github.io/goose/documentation/provider/).
It returns `*goose.Provider` directly: Goose owns execution, version tracking,
status, and errors. There is no lifecycle dependency or background work.
Create SQL migration files with Goose's
[`create` command](https://pressly.github.io/goose/documentation/cli-commands/#create).
For timestamp-to-sequential reconciliation before deployment, use its
[`fix` command](https://pressly.github.io/goose/documentation/cli-commands/#fix)
as described in [hybrid versioning](https://github.com/pressly/goose#hybrid-versioning).
These commands edit source files before embedding; `fix` does not update database
history. Never renumber migrations already applied to shared databases.
**Goose's default Go template is not compatible unchanged with this package.**
It registers migrations globally from `init`, while this package uses explicit
provider-local registration. Until the compatible authoring CLI planned in
[#9](https://gitea.auvem.com/corestack/go/issues/9) is available, Go migrations
require the adaptation described below; do not treat stock `goose create ... go`
output as ready to use. That follow-up must supply and document the supported
creation/reconciliation workflow before it is complete.
Embed migrations in the application's binary and run them before serving traffic:
```go
package app
import (
"context"
"database/sql"
"embed"
"log/slog"
"github.com/pressly/goose/v3"
"go.auvem.com/corestack/migrate"
)
//go:embed migrations/*.sql
var migrations embed.FS
func migrateDatabase(ctx context.Context, db *sql.DB, logger *slog.Logger) error {
provider, err := migrate.New(db, goose.DialectPostgres, migrations, "migrations", logger)
if err != nil {
return err
}
_, err = provider.Up(ctx)
return err
}
```
The caller opens and closes `db`, selects a matching database driver and dialect,
supplies a nonnil logger, and controls the operation context/deadline. Construction
is offline; SQL is parsed lazily by Goose. `Provider.Close` also closes `db`, so do
not defer it when sharing the pool with the application. Use `"."` for SQL files
at the embedded root. Files in the selected directory are discovered directly,
without recursively searching subdirectories.
Preserve Goose's creator/fix filenames and ship the complete migration history.
The pinned Goose SQL-only provider relies on lexical filename order matching
numeric version order. Timestamp versions and gaps are supported; never derive
versions from file counts. For operations, errors, and SQL annotations, use the
[Provider API](https://pkg.go.dev/github.com/pressly/goose/v3#Provider) and
[SQL migration docs](https://pressly.github.io/goose/documentation/annotations/).
For Go functions, use Goose's
[provider-local registration](https://pressly.github.io/goose/documentation/provider/#withgomigrations):
pass fresh `goose.NewGoMigration` values as the trailing arguments to `migrate.New`.
The creator's default Go template instead calls `goose.AddMigrationContext` from
`init`; replace that registration with provider-local construction. Global
registration is disabled to keep each provider's migration set isolated and avoid
import side effects. If `fix` renames Go migrations, update their explicit
registration versions to match. Versions must be unique across SQL and Go files.
Postgres providers automatically use Goose's default session advisory locker.
Concurrent startup calls from separate providers/processes serialize migration
execution and recheck applied versions under the lock. The default lock ID
serializes Goose migrations throughout the same database, including different
schemas. Use a direct connection or a session-pooling proxy: transaction pooling
cannot preserve session locks.
`RunDB` Go migrations need spare pool connections while the locked session is
held; avoid a one-connection pool for them.
Other dialects have no cross-provider/process lock configured by this package.
Serialize migration runs externally, for example with a single deployment job.
SQLite tests validate the SQL convention, not concurrent daemon startup safety.
Run the integration suite against an existing Postgres database with schema
creation privileges:
```sh
MIGRATE_POSTGRES_DSN='postgres://user:password@localhost/testdb?sslmode=disable' go test -race ./migrate
```
Tests create and clean only random schemas they own. Without a DSN, Postgres
tests skip locally; in CI a missing DSN fails, and the CI job supplies a Postgres
service. SQLite tests always run in process using temporary databases.
-65
View File
@@ -1,65 +0,0 @@
// Package migrate configures Goose providers for embedded application migrations.
// Applications own their database, migration files, and startup ordering.
package migrate
import (
"database/sql"
"embed"
"fmt"
"io/fs"
"log/slog"
"github.com/pressly/goose/v3"
"github.com/pressly/goose/v3/lock"
)
// New returns a provider for SQL files in dir and optional provider-local Go
// migrations. Pass "." for files at the embedded root and a nonnil logger.
// The selected directory must exist, including when using only Go migrations.
// Construction does not connect to db; the caller owns and closes db.
//
// PostgreSQL uses Goose's session advisory locker with its default lock ID and
// retry limits. Other dialects require the caller to serialize migration runs
// across providers/processes. PostgreSQL connections must preserve session
// affinity (transaction-pooling proxies cannot safely hold session locks).
//
// Use the returned Provider's Up, UpTo, DownTo, Status, GetDBVersion, GetVersions,
// and HasPending methods directly. Create migration files with Goose's create
// command; lexical filename order must match numeric version order. Go migrations
// are registered per provider; global Go registration is ignored.
func New(db *sql.DB, dialect goose.Dialect, files embed.FS, dir string, logger *slog.Logger, migrations ...*goose.Migration) (*goose.Provider, error) {
for i, migration := range migrations {
if migration == nil {
return nil, fmt.Errorf("migrate: Go migration %d must not be nil", i)
}
}
root, err := fs.Sub(files, dir)
if err != nil {
return nil, fmt.Errorf("migrate: directory %q: %w", dir, err)
}
info, err := fs.Stat(root, ".")
if err != nil {
return nil, fmt.Errorf("migrate: directory %q: %w", dir, err)
}
if !info.IsDir() {
return nil, fmt.Errorf("migrate: directory %q: not a directory", dir)
}
opts := []goose.ProviderOption{
goose.WithDisableGlobalRegistry(true),
goose.WithGoMigrations(migrations...),
goose.WithSlog(logger),
goose.WithVerbose(true),
}
if dialect == goose.DialectPostgres {
locker, err := lock.NewPostgresSessionLocker()
if err != nil {
return nil, fmt.Errorf("migrate: postgres locker: %w", err)
}
opts = append(opts, goose.WithSessionLocker(locker))
}
provider, err := goose.NewProvider(dialect, db, root, opts...)
if err != nil {
return nil, fmt.Errorf("migrate: new provider: %w", err)
}
return provider, nil
}
-457
View File
@@ -1,457 +0,0 @@
package migrate_test
import (
"bytes"
"context"
"crypto/rand"
"database/sql"
"embed"
"errors"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
_ "modernc.org/sqlite"
"go.auvem.com/corestack/migrate"
)
// invariant: embedded fixtures are read-only; no test mutates shared migration state.
//
//go:embed testdata
var fixtures embed.FS
func TestSQLite(t *testing.T) {
for _, test := range []struct {
name string
run func(*testing.T, *sql.DB, goose.Dialect)
}{
{"flow", testFlow}, {"sql-only", testSQLOnly}, {"failure", testFailure},
} {
t.Run(test.name, func(t *testing.T) {
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "migrate.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Error(err)
}
})
test.run(t, db, goose.DialectSQLite3)
})
}
}
func TestPostgres(t *testing.T) {
for _, test := range []struct {
name string
run func(*testing.T, *sql.DB, goose.Dialect)
}{
{"flow", testFlow}, {"sql-only", testSQLOnly}, {"failure", testFailure},
} {
t.Run(test.name, func(t *testing.T) { test.run(t, postgresDatabase(t)(), goose.DialectPostgres) })
}
}
func testFlow(t *testing.T, db *sql.DB, dialect goose.Dialect) {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
var logs bytes.Buffer
goMigration := goose.NewGoMigration(20261027120000,
&goose.GoFunc{RunTx: func(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, "INSERT INTO migration_events (version) VALUES (20261027120000)")
return err
}},
&goose.GoFunc{RunTx: func(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, "DELETE FROM migration_events WHERE version = 20261027120000")
return err
}})
p, err := migrate.New(db, dialect, fixtures, "testdata/migrations", slog.New(slog.NewJSONHandler(&logs, nil)), goMigration)
if err != nil {
t.Fatal(err)
}
versions := []int64{7, 20261026093000, 20261027114500, 20261027120000}
// Registering on one provider must not leak into another provider in the same process.
other := newProvider(t, db, dialect, "testdata/migrations")
if len(other.ListSources()) != 3 {
t.Fatal("Go migration leaked to another provider")
}
assertState(t, ctx, p, 0, versions, 0)
results, err := p.UpTo(ctx, versions[1])
if err != nil {
t.Fatal(err)
}
assertResults(t, results, versions[:2])
assertState(t, ctx, p, versions[1], versions, 2)
results, err = p.Up(ctx)
if err != nil {
t.Fatal(err)
}
assertResults(t, results, versions[2:])
assertState(t, ctx, p, versions[3], versions, 4)
assertEvents(t, ctx, db, versions)
results, err = p.Up(ctx)
if err != nil || len(results) != 0 {
t.Fatalf("idempotent Up = %v, %v", results, err)
}
results, err = p.DownTo(ctx, versions[1])
if err != nil {
t.Fatal(err)
}
assertResults(t, results, []int64{versions[3], versions[2]})
assertState(t, ctx, p, versions[1], versions, 2)
assertEvents(t, ctx, db, versions[:2])
results, err = p.DownTo(ctx, versions[0])
if err != nil {
t.Fatal(err)
}
assertResults(t, results, []int64{versions[1]})
assertState(t, ctx, p, versions[0], versions, 1)
assertEvents(t, ctx, db, versions[:1])
if _, err := p.DownTo(ctx, 0); err != nil {
t.Fatal(err)
}
assertState(t, ctx, p, 0, versions, 0)
results, err = p.Up(ctx)
if err != nil {
t.Fatal(err)
}
assertResults(t, results, versions)
assertEvents(t, ctx, db, versions)
if !strings.Contains(logs.String(), `"logger":"goose"`) || !strings.Contains(logs.String(), `"version":20261027114500`) {
t.Fatalf("missing structured migration logs: %s", logs.String())
}
}
func testSQLOnly(t *testing.T, db *sql.DB, dialect goose.Dialect) {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
for _, test := range []struct {
dir string
versions []int64
}{
{"testdata/numeric", []int64{7, 10}},
{"testdata/migrations", []int64{7, 20261026093000, 20261027114500}},
} {
p := newProvider(t, db, dialect, test.dir)
assertState(t, ctx, p, 0, test.versions, 0)
results, err := p.UpTo(ctx, test.versions[1]-1)
if err != nil {
t.Fatal(err)
}
assertResults(t, results, test.versions[:1])
assertState(t, ctx, p, test.versions[0], test.versions, 1)
results, err = p.Up(ctx)
if err != nil {
t.Fatal(err)
}
assertResults(t, results, test.versions[1:])
assertState(t, ctx, p, test.versions[len(test.versions)-1], test.versions, len(test.versions))
assertEvents(t, ctx, db, test.versions)
results, err = p.DownTo(ctx, test.versions[1]-1)
if err != nil {
t.Fatal(err)
}
wantDown := slices.Clone(test.versions[1:])
slices.Reverse(wantDown)
assertResults(t, results, wantDown)
assertState(t, ctx, p, test.versions[0], test.versions, 1)
if _, err := p.DownTo(ctx, 0); err != nil {
t.Fatal(err)
}
assertState(t, ctx, p, 0, test.versions, 0)
}
}
func testFailure(t *testing.T, db *sql.DB, dialect goose.Dialect) {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
p := newProvider(t, db, dialect, "testdata/failure")
_, err := p.Up(ctx)
var partial *goose.PartialError
if !errors.As(err, &partial) || partial.Failed.Source.Version != 20261027114500 ||
!strings.Contains(err.Error(), "missing_migration_table") || !strings.Contains(err.Error(), "20261027114500") {
t.Fatalf("expected clear failed timestamp migration error, got %v", err)
}
assertResults(t, partial.Applied, []int64{7, 20261026093000})
assertState(t, ctx, p, 20261026093000, []int64{7, 20261026093000, 20261027114500}, 2)
assertEvents(t, ctx, db, []int64{7, 20261026093000})
// A second attempt must fail at the same version without repeating committed work.
if _, err := p.Up(ctx); err == nil {
t.Fatal("retry unexpectedly succeeded")
}
assertEvents(t, ctx, db, []int64{7, 20261026093000})
}
func newProvider(t *testing.T, db *sql.DB, dialect goose.Dialect, dir string, migrations ...*goose.Migration) *goose.Provider {
t.Helper()
p, err := migrate.New(db, dialect, fixtures, dir, slog.New(slog.NewTextHandler(io.Discard, nil)), migrations...)
if err != nil {
t.Fatal(err)
}
return p
}
func assertState(t *testing.T, ctx context.Context, p *goose.Provider, current int64, versions []int64, applied int) {
t.Helper()
var sources []int64
for _, source := range p.ListSources() {
sources = append(sources, source.Version)
}
if !slices.Equal(sources, versions) {
t.Fatalf("ListSources = %v; want %v", sources, versions)
}
got, err := p.GetDBVersion(ctx)
if err != nil || got != current {
t.Fatalf("GetDBVersion = %d, %v; want %d", got, err, current)
}
got, latest, err := p.GetVersions(ctx)
if err != nil || got != current || latest != versions[len(versions)-1] {
t.Fatalf("GetVersions = %d, %d, %v", got, latest, err)
}
pending, err := p.HasPending(ctx)
if err != nil || pending != (applied < len(versions)) {
t.Fatalf("HasPending = %v, %v", pending, err)
}
status, err := p.Status(ctx)
if err != nil {
t.Fatal(err)
}
if len(status) != len(versions) {
t.Fatalf("Status length = %d; want %d", len(status), len(versions))
}
for i, s := range status {
want := goose.StatePending
if i < applied {
want = goose.StateApplied
}
if s.Source.Version != versions[i] || s.State != want {
t.Fatalf("Status[%d] = %+v, version %d; want %s, %d", i, s, s.Source.Version, want, versions[i])
}
}
}
func assertResults(t *testing.T, results []*goose.MigrationResult, want []int64) {
t.Helper()
got := make([]int64, 0, len(results))
for _, r := range results {
got = append(got, r.Source.Version)
}
if !slices.Equal(got, want) {
t.Fatalf("applied versions = %v; want %v", got, want)
}
}
func assertEvents(t *testing.T, ctx context.Context, db *sql.DB, want []int64) {
t.Helper()
rows, err := db.QueryContext(ctx, "SELECT version FROM migration_events ORDER BY version")
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var got []int64
for rows.Next() {
var v int64
if err := rows.Scan(&v); err != nil {
t.Fatal(err)
}
got = append(got, v)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
if !slices.Equal(got, want) {
t.Fatalf("events = %v; want %v", got, want)
}
}
// postgresDatabase owns only its random schema, never the supplied database.
func postgresDatabase(t *testing.T) func() *sql.DB {
t.Helper()
dsn := os.Getenv("MIGRATE_POSTGRES_DSN")
if dsn == "" {
if os.Getenv("CI") != "" {
t.Fatal("MIGRATE_POSTGRES_DSN is required in CI")
}
t.Skip("set MIGRATE_POSTGRES_DSN to run Postgres integration tests")
}
config, err := pgx.ParseConfig(dsn)
if err != nil {
t.Fatal(err)
}
admin := stdlib.OpenDB(*config)
t.Cleanup(func() {
if err := admin.Close(); err != nil {
t.Error(err)
}
})
schema := "migrate_" + strings.ToLower(rand.Text())
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
if _, err := admin.ExecContext(ctx, "CREATE SCHEMA "+schema); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
if _, err := admin.ExecContext(ctx, "DROP SCHEMA "+schema+" CASCADE"); err != nil {
t.Error(err)
}
})
config.RuntimeParams["search_path"] = schema
return func() *sql.DB {
db := stdlib.OpenDB(*config)
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Error(err)
}
})
return db
}
}
func TestPostgresConcurrentUp(t *testing.T) {
open := postgresDatabase(t)
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
const workers = 4
providers := make([]*goose.Provider, workers)
for i := range providers {
// Each provider owns a distinct pool and Go migration instance, like separate daemons.
pause := goose.NewGoMigration(3, &goose.GoFunc{RunTx: func(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, "SELECT pg_sleep(0.2)")
return err
}}, nil)
providers[i] = newProvider(t, open(), goose.DialectPostgres, "testdata/migrations", pause)
}
type outcome struct {
results []*goose.MigrationResult
err error
}
start := make(chan struct{})
done := make(chan outcome, workers)
for _, p := range providers {
go func() {
select {
case <-start:
case <-ctx.Done():
done <- outcome{err: ctx.Err()}
return
}
results, err := p.Up(ctx)
done <- outcome{results, err}
}()
}
close(start)
var applied []int64
for range workers {
result := <-done
if result.err != nil {
t.Error(result.err)
}
for _, r := range result.results {
applied = append(applied, r.Source.Version)
}
}
slices.Sort(applied)
if want := []int64{3, 7, 20261026093000, 20261027114500}; !slices.Equal(applied, want) {
t.Fatalf("concurrent Up applied = %v; want exactly once each: %v", applied, want)
}
assertEvents(t, ctx, open(), []int64{7, 20261026093000, 20261027114500})
for _, p := range providers {
assertState(t, ctx, p, 20261027114500, applied, len(applied))
}
}
func TestConstructor(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
for _, test := range []struct {
name string
db *sql.DB
dialect goose.Dialect
dir string
logger *slog.Logger
migrations []*goose.Migration
contains string
}{
{name: "nil database", dialect: goose.DialectSQLite3, dir: "testdata/migrations", logger: logger, contains: "db must not be nil"},
{name: "nil logger", db: db, dialect: goose.DialectSQLite3, dir: "testdata/migrations", contains: "slog logger must not be nil"},
{name: "invalid directory", db: db, dialect: goose.DialectSQLite3, dir: "../migrations", logger: logger, contains: "directory"},
{name: "missing directory", db: db, dialect: goose.DialectSQLite3, dir: "missing", logger: logger, contains: "migrate:"},
{name: "invalid dialect", db: db, dialect: "unknown", dir: "testdata/migrations", logger: logger, contains: "dialect"},
{name: "nil migration", db: db, dialect: goose.DialectSQLite3, dir: "testdata/migrations", logger: logger, migrations: []*goose.Migration{nil}, contains: "must not be nil"},
{name: "duplicate version", db: db, dialect: goose.DialectSQLite3, dir: "testdata/migrations", logger: logger, migrations: []*goose.Migration{goose.NewGoMigration(7, nil, nil)}, contains: "7"},
} {
t.Run(test.name, func(t *testing.T) {
p, err := migrate.New(test.db, test.dialect, fixtures, test.dir, test.logger, test.migrations...)
if err == nil || p != nil || !strings.Contains(err.Error(), test.contains) {
t.Fatalf("New = %v, %v; want error containing %q", p, err, test.contains)
}
})
}
// Construction and validation are offline, even when the database is closed.
if err := db.Close(); err != nil {
t.Fatal(err)
}
if _, err := migrate.New(db, goose.DialectSQLite3, fixtures, "testdata/migrations", logger); err != nil {
t.Fatal(err)
}
}
func TestConstructorDirectory(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
// Directory validation and Go-only construction must remain offline.
if err := db.Close(); err != nil {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
for _, test := range []struct {
dir string
wantErr string
}{
{dir: "missing", wantErr: "directory"},
{dir: "testdata/migrations/00007_events.sql", wantErr: "not a directory"},
{dir: "."},
{dir: "testdata"},
} {
t.Run(test.dir, func(t *testing.T) {
p, err := migrate.New(db, goose.DialectSQLite3, fixtures, test.dir, logger, goose.NewGoMigration(10, nil, nil))
if test.wantErr != "" {
if err == nil || p != nil || !strings.Contains(err.Error(), test.wantErr) || !strings.Contains(err.Error(), test.dir) {
t.Fatalf("New = %v, %v; want directory error containing %q and %q", p, err, test.wantErr, test.dir)
}
if test.dir == "missing" && !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("New error = %v; want fs.ErrNotExist", err)
}
return
}
if err != nil {
t.Fatal(err)
}
if sources := p.ListSources(); len(sources) != 1 || sources[0].Version != 10 {
t.Fatalf("ListSources = %v; want Go migration 10 only", sources)
}
})
}
}
-6
View File
@@ -1,6 +0,0 @@
-- +goose Up
CREATE TABLE migration_events (version BIGINT PRIMARY KEY);
INSERT INTO migration_events (version) VALUES (7);
-- +goose Down
DROP TABLE migration_events;
-5
View File
@@ -1,5 +0,0 @@
-- +goose Up
INSERT INTO migration_events (version) VALUES (20261026093000);
-- +goose Down
DELETE FROM migration_events WHERE version = 20261026093000;
-6
View File
@@ -1,6 +0,0 @@
-- +goose Up
INSERT INTO migration_events (version) VALUES (20261027114500);
INSERT INTO missing_migration_table (version) VALUES (20261027114500);
-- +goose Down
DELETE FROM migration_events WHERE version = 20261027114500;
-6
View File
@@ -1,6 +0,0 @@
-- +goose Up
CREATE TABLE migration_events (version BIGINT PRIMARY KEY);
INSERT INTO migration_events (version) VALUES (7);
-- +goose Down
DROP TABLE migration_events;
-5
View File
@@ -1,5 +0,0 @@
-- +goose Up
INSERT INTO migration_events (version) VALUES (20261026093000);
-- +goose Down
DELETE FROM migration_events WHERE version = 20261026093000;
-5
View File
@@ -1,5 +0,0 @@
-- +goose Up
INSERT INTO migration_events (version) VALUES (20261027114500);
-- +goose Down
DELETE FROM migration_events WHERE version = 20261027114500;
-6
View File
@@ -1,6 +0,0 @@
-- +goose Up
CREATE TABLE migration_events (version BIGINT PRIMARY KEY);
INSERT INTO migration_events (version) VALUES (7);
-- +goose Down
DROP TABLE migration_events;
-5
View File
@@ -1,5 +0,0 @@
-- +goose Up
INSERT INTO migration_events (version) VALUES (10);
-- +goose Down
DELETE FROM migration_events WHERE version = 10;
-216
View File
@@ -1,216 +0,0 @@
package page
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"strconv"
"time"
"unicode/utf8"
"uuid"
)
const maxCursorBytes = 16384
// CursorError identifies malformed, version, sort, value, or size cursor failures via
// Kind. It intentionally omits cursor payloads from its message.
type CursorError struct{ Kind string }
// Error implements error without including cursor contents.
func (e *CursorError) Error() string { return "page: cursor " + e.Kind }
// ValueError identifies an unsupported concrete type or invalid supported value
// at Position in the row key, without exposing the value itself.
type ValueError struct {
Position int
Type string
}
// Error implements error without including key values.
func (e *ValueError) Error() string {
return fmt.Sprintf("page: invalid key value at %d (%s)", e.Position, e.Type)
}
type envelope struct {
Version int `json:"v"`
Sort string `json:"sort"`
Values [][2]string `json:"values"`
}
func encodeCursor(sortID string, values []any) (string, error) {
e := envelope{Version: 1, Sort: sortID, Values: make([][2]string, len(values))}
for i, v := range values {
pair, ok := encodeValue(v)
if !ok {
return "", &ValueError{Position: i, Type: fmt.Sprintf("%T", v)}
}
e.Values[i] = pair
}
data, err := json.Marshal(e)
if err != nil {
return "", &CursorError{Kind: "malformed"}
}
if base64.RawURLEncoding.EncodedLen(len(data)) > maxCursorBytes {
return "", &CursorError{Kind: "size"}
}
return base64.RawURLEncoding.EncodeToString(data), nil
}
func encodeValue(value any) ([2]string, bool) {
var tag, payload string
switch v := value.(type) {
case int:
tag, payload = "int", strconv.FormatInt(int64(v), 10)
case int8:
tag, payload = "int8", strconv.FormatInt(int64(v), 10)
case int16:
tag, payload = "int16", strconv.FormatInt(int64(v), 10)
case int32:
tag, payload = "int32", strconv.FormatInt(int64(v), 10)
case int64:
tag, payload = "int64", strconv.FormatInt(v, 10)
case string:
if !utf8.ValidString(v) {
return [2]string{}, false
}
tag, payload = "string", v
case time.Time:
v = v.UTC()
if v.Year() < 0 || v.Year() > 9999 {
return [2]string{}, false
}
tag, payload = "time", v.Format(time.RFC3339Nano)
case uuid.UUID:
tag, payload = "uuid", v.String()
default:
return [2]string{}, false
}
return [2]string{tag, payload}, true
}
func decodeCursor(token string, sort Sort) ([]any, error) {
failure := func(kind string) ([]any, error) { return nil, &CursorError{Kind: kind} }
if len(token) > maxCursorBytes {
return failure("size")
}
data, err := base64.RawURLEncoding.Strict().DecodeString(token)
if err != nil || !utf8.Valid(data) || base64.RawURLEncoding.EncodeToString(data) != token {
return failure("malformed")
}
// invariant: exactly three distinct envelope fields; struct decoding alone permits duplicates.
decoder := json.NewDecoder(bytes.NewReader(data))
start, err := decoder.Token()
if err != nil || start != json.Delim('{') {
return failure("malformed")
}
fields := make(map[string]json.RawMessage, 3)
for decoder.More() {
name, err := decoder.Token()
if err != nil {
return failure("malformed")
}
key, ok := name.(string)
if !ok || (key != "v" && key != "sort" && key != "values") {
return failure("malformed")
}
if _, exists := fields[key]; exists {
return failure("malformed")
}
var raw json.RawMessage
if decoder.Decode(&raw) != nil {
return failure("malformed")
}
fields[key] = raw
}
if _, err := decoder.Token(); err != nil || len(fields) != 3 {
return failure("malformed")
}
if _, err := decoder.Token(); err != io.EOF {
return failure("malformed")
}
var version int
if bytes.Equal(fields["v"], []byte("null")) || json.Unmarshal(fields["v"], &version) != nil {
return failure("malformed")
}
if version != 1 {
return failure("version")
}
var id string
if !jsonString(fields["sort"], &id) {
return failure("malformed")
}
if id != sort.ID {
return failure("sort")
}
var entries []json.RawMessage
if len(fields["values"]) == 0 || fields["values"][0] != '[' || json.Unmarshal(fields["values"], &entries) != nil || len(entries) != len(sort.Columns) {
return failure("malformed")
}
values := make([]any, len(entries))
for i, entry := range entries {
var pair []json.RawMessage
if len(entry) == 0 || entry[0] != '[' || json.Unmarshal(entry, &pair) != nil || len(pair) != 2 {
return failure("malformed")
}
var tag, payload string
if !jsonString(pair[0], &tag) || !jsonString(pair[1], &payload) {
return failure("malformed")
}
value, ok := decodeValue(tag, payload)
if !ok {
return failure("value")
}
values[i] = value
}
return values, nil
}
func jsonString(raw json.RawMessage, value *string) bool {
return len(raw) > 0 && raw[0] == '"' && json.Unmarshal(raw, value) == nil && utf8.ValidString(*value)
}
func decodeValue(tag, payload string) (any, bool) {
switch tag {
case "int", "int8", "int16", "int32", "int64":
bits := strconv.IntSize
switch tag {
case "int8":
bits = 8
case "int16":
bits = 16
case "int32":
bits = 32
case "int64":
bits = 64
}
n, err := strconv.ParseInt(payload, 10, bits)
if err != nil || strconv.FormatInt(n, 10) != payload {
return nil, false
}
switch tag {
case "int":
return int(n), true
case "int8":
return int8(n), true
case "int16":
return int16(n), true
case "int32":
return int32(n), true
default:
return n, true
}
case "string":
return payload, utf8.ValidString(payload)
case "time":
t, err := time.Parse(time.RFC3339Nano, payload)
t = t.UTC()
return t, err == nil && t.Year() >= 0 && t.Year() <= 9999
case "uuid":
id, err := uuid.Parse(payload)
return id, err == nil && id.String() == payload
default:
return nil, false
}
}
-220
View File
@@ -1,220 +0,0 @@
package page
import (
"encoding/base64"
"encoding/json"
"errors"
"math"
"reflect"
"strings"
"testing"
"time"
"uuid"
"github.com/stephenafamo/bob"
"github.com/stephenafamo/bob/dialect/psql"
pd "github.com/stephenafamo/bob/dialect/psql/dialect"
"github.com/stephenafamo/bob/expr"
)
func scalarSort() Sort {
return Sort{ID: "scalar/v1", Columns: []Column{{Expression: expr.Quote("id"), Unique: true}}}
}
func TestSupportedValues(t *testing.T) {
instant := time.Date(2026, 10, 27, 12, 34, 56, 123456789, time.FixedZone("odd", 3661))
values := []any{
int(0), int(math.MaxInt), int(math.MinInt), int8(math.MinInt8), int8(math.MaxInt8),
int16(math.MinInt16), int16(math.MaxInt16), int32(math.MinInt32), int32(math.MaxInt32),
int64(math.MinInt64), int64(math.MaxInt64), int64(9007199254740993), "", "こんにちは\x00' OR 1=1 --",
instant, time.Date(0, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(9999, 12, 31, 23, 59, 59, 999999999, time.UTC),
uuid.MustParse("01234567-89ab-cdef-0123-456789abcdef"), uuid.Nil(),
}
for _, value := range values {
t.Run(reflect.TypeOf(value).String(), func(t *testing.T) {
token, err := encodeCursor("scalar/v1", []any{value})
if err != nil {
t.Fatal(err)
}
decoded, err := decodeCursor(token, scalarSort())
if err != nil {
t.Fatal(err)
}
expected := value
if v, ok := value.(time.Time); ok {
expected = v.UTC()
}
if !reflect.DeepEqual(decoded, []any{expected}) {
t.Fatalf("type/value: got %#v want %#v", decoded, expected)
}
p, err := Prepare(scalarSort(), func(v any) []any { return []any{v} }, Request{Cursor: token}, Limits{Default: 1, Maximum: 2})
if err != nil {
t.Fatal(err)
}
q := psql.Select(Mod[*pd.SelectQuery](p))
_, args, err := bob.Build(t.Context(), q)
if err != nil || !reflect.DeepEqual(args, decoded) {
t.Fatalf("bound values: %#v %v", args, err)
}
if tm, ok := expected.(time.Time); ok {
data, _ := base64.RawURLEncoding.DecodeString(token)
if !strings.Contains(string(data), tm.Format(time.RFC3339Nano)) {
t.Fatalf("non-UTC timestamp: %s", data)
}
}
})
}
}
func TestUnsupportedValues(t *testing.T) {
type named int64
n := 1
values := []any{nil, named(1), uint(1), uint64(1), float64(1), true, &n, []byte("x"), struct{}{}, "\xff", time.Date(-1, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC)}
for _, value := range values {
p, err := Prepare(scalarSort(), func(v any) []any { return []any{v} }, Request{}, Limits{Default: 1, Maximum: 2})
if err != nil {
t.Fatal(err)
}
result, err := p.Page([]any{value})
var valueError *ValueError
if !errors.As(err, &valueError) || valueError.Position != 0 || !reflect.DeepEqual(result, Page[any]{}) {
t.Fatalf("%T: %+v %v", value, result, err)
}
}
type alias = int64
if _, err := encodeCursor("scalar/v1", []any{alias(1)}); err != nil {
t.Fatal(err)
}
}
func TestMalformedCursors(t *testing.T) {
valid := `{"v":1,"sort":"scalar/v1","values":[["int64","1"]]}`
cases := []struct{ data, kind string }{
{`null`, "malformed"}, {`[]`, "malformed"}, {`{`, "malformed"},
{valid + ` {}`, "malformed"},
{strings.Replace(valid, `"v":1`, `"v":2`, 1), "version"},
{strings.Replace(valid, `"v":1`, `"v":null`, 1), "malformed"},
{strings.Replace(valid, `"v":1`, `"v":1,"v":1`, 1), "malformed"},
{strings.Replace(valid, `"v":1,`, ``, 1), "malformed"},
{strings.Replace(valid, `"v":1`, `"v":1,"columns":[]`, 1), "malformed"},
{strings.Replace(valid, `scalar/v1`, `other/v1`, 1), "sort"},
{strings.Replace(valid, `"scalar/v1"`, `null`, 1), "malformed"},
{strings.Replace(valid, `[["int64","1"]]`, `null`, 1), "malformed"},
{strings.Replace(valid, `[["int64","1"]]`, `[]`, 1), "malformed"},
{strings.Replace(valid, `[["int64","1"]]`, `[["int64","1"],["int64","2"]]`, 1), "malformed"},
}
for _, entry := range []string{`null`, `{}`, `{"type":"int64","value":"1"}`, `[]`, `["int64"]`, `["int64","1","x"]`, `[null,"1"]`, `["int64",null]`, `["int64",1]`, `[1,"1"]`} {
cases = append(cases, struct{ data, kind string }{strings.Replace(valid, `["int64","1"]`, entry, 1), "malformed"})
}
for _, pair := range [][2]string{{"what", "1"}, {"int8", "128"}, {"int8", "-129"}, {"int16", "32768"}, {"int32", "2147483648"}, {"int64", "9223372036854775808"}, {"int", "999999999999999999999"}, {"int64", "01"}, {"int64", "+1"}, {"int64", "-0"}, {"int64", "1.0"}, {"time", "10000-01-01T00:00:00Z"}, {"time", "yesterday"}, {"time", "9999-12-31T23:59:59-01:00"}, {"time", "0000-01-01T00:00:00+01:00"}, {"uuid", "broken"}} {
data, _ := json.Marshal(pair)
cases = append(cases, struct{ data, kind string }{strings.Replace(valid, `["int64","1"]`, string(data), 1), "value"})
}
for i, tc := range cases {
_, err := decodeCursor(base64.RawURLEncoding.EncodeToString([]byte(tc.data)), scalarSort())
var ce *CursorError
if !errors.As(err, &ce) || ce.Kind != tc.kind {
t.Fatalf("case %d %s: %v", i, tc.data, err)
}
}
for _, token := range []string{"!", "====", base64.RawURLEncoding.EncodeToString([]byte(valid)) + "\n", base64.RawURLEncoding.EncodeToString([]byte("\xff"))} {
if _, err := decodeCursor(token, scalarSort()); err == nil {
t.Fatalf("accepted %q", token)
}
}
// A well-formed edit is a new seek boundary, not an authentication failure.
edited := strings.Replace(valid, `"1"`, `"9007199254740993"`, 1)
got, err := decodeCursor(base64.RawURLEncoding.EncodeToString([]byte(edited)), scalarSort())
if err != nil || got[0] != int64(9007199254740993) {
t.Fatalf("edited boundary: %v %v", got, err)
}
}
func TestCursorSize(t *testing.T) {
// Find the exact final-envelope boundary instead of assuming an overhead size.
var at string
var length int
for length = 12000; length < 12400; length++ {
token, err := encodeCursor("scalar/v1", []any{strings.Repeat("x", length)})
if err == nil && len(token) == maxCursorBytes {
at = token
break
}
}
if at == "" {
t.Fatal("exact boundary not found")
}
if _, err := decodeCursor(at, scalarSort()); err != nil {
t.Fatal(err)
}
var ce *CursorError
if _, err := decodeCursor(at+"A", scalarSort()); !errors.As(err, &ce) || ce.Kind != "size" {
t.Fatalf("received limit: %v", err)
}
p, err := Prepare(scalarSort(), func(v string) []any { return []any{v} }, Request{}, Limits{Default: 1, Maximum: 2})
if err != nil {
t.Fatal(err)
}
result, err := p.Page([]string{strings.Repeat("x", length+1)})
if !errors.As(err, &ce) || ce.Kind != "size" || !reflect.DeepEqual(result, Page[string]{}) {
t.Fatalf("emitted limit: %+v %v", result, err)
}
if _, err := encodeCursor("scalar/v1", []any{strings.Repeat("x", length-1)}); err != nil {
t.Fatal(err)
}
// Failure in an overflow row also invalidates the entire page.
result, err = p.Page([]string{"valid", strings.Repeat("x", length+1)})
if !errors.As(err, &ce) || !reflect.DeepEqual(result, Page[string]{}) {
t.Fatalf("overflow: %+v %v", result, err)
}
}
func TestPrepareAndKeyValidation(t *testing.T) {
limits := Limits{Default: 2, Maximum: 3}
badSorts := []Sort{{}, {ID: "x"}, {ID: "x", Columns: []Column{{Unique: true}}}, {ID: "x", Columns: []Column{{Expression: expr.Quote("id"), Direction: Order(2), Unique: true}}}, {ID: "x", Columns: []Column{{Expression: expr.Quote("id")}}}}
key := func(v int) []any { return []any{v} }
for _, sort := range badSorts {
if _, err := Prepare(sort, key, Request{}, limits); err == nil {
t.Fatalf("accepted sort: %+v", sort)
}
}
if _, err := Prepare[int](scalarSort(), nil, Request{}, limits); err == nil {
t.Fatal("nil key accepted")
}
for _, l := range []Limits{{}, {Default: 2, Maximum: 1}, {Default: 1, Maximum: math.MaxInt}} {
if _, err := Prepare(scalarSort(), key, Request{}, l); err == nil {
t.Fatalf("accepted limits %+v", l)
}
}
for _, r := range []Request{{Size: -1}, {Direction: Direction(2)}} {
if _, err := Prepare(scalarSort(), key, r, limits); err == nil {
t.Fatal("bad request accepted")
}
}
for _, size := range []int{0, 1, 3, 4, math.MaxInt} {
p, err := Prepare(scalarSort(), key, Request{Size: size}, limits)
want := min(size, 3)
if size == 0 {
want = 2
}
if err != nil || p.size != want {
t.Fatalf("size %d: %+v %v", size, p, err)
}
}
for _, badLength := range []int{0, 2} {
p, err := Prepare(scalarSort(), func(v int) []any {
if v == 9 {
return make([]any, badLength)
}
return []any{v}
}, Request{Size: 1}, limits)
if err != nil {
t.Fatal(err)
}
result, err := p.Page([]int{1, 9})
var ke *KeyError
if !errors.As(err, &ke) || ke.Expected != 1 || ke.Actual != badLength || !reflect.DeepEqual(result, Page[int]{}) {
t.Fatalf("key: %+v %v", result, err)
}
}
}
-69
View File
@@ -1,69 +0,0 @@
package page_test
import (
"context"
"errors"
"fmt"
"github.com/stephenafamo/bob"
"github.com/stephenafamo/bob/dialect/sqlite"
sd "github.com/stephenafamo/bob/dialect/sqlite/dialect"
"github.com/stephenafamo/bob/dialect/sqlite/sm"
"github.com/stephenafamo/bob/expr"
"github.com/stephenafamo/scan"
"go.auvem.com/corestack/page"
)
func Example() {
ctx := context.Background()
db, err := bob.Open("sqlite3", ":memory:")
if err != nil {
panic(err)
}
defer db.Close()
db.SetMaxOpenConns(1)
if _, err := db.ExecContext(ctx, "CREATE TABLE orders (id BIGINT PRIMARY KEY, priority BIGINT NOT NULL, tenant BIGINT NOT NULL)"); err != nil {
panic(err)
}
if _, err := db.ExecContext(ctx, "INSERT INTO orders VALUES (1,10,7),(2,10,7),(3,5,7),(4,20,8)"); err != nil {
panic(err)
}
type Order struct {
ID int64
Priority int64
}
sort := page.Sort{ID: "orders/priority/v1", Columns: []page.Column{
{Expression: expr.Quote("priority"), Direction: page.Desc},
{Expression: expr.Quote("id"), Direction: page.Asc, Unique: true},
}}
// With Bob-generated models, use their column references for these expressions.
// The key must independently match their order and actual stored value types.
key := func(o Order) []any { return []any{o.Priority, o.ID} }
p, err := page.Prepare(sort, key, page.Request{Size: 2}, page.Limits{Default: 2, Maximum: 100})
if err != nil {
panic(err)
}
q := sqlite.Select(sm.Columns("id", "priority"), sm.From("orders"), page.Mod[*sd.SelectQuery](p))
q.Apply(sm.Where(sqlite.Quote("tenant").EQ(sqlite.Arg(int64(7)))))
rows, err := bob.All(ctx, db, q, scan.StructMapper[Order]())
if err != nil {
panic(err)
}
result, err := p.Page(rows)
if err != nil {
panic(err)
}
fmt.Println(result.Items, result.HasNext, result.HasPrevious)
// Continue with Forward + result.EndCursor, or Backward + result.StartCursor.
// A mismatched key returns no partial Page, including for an overflow row.
bad, err := page.Prepare(sort, func(o Order) []any { return []any{o.ID} }, page.Request{}, page.Limits{Default: 2, Maximum: 100})
if err != nil {
panic(err)
}
_, err = bad.Page(rows)
var keyError *page.KeyError
fmt.Println(errors.As(err, &keyError), keyError.Expected, keyError.Actual)
// Output:
// [{1 10} {2 10}] true false
// true 2 1
}
-184
View File
@@ -1,184 +0,0 @@
// Package page provides transport-neutral keyset pagination for Bob SELECT queries.
// Callers own authorization, non-null stored sort values, and correspondence between
// Sort columns and the typed row key. The final column must itself be unique in the
// selected relation. Mixed directions are supported; null sort values are not.
//
// Sort IDs version the expressions, directions, key types and comparison semantics.
// Cursors expose values and allow edits; they are seek boundaries, not credentials.
// Concurrent writes or changed filters can skip or repeat rows between requests.
// Code generation may eventually align Sort and key; handwritten pairs have no
// compile-time field/type correspondence guarantee.
//
// Keys support exactly int, int8, int16, int32, int64, string, time.Time and the
// standard uuid.UUID. Named types, unsigned values, pointers and null wrappers
// require explicit caller conversion. Timestamps normalize to UTC with nanosecond
// precision. Versioned JSON cursors use unpadded base64url, limited to 16,384 bytes
// after encoding; unsupported values and malformed cursors return typed errors.
package page
import (
"fmt"
"slices"
"unicode/utf8"
"github.com/stephenafamo/bob"
)
// Order is a column's canonical ordering direction.
type Order uint8
const (
// Asc orders smaller values first.
Asc Order = iota
// Desc orders larger values first.
Desc
)
// Column declares a trusted, non-null SQL expression and its canonical direction.
// Unique asserts that this column alone is unique, including across joins.
type Column struct {
Expression bob.Expression
Direction Order
Unique bool
}
// Sort is an immutable, caller-owned ordering contract. Change ID when its
// expressions, order, key types, uniqueness or comparison semantics change.
type Sort struct {
ID string
Columns []Column
}
// Direction selects which side of an optional anchor to fetch.
type Direction uint8
const (
// Forward maps to first/after and is the default direction.
Forward Direction = iota
// Backward maps to last/before; returned items retain canonical sort order.
Backward
)
// Request carries a transport-independent page request. Zero Size uses the
// configured default; excessive sizes clamp to Maximum. Empty Cursor has no anchor.
type Request struct {
Direction Direction
Size int
Cursor string
}
// Limits configures page sizes; require 1 <= Default <= Maximum < max int.
type Limits struct {
Default int
Maximum int
}
// KeyError identifies a programmer error in the row key's arity.
type KeyError struct {
Expected int
Actual int
}
// Error implements error with the expected and actual key lengths.
func (e *KeyError) Error() string {
return fmt.Sprintf("page: key length %d, expected %d", e.Actual, e.Expected)
}
// Page contains canonical-order items and cursors for their first and last rows.
// Empty pages have empty cursors. Overflow determines the fetch-direction flag;
// anchor presence determines the opposite flag, even for empty/stale results.
// The opposite flag does not prove another row exists.
type Page[T any] struct {
Items []T
StartCursor string
EndCursor string
HasNext bool
HasPrevious bool
}
// Prepared holds validated pagination and the typed row key. Construct with Prepare.
// Treat Sort expressions and the key's captured state as immutable while in use.
type Prepared[T any] struct {
sort Sort
key func(T) []any
direction Direction
size int
anchor []any
}
// Prepare validates a request before a Bob mod can be applied. key must return
// exactly one supported concrete value per Sort column, in the same order, using
// actual stored/scanned values (including database timestamp precision).
func Prepare[T any](sort Sort, key func(T) []any, request Request, limits Limits) (*Prepared[T], error) {
if sort.ID == "" || !utf8.ValidString(sort.ID) || len(sort.Columns) == 0 {
return nil, fmt.Errorf("page: sort needs a valid ID and columns")
}
for i, c := range sort.Columns {
if c.Expression == nil || c.Direction > Desc {
return nil, fmt.Errorf("page: invalid sort column %d", i)
}
}
if !sort.Columns[len(sort.Columns)-1].Unique {
return nil, fmt.Errorf("page: final sort column must be unique")
}
if key == nil {
return nil, fmt.Errorf("page: nil key function")
}
if limits.Default < 1 || limits.Maximum < limits.Default || limits.Maximum == int(^uint(0)>>1) {
return nil, fmt.Errorf("page: invalid limits")
}
if request.Direction > Backward || request.Size < 0 {
return nil, fmt.Errorf("page: invalid request direction or size")
}
size := request.Size
if size == 0 {
size = limits.Default
}
size = min(size, limits.Maximum)
sort.Columns = slices.Clone(sort.Columns)
p := &Prepared[T]{sort: sort, key: key, direction: request.Direction, size: size}
if request.Cursor != "" {
var err error
p.anchor, err = decodeCursor(request.Cursor, sort)
if err != nil {
return nil, err
}
}
return p, nil
}
// Page validates all fetched rows, including overflow, and assembles a page.
// Pass rows in query order, with at most size+1 rows. It copies retained items;
// backward pagination never reverses the caller's slice. Errors return no page.
func (p *Prepared[T]) Page(rows []T) (Page[T], error) {
var zero Page[T]
if len(rows) > p.size+1 {
return zero, fmt.Errorf("page: too many fetched rows")
}
cursors := make([]string, len(rows))
for i, row := range rows {
key := p.key(row)
if len(key) != len(p.sort.Columns) {
return zero, &KeyError{Expected: len(p.sort.Columns), Actual: len(key)}
}
var err error
cursors[i], err = encodeCursor(p.sort.ID, key)
if err != nil {
return zero, err
}
}
overflow := len(rows) > p.size
n := min(len(rows), p.size)
result := Page[T]{Items: slices.Clone(rows[:n])}
if n > 0 {
result.StartCursor, result.EndCursor = cursors[0], cursors[n-1]
}
if p.direction == Backward {
slices.Reverse(result.Items)
result.StartCursor, result.EndCursor = result.EndCursor, result.StartCursor
result.HasPrevious, result.HasNext = overflow, len(p.anchor) > 0
} else {
result.HasNext, result.HasPrevious = overflow, len(p.anchor) > 0
}
return result, nil
}
-68
View File
@@ -1,68 +0,0 @@
package page
import (
"context"
"fmt"
"github.com/stephenafamo/bob"
"github.com/stephenafamo/bob/clause"
"github.com/stephenafamo/bob/expr"
"github.com/stephenafamo/scan"
)
// Mod applies validated keyset WHERE, ORDER BY and integer LIMIT size+1 to native
// PostgreSQL, SQLite or MySQL selects. Apply once to a query with no existing
// ORDER BY, LIMIT, OFFSET or FETCH; do not override pagination afterward.
// Callers may compose additional WHERE filters before or after this mod.
func Mod[Q interface {
AppendWhere(...any)
AppendOrder(bob.Expression)
SetLimit(any)
}, T any](p *Prepared[T]) bob.Mod[Q] {
return bob.ModFunc[Q](func(q Q) {
var alternatives []bob.Expression
for i, c := range p.sort.Columns {
desc := c.Direction == Desc
if p.direction == Backward {
desc = !desc
}
direction, operator := "ASC", ">"
if desc {
direction, operator = "DESC", "<"
}
q.AppendOrder(clause.OrderDef{Expression: c.Expression, Direction: direction})
if len(p.anchor) == 0 {
continue
}
terms := make([]bob.Expression, 0, i+1)
for j := 0; j < i; j++ {
terms = append(terms, expr.OP("=", p.sort.Columns[j].Expression, expr.Arg(p.anchor[j])))
}
terms = append(terms, expr.OP(operator, c.Expression, expr.Arg(p.anchor[i])))
alternatives = append(alternatives, grouped(terms, " AND "))
}
// authz: group the seek disjunction so caller filters constrain every branch.
if len(alternatives) > 0 {
q.AppendWhere(grouped(alternatives, " OR "))
}
q.SetLimit(p.size + 1)
})
}
func grouped(expressions []bob.Expression, separator string) bob.Expression {
return expr.Join{Exprs: []bob.Expression{
expr.Raw("("), expr.Join{Exprs: expressions, Sep: separator}, expr.Raw(")"),
}, Sep: expr.NoSep}
}
// Count executes a separate caller-built, authorized count query returning one
// int64 column. It never rewrites a paginated query or infers grouped/distinct
// semantics. Nothing runs unless called.
func Count(ctx context.Context, executor bob.Executor, query bob.Query) (int64, error) {
// authz: callers supply the same authorized relation as their item query.
count, err := bob.One(ctx, executor, query, scan.SingleColumnMapper[int64])
if err != nil {
return 0, fmt.Errorf("page: count: %w", err)
}
return count, nil
}
-189
View File
@@ -1,189 +0,0 @@
package page_test
import (
"database/sql"
"os"
"reflect"
"errors"
"testing"
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
"github.com/stephenafamo/bob"
"github.com/stephenafamo/bob/dialect/mysql"
md "github.com/stephenafamo/bob/dialect/mysql/dialect"
mm "github.com/stephenafamo/bob/dialect/mysql/sm"
"github.com/stephenafamo/bob/dialect/psql"
pd "github.com/stephenafamo/bob/dialect/psql/dialect"
pm "github.com/stephenafamo/bob/dialect/psql/sm"
"github.com/stephenafamo/bob/dialect/sqlite"
sd "github.com/stephenafamo/bob/dialect/sqlite/dialect"
"github.com/stephenafamo/bob/dialect/sqlite/sm"
"github.com/stephenafamo/bob/expr"
"github.com/stephenafamo/scan"
"go.auvem.com/corestack/page"
)
type item struct {
ID int64
Score int64
}
func itemSort() page.Sort {
return page.Sort{ID: "items/score/v1", Columns: []page.Column{
{Expression: expr.Quote("score"), Direction: page.Desc},
{Expression: expr.Quote("id"), Direction: page.Asc, Unique: true},
}}
}
func itemKey(i item) []any { return []any{i.Score, i.ID} }
func TestSQLiteFlow(t *testing.T) {
db, err := bob.Open("sqlite3", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
databaseFlow(t, db, false)
}
func TestPostgresFlow(t *testing.T) {
dsn := os.Getenv("PAGE_POSTGRES_DSN")
if dsn == "" {
if os.Getenv("PAGE_REQUIRE_POSTGRES") != "" {
t.Fatal("PAGE_POSTGRES_DSN is required")
}
t.Skip("set PAGE_POSTGRES_DSN to run PostgreSQL integration")
}
db, err := bob.Open("postgres", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
databaseFlow(t, db, true)
}
func databaseFlow(t *testing.T, db bob.DB, postgres bool) {
t.Helper()
db.SetMaxOpenConns(1)
for _, statement := range []string{
"CREATE TEMP TABLE items (id BIGINT PRIMARY KEY, score BIGINT NOT NULL, tenant BIGINT NOT NULL)",
"INSERT INTO items VALUES (1,30,1),(2,30,1),(3,20,1),(4,10,1),(5,5,2),(6,30,2)",
} {
if _, err := db.ExecContext(t.Context(), statement); err != nil {
t.Fatal(err)
}
}
fetch := func(direction page.Direction, size int, cursor string) page.Page[item] {
t.Helper()
p, err := page.Prepare(itemSort(), itemKey, page.Request{Direction: direction, Size: size, Cursor: cursor}, page.Limits{Default: 2, Maximum: 3})
if err != nil {
t.Fatal(err)
}
var q bob.Query
if postgres {
query := psql.Select(pm.Columns("id", "score"), pm.From("items"), pm.Where(psql.Quote("tenant").EQ(psql.Arg(int64(1)))))
query.Apply(page.Mod[*pd.SelectQuery](p))
query.Apply(pm.Where(psql.Quote("score").GT(psql.Arg(int64(0)))))
q = query
} else {
query := sqlite.Select(sm.Columns("id", "score"), sm.From("items"), sm.Where(sqlite.Quote("tenant").EQ(sqlite.Arg(int64(1)))))
query.Apply(page.Mod[*sd.SelectQuery](p))
query.Apply(sm.Where(sqlite.Quote("score").GT(sqlite.Arg(int64(0)))))
q = query
}
rows, err := bob.All(t.Context(), db, q, scan.StructMapper[item]())
if err != nil {
t.Fatal(err)
}
before := append([]item(nil), rows...)
result, err := p.Page(rows)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(before, rows) {
t.Fatal("Page mutated fetched slice")
}
return result
}
check := func(got page.Page[item], ids []int64, next, previous bool) {
t.Helper()
actual := make([]int64, len(got.Items))
for i, row := range got.Items {
actual[i] = row.ID
}
if !reflect.DeepEqual(actual, ids) || got.HasNext != next || got.HasPrevious != previous {
t.Fatalf("got IDs %v next=%v previous=%v; want %v %v %v", actual, got.HasNext, got.HasPrevious, ids, next, previous)
}
if len(ids) == 0 && (got.StartCursor != "" || got.EndCursor != "") {
t.Fatal("empty page has cursors")
}
if len(ids) > 0 && (got.StartCursor == "" || got.EndCursor == "") {
t.Fatal("missing cursors")
}
}
first := fetch(page.Forward, 2, "")
check(first, []int64{1, 2}, true, false)
second := fetch(page.Forward, 2, first.EndCursor)
check(second, []int64{3, 4}, false, true)
check(fetch(page.Forward, 2, second.EndCursor), []int64{}, false, true)
last := fetch(page.Backward, 2, "")
check(last, []int64{3, 4}, false, true)
previous := fetch(page.Backward, 2, last.StartCursor)
check(previous, []int64{1, 2}, true, false)
check(fetch(page.Backward, 2, previous.StartCursor), []int64{}, true, false)
check(fetch(page.Forward, 0, ""), []int64{1, 2}, true, false)
check(fetch(page.Forward, 1000, ""), []int64{1, 2, 3}, true, false)
// A cursor describes a value boundary even after its row is deleted.
if _, err := db.ExecContext(t.Context(), "DELETE FROM items WHERE id=2"); err != nil {
t.Fatal(err)
}
check(fetch(page.Forward, 2, first.EndCursor), []int64{3, 4}, false, true)
var countQuery, emptyQuery bob.Query
if postgres {
countQuery = psql.Select(pm.Columns("count(*)"), pm.From("items"), pm.Where(psql.Quote("tenant").EQ(psql.Arg(1))))
emptyQuery = psql.Select(pm.Columns("id"), pm.From("items"), pm.Where(psql.Quote("id").LT(psql.Arg(0))))
} else {
countQuery = sqlite.Select(sm.Columns("count(*)"), sm.From("items"), sm.Where(sqlite.Quote("tenant").EQ(sqlite.Arg(1))))
emptyQuery = sqlite.Select(sm.Columns("id"), sm.From("items"), sm.Where(sqlite.Quote("id").LT(sqlite.Arg(0))))
}
count, err := page.Count(t.Context(), db, countQuery)
if err != nil || count != 3 {
t.Fatalf("count %d: %v", count, err)
}
if _, err := page.Count(t.Context(), db, emptyQuery); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("count error lost cause: %v", err)
}
_, err = page.Prepare(itemSort(), itemKey, page.Request{Cursor: "not!base64"}, page.Limits{Default: 2, Maximum: 3})
var cursorError *page.CursorError
if !errors.As(err, &cursorError) {
t.Fatalf("invalid cursor: %v", err)
}
bad, err := page.Prepare(itemSort(), func(item) []any { return []any{int64(1)} }, page.Request{}, page.Limits{Default: 2, Maximum: 3})
if err != nil {
t.Fatal(err)
}
result, err := bad.Page([]item{{ID: 1}})
var keyError *page.KeyError
if !errors.As(err, &keyError) || !reflect.DeepEqual(result, page.Page[item]{}) {
t.Fatalf("key failure: %+v %v", result, err)
}
if _, err := db.ExecContext(t.Context(), "DELETE FROM items"); err != nil {
t.Fatal(err)
}
check(fetch(page.Forward, 2, ""), []int64{}, false, false)
check(fetch(page.Backward, 2, ""), []int64{}, false, false)
check(fetch(page.Forward, 2, first.EndCursor), []int64{}, false, true)
check(fetch(page.Backward, 2, last.StartCursor), []int64{}, true, false)
}
func TestMySQLModComposition(t *testing.T) {
p, err := page.Prepare(itemSort(), itemKey, page.Request{}, page.Limits{Default: 2, Maximum: 3})
if err != nil {
t.Fatal(err)
}
q := mysql.Select(mm.Columns("id", "score"), mm.From("items"), page.Mod[*md.SelectQuery](p))
q.Apply(mm.Where(mysql.Quote("tenant").EQ(mysql.Arg(int64(1)))))
// Compile-time composition only; MySQL runtime behavior needs a database test.
}