From e5298cff37f4beea2719935cf874269bdf31912b Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Tue, 27 Oct 2026 15:01:50 -0700 Subject: [PATCH 1/2] feat(lifecycle): supervise services and bounded shutdown (#13) Add ordered setup, reverse rollback and teardown, concurrent services, and managed background workers using only the standard library. Coordinate context cancellation, injectable signals and joined errors within one configurable shutdown deadline. Include race tests, a two-service example, and the accepted lifecycle ADR. Closes #5 --- README.md | 27 +- ...aged-workers-within-one-shutdown-budget.md | 75 ++ lifecycle/example_test.go | 102 +++ lifecycle/lifecycle.go | 349 +++++++++ lifecycle/lifecycle_test.go | 676 ++++++++++++++++++ 5 files changed, 1228 insertions(+), 1 deletion(-) create mode 100644 docs/adr/20261027T184038082Z-supervise-module-lifecycle-and-managed-workers-within-one-shutdown-budget.md create mode 100644 lifecycle/example_test.go create mode 100644 lifecycle/lifecycle.go create mode 100644 lifecycle/lifecycle_test.go diff --git a/README.md b/README.md index a8fa370..2d63b5c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # corestack go -Shared Go building blocks. The first one is `docs`, a checker for architecture decision records (ADRs) and implementation plans. +Shared Go building blocks. Includes `docs`, a checker for architecture decision records (ADRs) and implementation plans, and `lifecycle`, a service and worker coordinator. ## docs @@ -20,6 +20,31 @@ 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. +## lifecycle + +`go.auvem.com/corestack/lifecycle` sets up modules in registration order, runs +services concurrently, and tears down successful setups in reverse order. +Construct an `App` with a required `*slog.Logger`, register `Module` callbacks, +and call `Run(ctx)` once. See the [two-service example](lifecycle/example_test.go). + +Launch background work with `app.Go(ctx, name, fn)` using the callback's lifecycle +context or a descendant, and handle admission errors. Run waits for every service +and admitted worker; raw goroutines remain your responsibility. First failure or +an injected signal cancels work before teardown. One shared shutdown deadline +(default 30 seconds) covers hooks and callback completion; a second signal +abandons shutdown. Callbacks must cooperate: abandonment cannot kill goroutines, +and the caller owns process exit and any unreleased resources. + +During App-initiated shutdown, callback errors containing only the callback's +context cancellation are omitted. Mixed cancellation and independent failures +are retained intact to preserve wrapper types, identity and custom matching; +these errors may still match cancellation through `errors.Is`. + +The caller owns signal subscription, SIGINT/SIGTERM filtering, a channel buffered +for at least two signals, and subscription cleanup. Nil signals disable reception. +There are no signal subscriptions, process exits, external dependencies or CLI +integration in this package. + ## Status No support is guaranteed. Breaking changes, including to the format rules, may happen before 1.0 and are listed in the release notes. diff --git a/docs/adr/20261027T184038082Z-supervise-module-lifecycle-and-managed-workers-within-one-shutdown-budget.md b/docs/adr/20261027T184038082Z-supervise-module-lifecycle-and-managed-workers-within-one-shutdown-budget.md new file mode 100644 index 0000000..15a90bf --- /dev/null +++ b/docs/adr/20261027T184038082Z-supervise-module-lifecycle-and-managed-workers-within-one-shutdown-budget.md @@ -0,0 +1,75 @@ +--- +title: "Supervise module lifecycle and managed workers within one shutdown budget" +date: 2026-10-27 +status: accepted +implementation: implemented +summary: Order module startup and reverse cleanup while supervising services and background workers under one bounded shutdown. +tags: + - lifecycle +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/5 +implemented_by: + - https://gitea.auvem.com/corestack/go/pulls/13 +--- + +# Supervise module lifecycle and managed workers within one shutdown budget + +## Context and Problem Statement + +[Issue 5](https://gitea.auvem.com/corestack/go/issues/5) needs ordered startup and supervised concurrent services for Lark Relay's daemons and muxer. Its [follow-up](https://gitea.auvem.com/corestack/go/issues/5#issuecomment-7805) also requires explicit coordination of background workers: canceling service contexts alone cannot establish that the entire app has stopped. + +The owner approved a standard-library-only root package, registration-order setup, reverse rollback/teardown, injected signals and logging, joined errors, and one configurable shutdown budget (default 30 seconds) covering teardown and worker completion. Cancellation precedes teardown, which may unblock workers; a second signal abandons shutdown. The owner accepted the API and ownership details after independent review, including the R1/R2/R3 clarifications. It is a clean-room design; no go-toolkit/app source is used. + +## Considered Options + +* Keep coordination in each application's main function: least library code, but duplicates signal, rollback and worker ownership rules across the initial consumers. +* Provide only ordered hooks and service supervision: smaller package, but callers still need a second worker coordinator to satisfy the follow-up and share its shutdown deadline. +* Provide ordered hooks plus a managed-worker launch method: one owner for cancellation, registration closure, errors and the shutdown budget, without a dependency graph or framework. + +## Decision Outcome + +Choose the third option in `go.auvem.com/corestack/lifecycle`, sharing root-module versions under the accepted module-boundary decision. No CLI integration, dependency DAG, external dependency, restart policy or UI is included. + +The public surface is: + +```go +type Module struct { + Name string + Setup func(context.Context) error + Teardown func(context.Context) error + Run func(context.Context) error // optional +} +type Config struct { + Logger *slog.Logger + Signals <-chan os.Signal + ShutdownTimeout time.Duration +} +type App struct { /* private state; no stored context */ } +func New(config Config) (*App, error) +func (a *App) Register(module Module) error +func (a *App) Go(ctx context.Context, name string, fn func(context.Context) error) error +func (a *App) Run(ctx context.Context) error +``` + +The accepted signatures have the following edge semantics: + +* `New` requires a non-nil logger; zero timeout selects 30 seconds and negative durations fail. `Register` requires a unique nonempty module name and non-nil setup/teardown. Registration closes when `Run` begins; an App runs once. Invalid or late calls return errors without launching work. Configuration is captured by `New` and modules by `Register`, with no global state or initialization side effects. +* `Run` sets up modules sequentially in registration order using a shared child of its parent context. Only after every setup succeeds does it launch the optional service callbacks concurrently. Dependencies are expressed by registration order and ordinary Go wiring, not lookup or dependency injection APIs. Service scheduling order is unspecified. +* `Go` is the explicit tooling for background work. A setup or service callback can capture the App and call `Go`; the supplied context must be that callback's lifecycle context or a descendant. Context ancestry is a caller precondition that App cannot generally enforce; detached contexts forfeit graceful cancellation. It launches `fn(ctx)` immediately, tracks completion and errors, and returns once admitted; admission is atomic with shutdown closure. It is available only during setup/running, accepts a nonempty diagnostic name and non-nil function, and is safe for concurrent callers. Worker names need not be unique. Callers must handle admission errors. Raw goroutines remain the caller's responsibility and cannot be included in the completion guarantee. +* Once startup completes, normal completion means every service and admitted worker has returned nil; no services/workers means immediate normal shutdown. A nil return from just one service/worker does not stop the others. Normal completion atomically closes worker admission so a racing launch is either tracked or rejected. Before startup completes, temporarily having no workers does not end the run. +* The first setup/service/worker failure, parent cancellation, or received SIGINT/SIGTERM starts shutdown and closes worker admission. Cancel the shared context first, then tear down successfully completed setups in reverse order while services/workers unwind concurrently. Do not wait for workers before teardown: closing a listener or queue may be what releases them. A failing setup owns cleanup of its partial acquisition and is not torn down by App. +* One fresh shutdown context retains parent values but is detached from parent cancellation and has the configured deadline, measured from the shutdown trigger. Reverse teardown hooks run sequentially with that context; all admitted services/workers share the same remaining completion budget. An in-flight setup is canceled and awaited only within this budget before reverse rollback, avoiding concurrent setup and teardown. Its successful return before abandonment adds it to rollback; failure does not. No further setup starts after shutdown begins. +* The deadline or a second signal cancels the shutdown context and returns without starting remaining teardown hooks or waiting further for callbacks. App must observe setup/teardown results asynchronously so an uncooperative hook cannot block its return. App will not later call teardown for an abandoned setup, even if it later succeeds; its acquisitions may leak until caller-owned process exit. A cancellation-aware setup must clean up partial acquisitions and return an error when it observes cancellation. Completion reporting must not block after App returns. Go cannot forcibly kill uncooperative goroutines: the bound covers App's coordination and return, not guaranteed resource release or goroutine termination. Callbacks must honor cancellation, and the caller owns process exit and must not reuse abandoned resources. Injected logging must not block shutdown. +* The caller owns OS signal subscription, filtering to SIGINT/SIGTERM, buffering (at least two), and subscription cleanup. App only receives from the injected channel, never closes it or calls `signal.Notify`, `signal.Stop`, or `os.Exit`. Nil disables signals and a closed channel is disabled. If shutdown began for another reason, the first subsequently received signal counts as the first signal; the next abandons shutdown. Signal counting spans startup and shutdown. +* Wrap callback failures with module/worker name and phase and combine observed errors using `errors.Join`; retain every completed teardown error, including cancellation errors. A setup/service/worker error solely wrapping its callback context's cancellation/deadline caused by an already-established shutdown is expected and omitted; independent failures and non-cancellation components of joined errors remain errors. An instance-private cancellation cause proves App-initiated cancellation; retain ambiguous parent-propagated cancellation conservatively, including independently canceled descendants. The runtime context remains a direct child of its parent and preserves its deadline. The initiating setup failure remains an error. Parent cancellation returns its context error; graceful signal/normal completion returns nil if cleanup succeeds. Deadline abandonment adds `context.DeadlineExceeded`; signal abandonment adds an error wrapping `context.Canceled` with the signal as context. Retain errors already observed on abandonment, with no promise to collect later results or deterministic concurrent-error order. Do not recover panics. + +### Consequences + +* One coordinator owns admission, cancellation, hook ordering and completion accounting. Applications must route background work through `Go` and supply cooperative callbacks to claim graceful whole-app completion; this is an explicit boundary, not automatic goroutine discovery. +* The shutdown bound requires asynchronous hook invocation and synchronized worker admission/completion, even when a consumer has no background workers. A blocked hook can consume the entire budget and leave earlier modules without teardown; a second signal deliberately accepts that same incomplete cleanup sooner. +* Delivery should remain approximately 300–500 production lines plus focused race tests, a two-service package Example and public/package docs, in one PR. Verify startup/reverse ordering, partial rollback, service and worker failure propagation, normal completion, cancellation/error classification (including a first signal during setup with successful partial cleanup and rollback returning nil, and an initiating setup failure remaining observable), admission races, teardown unblocking workers, a shared deadline (including an uncooperative hook), second signals and joined errors. Test with injected signals, release blocked test goroutines explicitly, and use no real process signals. +* No plan tier is needed. The accepted decision is implemented by [PR 13](https://gitea.auvem.com/corestack/go/pulls/13), including managed workers, bounded shutdown, focused race tests, the two-service example and public documentation. Independent standard and high implementation reviews are complete with no outstanding findings, and required local checks passed on the reviewed code. Publication is complete; CI and merge readiness are tracked in the PR. Delivery was reconciled against main at `46c4416ed44b4c8cfc69570b9bcb572b624d765e` plus the PR; this records implementation evidence, not a merge or release. diff --git a/lifecycle/example_test.go b/lifecycle/example_test.go new file mode 100644 index 0000000..6f5df46 --- /dev/null +++ b/lifecycle/example_test.go @@ -0,0 +1,102 @@ +package lifecycle_test + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "syscall" + "testing" + "time" + + "go.auvem.com/corestack/lifecycle" +) + +func TestRealFlow(t *testing.T) { + for _, failure := range []bool{false, true} { + t.Run(fmt.Sprint(failure), func(t *testing.T) { + signals := make(chan os.Signal, 2) + app, err := lifecycle.New(lifecycle.Config{Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Signals: signals, ShutdownTimeout: time.Second}) + if err != nil { + t.Fatal(err) + } + ready := make(chan struct{}, 3) + release := make(chan struct{}) + t.Cleanup(func() { + select { + case <-release: + default: + close(release) + } + }) + failed := errors.New("service failed") + for _, name := range []string{"daemon", "muxer"} { + err = app.Register(lifecycle.Module{Name: name, Setup: func(ctx context.Context) error { + if name == "daemon" { + return app.Go(ctx, "queue", func(ctx context.Context) error { ready <- struct{}{}; <-ctx.Done(); <-release; return ctx.Err() }) + } + return nil + }, Run: func(ctx context.Context) error { + ready <- struct{}{} + if name == "muxer" && failure { + <-release + return failed + } + <-ctx.Done() + return ctx.Err() + }, Teardown: func(context.Context) error { return nil }}) + if err != nil { + t.Fatal(err) + } + } + done := make(chan error, 1) + go func() { done <- app.Run(context.Background()) }() + for range 3 { + select { + case <-ready: + case <-time.After(3 * time.Second): + t.Fatal("services did not start") + } + } + if !failure { + signals <- syscall.SIGTERM + } + close(release) + select { + case err = <-done: + if failure && !errors.Is(err, failed) || !failure && err != nil { + t.Fatalf("Run: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Run blocked") + } + }) + } +} + +func Example() { + signals := make(chan os.Signal, 2) + app, err := lifecycle.New(lifecycle.Config{Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Signals: signals}) + if err != nil { + panic(err) + } + ready := make(chan struct{}, 2) + for _, name := range []string{"daemon", "muxer"} { + err := app.Register(lifecycle.Module{ + Name: name, + Setup: func(ctx context.Context) error { + return app.Go(ctx, name+" worker", func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() }) + }, + Run: func(ctx context.Context) error { ready <- struct{}{}; <-ctx.Done(); return ctx.Err() }, + Teardown: func(context.Context) error { return nil }, + }) + if err != nil { + panic(err) + } + } + go func() { <-ready; <-ready; signals <- syscall.SIGTERM }() + fmt.Println(app.Run(context.Background())) + // Output: +} diff --git a/lifecycle/lifecycle.go b/lifecycle/lifecycle.go new file mode 100644 index 0000000..21d1081 --- /dev/null +++ b/lifecycle/lifecycle.go @@ -0,0 +1,349 @@ +// Package lifecycle supervises ordered module setup, concurrent services and +// explicitly admitted workers within one shutdown budget. It owns cancellation +// and reverse teardown, not signal subscriptions, process exit or raw goroutines. +// Callbacks and the injected logger must cooperate: abandonment bounds Run's +// return, but cannot kill callbacks or reclaim their resources. +package lifecycle + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "sync" + "time" +) + +// Module describes one resource owner. Setup acquires resources; Teardown releases +// them after successful setup. A failing Setup owns its partial cleanup. Optional +// Run starts only after all setups succeed. Callbacks must honor cancellation. +type Module struct { + // Name identifies the module in errors. + Name string + // Setup acquires resources before any service starts. + Setup func(context.Context) error + // Teardown releases resources after successful Setup. + Teardown func(context.Context) error + // Run optionally serves until completion or cancellation. + Run func(context.Context) error +} + +// Config supplies application policy. Logger is required and must not block. +// Zero ShutdownTimeout selects 30 seconds; negative values are invalid. +// The caller owns Signals, its subscription and cleanup, filtering to SIGINT and +// SIGTERM and buffering at least two signals. Nil or closed disables reception. +type Config struct { + // Logger receives lifecycle diagnostics and must not block. + Logger *slog.Logger + // Signals is a caller-owned, filtered signal source. + Signals <-chan os.Signal + // ShutdownTimeout bounds all shutdown work together. + ShutdownTimeout time.Duration +} + +// App is a single-use coordinator constructed by New. Do not copy it. Register +// modules before Run; Go may be called concurrently during setup and running. +// App stores no context. After abandonment, callers own process exit and must not +// reuse resources whose callbacks have not returned. +type App struct { + config Config + mu sync.Mutex + modules []Module + used bool + accepting bool + stopping bool + active int + cancel context.CancelCauseFunc + shutdownCause error + stoppedAt time.Time + errs []error + wake chan struct{} +} + +// New validates and captures config without subscribing to signals or starting work. +func New(config Config) (*App, error) { + if config.Logger == nil { + return nil, errors.New("lifecycle: logger is required") + } + if config.ShutdownTimeout < 0 { + return nil, errors.New("lifecycle: negative shutdown timeout") + } + if config.ShutdownTimeout == 0 { + config.ShutdownTimeout = 30 * time.Second + } + return &App{config: config, wake: make(chan struct{}, 1)}, nil +} + +// Register captures a uniquely named module with required setup and teardown. +// Registration closes permanently when Run begins, including a failed run. +func (a *App) Register(module Module) error { + a.mu.Lock() + defer a.mu.Unlock() + if a.used { + return errors.New("lifecycle: registration is closed") + } + if module.Name == "" || module.Setup == nil || module.Teardown == nil { + return errors.New("lifecycle: module needs name, setup and teardown") + } + for _, m := range a.modules { + if m.Name == module.Name { + return fmt.Errorf("lifecycle: duplicate module %q", module.Name) + } + } + a.modules = append(a.modules, module) + return nil +} + +// Go immediately launches and tracks fn, or returns an admission error without +// launching it. ctx must be a callback's lifecycle context or a descendant; +// ancestry is a caller precondition and cannot generally be checked. Detached +// contexts forfeit graceful cancellation. Names are diagnostic and may repeat. +// Callers must handle rejection; raw goroutines are outside App's guarantee. +func (a *App) Go(ctx context.Context, name string, fn func(context.Context) error) error { + if ctx == nil || name == "" || fn == nil { + return errors.New("lifecycle: worker needs context, name and function") + } + a.mu.Lock() + defer a.mu.Unlock() + if !a.accepting { + return errors.New("lifecycle: worker admission is closed") + } + a.launchLocked(ctx, "worker", name, fn) + return nil +} + +func (a *App) notifyLocked() { + select { + case a.wake <- struct{}{}: + default: + } +} + +func (a *App) stopLocked() { + if !a.stopping { + // invariant: closure and admission share the lock, so every accepted worker is counted. + a.accepting = false + a.stopping = true + a.stoppedAt = time.Now() + a.cancel(a.shutdownCause) + } + a.notifyLocked() +} + +// cancellationOnly recognizes expected cancellation without rebuilding error trees. +func cancellationOnly(err, cancellation error) bool { + if err == nil { + return false + } + if err == cancellation { + return true + } + if many, ok := err.(interface{ Unwrap() []error }); ok { + children := many.Unwrap() + for _, child := range children { + if !cancellationOnly(child, cancellation) { + return false + } + } + return len(children) > 0 + } + if one, ok := err.(interface{ Unwrap() error }); ok { + return cancellationOnly(one.Unwrap(), cancellation) + } + return false +} + +func (a *App) recordLocked(ctx context.Context, phase, name string, err error) { + if a.stopping && context.Cause(ctx) == a.shutdownCause && cancellationOnly(err, ctx.Err()) { + err = nil + } + if err != nil { + a.errs = append(a.errs, fmt.Errorf("%s %q: %w", phase, name, err)) + a.stopLocked() + } +} + +func (a *App) launchLocked(ctx context.Context, phase, name string, fn func(context.Context) error) { + a.active++ + go func() { + err := fn(ctx) + a.mu.Lock() + defer a.mu.Unlock() + a.recordLocked(ctx, phase, name, err) + a.active-- + a.notifyLocked() + }() +} + +// Run sets up modules in registration order, then runs services concurrently. +// Every service and admitted worker must finish for normal completion. The first +// failure, parent cancellation or signal cancels their shared context and closes +// admission before reverse teardown. A fresh context retains parent values and +// gives teardown and callback completion one deadline from the shutdown trigger. +// In-flight setup is awaited within that budget before rollback; abandoned setup +// is never torn down later. Deadline or second signal abandons outstanding work. +// Observed failures are joined; expected callback cancellation during established +// shutdown is omitted only for cancellation-only error trees. Mixed errors remain +// intact to preserve wrapper types, identity and matching, so may still match +// cancellation. Initiating failures and every teardown error remain. +// Cancellation is omitted only when context.Cause proves App caused it; parent +// propagation and independently canceled descendants are retained conservatively. +// Run never recovers panics, closes Signals or exits the process. +func (a *App) Run(parent context.Context) error { + if parent == nil { + return errors.New("lifecycle: nil run context") + } + a.mu.Lock() + if a.used { + a.mu.Unlock() + return errors.New("lifecycle: app already run") + } + ctx, cancel := context.WithCancelCause(parent) + a.cancel = cancel + a.shutdownCause = errors.New("lifecycle shutdown") + a.used = true + a.accepting = true + modules := a.modules + a.mu.Unlock() + defer cancel(nil) + signals := a.config.Signals + signalCount := 0 + var shutdown context.Context + var shutdownCancel context.CancelFunc + var setupDone <-chan error + completed := 0 + var teardownDone <-chan error + teardownIndex := -1 + startup := true + parentDone := parent.Done() + receiveSignal := func(sig os.Signal) error { + signalCount++ + if signalCount == 2 { + if shutdownCancel != nil { + shutdownCancel() + } + return fmt.Errorf("second signal %v: %w", sig, context.Canceled) + } + a.mu.Lock() + a.stopLocked() + a.mu.Unlock() + return nil + } + + for { + // invariant: queued abandonment is observed before starting another hook. + select { + case sig, ok := <-signals: + if !ok { + signals = nil + } else if err := receiveSignal(sig); err != nil { + return a.result(parent, err) + } + continue + default: + } + + a.mu.Lock() + if !a.stopping && parent.Err() != nil { + a.stopLocked() + parentDone = nil + } + if !a.stopping && startup && setupDone == nil { + if completed < len(modules) { + m := modules[completed] + // invariant: buffered hook results cannot strand a sender after abandonment. + result := make(chan error, 1) + setupDone = result + go func() { + err := m.Setup(ctx) + a.mu.Lock() + a.recordLocked(ctx, "setup", m.Name, err) + a.mu.Unlock() + result <- err + }() + } else { + startup = false + for _, m := range modules { + if m.Run != nil { + a.launchLocked(ctx, "run", m.Name, m.Run) + } + } + } + } + if !a.stopping && !startup && a.active == 0 { + a.stopLocked() + } + if a.stopping && shutdown == nil { + created, release := context.WithDeadline(context.WithoutCancel(parent), a.stoppedAt.Add(a.config.ShutdownTimeout)) + shutdown, shutdownCancel = created, release + defer release() + teardownIndex = completed - 1 + a.config.Logger.Debug("lifecycle shutdown started") + } + stopping := a.stopping + active := a.active + a.mu.Unlock() + + if stopping { + if err := shutdown.Err(); err != nil { + return a.result(parent, err) + } + if setupDone == nil && teardownDone == nil { + if teardownIndex >= 0 { + m := modules[teardownIndex] + result := make(chan error, 1) + teardownDone = result + go func() { result <- m.Teardown(shutdown) }() + } else if active == 0 { + return a.result(parent, nil) + } + } + } + var deadline <-chan struct{} + if shutdown != nil { + deadline = shutdown.Done() + } + select { + case err := <-setupDone: + setupDone = nil + if err == nil { + completed++ + if stopping { + teardownIndex = completed - 1 + } + } + case err := <-teardownDone: + teardownDone = nil + if err != nil { + a.mu.Lock() + a.errs = append(a.errs, fmt.Errorf("teardown %q: %w", modules[teardownIndex].Name, err)) + a.mu.Unlock() + } + teardownIndex-- + case <-a.wake: + case <-parentDone: + a.mu.Lock() + a.stopLocked() + a.mu.Unlock() + parentDone = nil + case sig, ok := <-signals: + if !ok { + signals = nil + continue + } + if err := receiveSignal(sig); err != nil { + return a.result(parent, err) + } + case <-deadline: + return a.result(parent, context.DeadlineExceeded) + } + } +} + +func (a *App) result(parent context.Context, extra error) error { + a.mu.Lock() + defer a.mu.Unlock() + // invariant: every completion and abandonment retains parent cancellation exactly once. + return errors.Join(append(a.errs, parent.Err(), extra)...) +} diff --git a/lifecycle/lifecycle_test.go b/lifecycle/lifecycle_test.go new file mode 100644 index 0000000..9bd5870 --- /dev/null +++ b/lifecycle/lifecycle_test.go @@ -0,0 +1,676 @@ +package lifecycle + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "reflect" + "strings" + "sync" + "sync/atomic" + "syscall" + "testing" + "time" +) + +func newApp(t *testing.T, signals <-chan os.Signal, timeout time.Duration) *App { + t.Helper() + a, err := New(Config{Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Signals: signals, ShutdownTimeout: timeout}) + if err != nil { + t.Fatal(err) + } + return a +} +func add(t *testing.T, a *App, m Module) { + t.Helper() + if m.Setup == nil { + m.Setup = func(context.Context) error { return nil } + } + if m.Teardown == nil { + m.Teardown = func(context.Context) error { return nil } + } + if err := a.Register(m); err != nil { + t.Fatal(err) + } +} +func run(a *App, ctx context.Context) <-chan error { + done := make(chan error, 1) + go func() { done <- a.Run(ctx) }() + return done +} +func receive(t *testing.T, done <-chan error) error { + t.Helper() + select { + case err := <-done: + return err + case <-time.After(3 * time.Second): + t.Fatal("Run blocked") + return nil + } +} +func wait(t *testing.T, ch <-chan struct{}) { + t.Helper() + select { + case <-ch: + case <-time.After(3 * time.Second): + t.Fatal("callback blocked") + } +} + +func TestValidation(t *testing.T) { + if _, err := New(Config{}); err == nil { + t.Fatal("nil logger accepted") + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, err := New(Config{Logger: logger, ShutdownTimeout: -1}); err == nil { + t.Fatal("negative timeout accepted") + } + a := newApp(t, nil, 0) + if a.config.ShutdownTimeout != 30*time.Second { + t.Fatal("wrong default") + } + if a.Register(Module{Name: "bad"}) == nil { + t.Fatal("invalid module accepted") + } + add(t, a, Module{Name: "one"}) + if a.Register(a.modules[0]) == nil { + t.Fatal("duplicate accepted") + } + if a.Go(context.Background(), "early", func(context.Context) error { return nil }) == nil { + t.Fatal("early worker accepted") + } + if a.Run(nil) == nil { + t.Fatal("nil context accepted") + } + if err := a.Run(context.Background()); err != nil { + t.Fatal(err) + } + if a.Run(context.Background()) == nil || a.Register(Module{}) == nil { + t.Fatal("reuse accepted") + } + for _, tc := range []struct { + ctx context.Context + name string + fn func(context.Context) error + }{{nil, "x", func(context.Context) error { return nil }}, {context.Background(), "", func(context.Context) error { return nil }}, {context.Background(), "x", nil}} { + if a.Go(tc.ctx, tc.name, tc.fn) == nil { + t.Fatal("invalid worker accepted") + } + } +} + +func TestOrderRollbackAndJoinedErrors(t *testing.T) { + for _, fail := range []bool{false, true} { + t.Run(map[bool]string{false: "normal", true: "rollback"}[fail], func(t *testing.T) { + a := newApp(t, nil, 0) + var order []string + setupErr := errors.New("setup failed") + first := errors.New("cleanup first") + second := errors.New("cleanup second") + for _, name := range []string{"one", "two", "three"} { + add(t, a, Module{Name: name, Setup: func(context.Context) error { + order = append(order, "setup "+name) + if fail && name == "three" { + return setupErr + } + return nil + }, Teardown: func(context.Context) error { + order = append(order, "stop "+name) + if name == "one" { + return first + } + if name == "two" { + return second + } + return nil + }}) + } + err := a.Run(context.Background()) + want := []string{"setup one", "setup two", "setup three"} + if !fail { + want = append(want, "stop three") + } + want = append(want, "stop two", "stop one") + if !reflect.DeepEqual(order, want) { + t.Fatalf("order %v", order) + } + if !errors.Is(err, first) || !errors.Is(err, second) || errors.Is(err, setupErr) != fail { + t.Fatalf("errors %v", err) + } + }) + } +} + +func TestAllWorkCompletion(t *testing.T) { + for _, kind := range []string{"service", "worker"} { + t.Run(kind, func(t *testing.T) { + a := newApp(t, nil, 0) + ready := make(chan struct{}) + release := make(chan struct{}) + var once sync.Once + defer once.Do(func() { close(release) }) + fn := func(context.Context) error { close(ready); <-release; return nil } + m := Module{Name: "long"} + if kind == "service" { + m.Run = fn + } else { + m.Setup = func(ctx context.Context) error { return a.Go(ctx, "long", fn) } + } + add(t, a, m) + add(t, a, Module{Name: "short", Run: func(context.Context) error { return nil }}) + done := run(a, context.Background()) + wait(t, ready) + select { + case err := <-done: + t.Fatalf("early completion: %v", err) + case <-time.After(10 * time.Millisecond): + } + once.Do(func() { close(release) }) + if err := receive(t, done); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestWorkerFailureAndTeardownUnblocks(t *testing.T) { + a := newApp(t, nil, time.Second) + released := make(chan struct{}) + ready := make(chan struct{}) + failure := errors.New("worker failed") + add(t, a, Module{Name: "resource", Setup: func(ctx context.Context) error { + return a.Go(ctx, "waiting", func(ctx context.Context) error { close(ready); <-ctx.Done(); <-released; return ctx.Err() }) + }, Teardown: func(ctx context.Context) error { + if ctx.Err() != nil { + t.Errorf("teardown canceled early: %v", ctx.Err()) + } + close(released) + return nil + }, Run: func(context.Context) error { <-ready; return failure }}) + err := a.Run(context.Background()) + if !errors.Is(err, failure) || errors.Is(err, context.Canceled) { + t.Fatalf("errors %v", err) + } + b := newApp(t, nil, 0) + add(t, b, Module{Name: "worker", Setup: func(ctx context.Context) error { + return b.Go(ctx, "failing", func(context.Context) error { return failure }) + }, Run: func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() }}) + if err = b.Run(context.Background()); !errors.Is(err, failure) { + t.Fatalf("worker error %v", err) + } +} + +func TestSignalDuringSetup(t *testing.T) { + aSignals := make(chan os.Signal, 2) + a := newApp(t, aSignals, time.Second) + entered := make(chan struct{}) + rolled := false + partialCleaned := false + add(t, a, Module{Name: "ready", Teardown: func(context.Context) error { rolled = true; return nil }}) + add(t, a, Module{Name: "partial", Setup: func(ctx context.Context) error { + close(entered) + <-ctx.Done() + partialCleaned = true + return errors.Join(ctx.Err(), nil) + }, Teardown: func(context.Context) error { t.Error("failed setup torn down"); return nil }}) + add(t, a, Module{Name: "never", Setup: func(context.Context) error { t.Error("late setup started"); return nil }}) + done := run(a, context.Background()) + wait(t, entered) + aSignals <- syscall.SIGINT + if err := receive(t, done); err != nil { + t.Fatal(err) + } + if !rolled || !partialCleaned { + t.Fatal("cleanup missing") + } + b := newApp(t, nil, 0) + add(t, b, Module{Name: "initiator", Setup: func(context.Context) error { return context.Canceled }}) + if err := b.Run(context.Background()); !errors.Is(err, context.Canceled) { + t.Fatalf("initiator lost: %v", err) + } +} + +func TestMixedErrorsAndParentValues(t *testing.T) { + type key struct{} + parent, cancel := context.WithCancel(context.WithValue(context.Background(), key{}, "value")) + defer cancel() + a := newApp(t, nil, time.Second) + ready := make(chan struct{}) + independent := errors.New("independent") + teardownErr := errors.New("teardown") + add(t, a, Module{Name: "mixed", Run: func(ctx context.Context) error { + close(ready) + <-ctx.Done() + return errors.Join(ctx.Err(), independent) + }, Teardown: func(ctx context.Context) error { + if ctx.Value(key{}) != "value" || ctx.Err() != nil { + t.Error("shutdown context lost values or inherited cancellation") + } + return errors.Join(teardownErr, context.Canceled) + }}) + done := run(a, parent) + wait(t, ready) + cancel() + err := receive(t, done) + for _, want := range []error{context.Canceled, independent, teardownErr} { + if !errors.Is(err, want) { + t.Fatalf("missing %v: %v", want, err) + } + } + if !strings.Contains(err.Error(), "mixed") { + t.Fatal("missing diagnostic name") + } +} + +func TestBoundedHooksAndWorkers(t *testing.T) { + for _, kind := range []string{"setup", "teardown", "worker"} { + t.Run(kind, func(t *testing.T) { + signals := make(chan os.Signal, 2) + a := newApp(t, signals, 30*time.Millisecond) + entered := make(chan struct{}) + release := make(chan struct{}) + exited := make(chan struct{}) + defer func() { close(release); wait(t, exited) }() + blocked := func(context.Context) error { close(entered); <-release; close(exited); return nil } + var torn atomic.Int32 + m := Module{Name: "blocked", Teardown: func(context.Context) error { torn.Add(1); return nil }} + switch kind { + case "setup": + m.Setup = blocked + case "teardown": + m.Teardown = blocked + case "worker": + m.Setup = func(ctx context.Context) error { return a.Go(ctx, "blocked", blocked) } + } + add(t, a, m) + done := run(a, context.Background()) + wait(t, entered) + start := time.Now() + signals <- syscall.SIGTERM + err := receive(t, done) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("deadline missing: %v", err) + } + if time.Since(start) > time.Second { + t.Fatal("shutdown exceeded bound") + } + if kind == "setup" && torn.Load() != 0 { + t.Fatal("abandoned setup torn down") + } + }) + } +} + +func TestSecondSignalAndClosedSource(t *testing.T) { + for _, trigger := range []string{"signal", "failure"} { + t.Run(trigger, func(t *testing.T) { + signals := make(chan os.Signal, 2) + a := newApp(t, signals, time.Second) + entered := make(chan struct{}) + release := make(chan struct{}) + exited := make(chan struct{}) + defer func() { close(release); wait(t, exited) }() + failure := errors.New("failed") + ready := make(chan struct{}) + add(t, a, Module{Name: "blocked", Run: func(ctx context.Context) error { + close(ready) + if trigger == "failure" { + return failure + } + <-ctx.Done() + return ctx.Err() + }, Teardown: func(context.Context) error { close(entered); <-release; close(exited); return nil }}) + done := run(a, context.Background()) + wait(t, ready) + if trigger == "signal" { + signals <- syscall.SIGINT + } + wait(t, entered) + if trigger == "failure" { + signals <- syscall.SIGINT + } + signals <- syscall.SIGTERM + err := receive(t, done) + if !errors.Is(err, context.Canceled) || errors.Is(err, failure) != (trigger == "failure") { + t.Fatalf("abandonment %v", err) + } + }) + } + signals := make(chan os.Signal) + close(signals) + a := newApp(t, signals, 0) + if err := a.Run(context.Background()); err != nil { + t.Fatal(err) + } +} + +func TestAdmissionRace(t *testing.T) { + for range 50 { + a := newApp(t, nil, time.Second) + parent, cancel := context.WithCancel(context.Background()) + ready := make(chan context.Context, 1) + add(t, a, Module{Name: "service", Run: func(ctx context.Context) error { ready <- ctx; <-ctx.Done(); return ctx.Err() }}) + done := run(a, parent) + ctx := <-ready + var accepted, finished atomic.Int32 + var callers sync.WaitGroup + for range 20 { + callers.Go(func() { + if err := a.Go(ctx, "racer", func(context.Context) error { finished.Add(1); return nil }); err == nil { + accepted.Add(1) + } + }) + } + cancel() + callers.Wait() + err := receive(t, done) + if !errors.Is(err, context.Canceled) { + t.Fatalf("parent error %v", err) + } + if accepted.Load() != finished.Load() { + t.Fatal("accepted worker escaped completion") + } + if a.Go(ctx, "late", func(context.Context) error { t.Error("late launch"); return nil }) == nil { + t.Fatal("late admission") + } + } +} + +func TestIndependentDescendantCancellation(t *testing.T) { + for _, mixed := range []bool{false, true} { + t.Run(map[bool]string{false: "cancellation-only", true: "mixed"}[mixed], func(t *testing.T) { + signals := make(chan os.Signal, 2) + a := newApp(t, signals, time.Second) + canceled := make(chan struct{}) + allowReturn := make(chan struct{}) + other := errors.New("other") + add(t, a, Module{Name: "owner", Setup: func(ctx context.Context) error { + child, cancel := context.WithCancel(ctx) + err := a.Go(child, "independent", func(ctx context.Context) error { + cancel() + close(canceled) + <-allowReturn + err := ctx.Err() + if mixed { + err = errors.Join(err, other) + } + return fmt.Errorf("worker result: %w", err) + }) + if err != nil { + cancel() + } + return err + }, Teardown: func(context.Context) error { close(allowReturn); return nil }}) + done := run(a, context.Background()) + wait(t, canceled) + signals <- syscall.SIGTERM + if err := receive(t, done); !errors.Is(err, context.Canceled) || errors.Is(err, other) != mixed { + t.Fatalf("independent cancellation lost: %v", err) + } + }) + } +} + +func TestParentCancellationDuringShutdown(t *testing.T) { + for _, boundary := range []string{"completion", "deadline", "second-signal"} { + t.Run(boundary, func(t *testing.T) { + iterations := 1 + if boundary == "completion" { + iterations = 1000 + } + for range iterations { + parent, cancel := context.WithCancel(context.Background()) + signals := make(chan os.Signal, 2) + timeout := time.Second + if boundary == "deadline" { + timeout = 20 * time.Millisecond + } + a := newApp(t, signals, timeout) + exited := make(chan struct{}) + add(t, a, Module{Name: "last", Teardown: func(ctx context.Context) error { + defer close(exited) + cancel() + switch boundary { + case "deadline": + <-ctx.Done() + case "second-signal": + signals <- syscall.SIGINT + signals <- syscall.SIGTERM + <-ctx.Done() + } + return nil + }}) + err := a.Run(parent) + cancel() + wait(t, exited) + if !errors.Is(err, context.Canceled) { + t.Fatalf("parent cancellation lost: %v", err) + } + if errors.Is(err, context.DeadlineExceeded) != (boundary == "deadline") { + t.Fatalf("unexpected shutdown result: %v", err) + } + parentErrors := 0 + for _, child := range err.(interface{ Unwrap() []error }).Unwrap() { + if child == context.Canceled { + parentErrors++ + } + } + if parentErrors != 1 { + t.Fatalf("parent error recorded %d times: %v", parentErrors, err) + } + } + }) + } +} + +func TestSuccessfulInterruptedSetupRollsBack(t *testing.T) { + signals := make(chan os.Signal, 2) + a := newApp(t, signals, time.Second) + entered := make(chan struct{}) + var order []string + for _, name := range []string{"first", "interrupted"} { + add(t, a, Module{Name: name, Setup: func(ctx context.Context) error { + if name == "interrupted" { + close(entered) + <-ctx.Done() + } + return nil + }, Teardown: func(context.Context) error { order = append(order, name); return nil }}) + } + done := run(a, context.Background()) + wait(t, entered) + signals <- syscall.SIGINT + if err := receive(t, done); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(order, []string{"interrupted", "first"}) { + t.Fatalf("rollback %v", order) + } +} + +func TestSharedDeadline(t *testing.T) { + a := newApp(t, nil, 80*time.Millisecond) + var firstDeadline time.Time + entered := make(chan struct{}) + release := make(chan struct{}) + exited := make(chan struct{}) + defer func() { close(release); wait(t, exited) }() + var skipped atomic.Bool + add(t, a, Module{Name: "never", Teardown: func(context.Context) error { skipped.Store(true); return nil }}) + add(t, a, Module{Name: "blocked", Teardown: func(ctx context.Context) error { + deadline, ok := ctx.Deadline() + if !ok || !deadline.Equal(firstDeadline) { + t.Error("deadline reset between hooks") + } + close(entered) + <-release + close(exited) + return nil + }}) + add(t, a, Module{Name: "first", Teardown: func(ctx context.Context) error { + firstDeadline, _ = ctx.Deadline() + time.Sleep(20 * time.Millisecond) + return nil + }}) + done := run(a, context.Background()) + wait(t, entered) + if err := receive(t, done); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("deadline %v", err) + } + if skipped.Load() { + t.Fatal("hook started after abandonment") + } +} + +func TestNormalCompletionAdmissionRace(t *testing.T) { + for range 50 { + a := newApp(t, nil, time.Second) + ready := make(chan context.Context, 1) + release := make(chan struct{}) + add(t, a, Module{Name: "service", Run: func(ctx context.Context) error { ready <- ctx; <-release; return nil }}) + done := run(a, context.Background()) + ctx := <-ready + var accepted, finished atomic.Int32 + var callers sync.WaitGroup + for range 20 { + callers.Go(func() { + if a.Go(ctx, "racer", func(context.Context) error { finished.Add(1); return nil }) == nil { + accepted.Add(1) + } + }) + } + close(release) + callers.Wait() + if err := receive(t, done); err != nil { + t.Fatal(err) + } + if accepted.Load() != finished.Load() { + t.Fatal("normal completion lost worker") + } + } +} + +func TestStartupWaitsForSetupDespiteEmptyWorkers(t *testing.T) { + a := newApp(t, nil, time.Second) + entered := make(chan struct{}) + release := make(chan struct{}) + defer func() { + select { + case <-release: + default: + close(release) + } + }() + add(t, a, Module{Name: "setup", Setup: func(context.Context) error { close(entered); <-release; return nil }}) + done := run(a, context.Background()) + wait(t, entered) + if a.Run(context.Background()) == nil { + t.Fatal("concurrent Run accepted") + } + if a.Register(Module{}) == nil { + t.Fatal("registration remained open") + } + select { + case err := <-done: + t.Fatalf("ended during setup: %v", err) + default: + } + close(release) + if err := receive(t, done); err != nil { + t.Fatal(err) + } +} + +func TestMixedShutdownErrorPreservesCause(t *testing.T) { + signals := make(chan os.Signal, 2) + a := newApp(t, signals, time.Second) + ready := make(chan struct{}) + independent := &os.PathError{Op: "read", Path: "queue", Err: io.ErrUnexpectedEOF} + add(t, a, Module{Name: "mixed", Run: func(ctx context.Context) error { + close(ready) + <-ctx.Done() + return fmt.Errorf("outer: %w", errors.Join(fmt.Errorf("cancellation: %w", ctx.Err()), independent)) + }}) + done := run(a, context.Background()) + wait(t, ready) + signals <- syscall.SIGTERM + err := receive(t, done) + var pathErr *os.PathError + if !errors.As(err, &pathErr) || pathErr != independent || !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("independent cause lost: %v", err) + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("mixed wrapper was reconstructed: %v", err) + } +} + +// matchingError exercises wrapper-owned matching independently of its child. +type matchingError struct { + child error + match error +} + +func (e *matchingError) Error() string { return "custom: " + e.child.Error() } +func (e *matchingError) Unwrap() error { return e.child } +func (e *matchingError) Is(target error) bool { return target == e.match } + +func TestMixedShutdownErrorPreservesWrapper(t *testing.T) { + for _, kind := range []string{"path", "custom"} { + t.Run(kind, func(t *testing.T) { + signals := make(chan os.Signal, 2) + a := newApp(t, signals, time.Second) + ready := make(chan struct{}) + match := errors.New("custom match") + var original error + add(t, a, Module{Name: "mixed", Run: func(ctx context.Context) error { + close(ready) + <-ctx.Done() + joined := errors.Join(ctx.Err(), io.ErrUnexpectedEOF) + if kind == "path" { + original = &os.PathError{Op: "read", Path: "queue", Err: joined} + } else { + original = &matchingError{child: joined, match: match} + } + return original + }}) + done := run(a, context.Background()) + wait(t, ready) + signals <- syscall.SIGTERM + err := receive(t, done) + if !errors.Is(err, original) || !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("wrapper identity or inner failure lost: %v", err) + } + if kind == "path" { + var pathErr *os.PathError + if !errors.As(err, &pathErr) || pathErr != original { + t.Fatalf("typed wrapper lost: %v", err) + } + } else { + var custom *matchingError + if !errors.As(err, &custom) || custom != original || !errors.Is(err, match) { + t.Fatalf("custom matching lost: %v", err) + } + } + }) + } +} + +func TestCancellationOnly(t *testing.T) { + for _, err := range []error{ + context.Canceled, + fmt.Errorf("wrapped: %w", context.Canceled), + &os.PathError{Op: "read", Path: "queue", Err: errors.Join(context.Canceled, fmt.Errorf("wrapped: %w", context.Canceled))}, + } { + if !cancellationOnly(err, context.Canceled) { + t.Errorf("expected cancellation not recognized: %v", err) + } + } + for _, err := range []error{nil, io.ErrUnexpectedEOF, context.DeadlineExceeded, errors.Join(context.Canceled, io.ErrUnexpectedEOF)} { + if cancellationOnly(err, context.Canceled) { + t.Errorf("independent error classified as cancellation: %v", err) + } + } +} From c45e8abe08fde98f7e0ada6a4206ec78868d5fec Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Tue, 27 Oct 2026 20:21:58 -0700 Subject: [PATCH 2/2] feat(page): add typed keyset pagination for Bob (#15) Add transport-neutral keyset pagination through native Bob query mods. Preserve supported cursor value types in a compact versioned format, with bidirectional pages, explicit row keys, and a separate opt-in count. Include SQLite tests, PostgreSQL service CI, and the cursor-format ADR. Closes #6 --- .gitea/workflows/ci.yml | 22 ++ README.md | 15 +- ...d-sort-values-in-versioned-page-cursors.md | 91 ++++++++ go.mod | 9 + go.sum | 143 ++++++++++++ page/cursor.go | 216 +++++++++++++++++ page/cursor_test.go | 220 ++++++++++++++++++ page/example_test.go | 69 ++++++ page/page.go | 184 +++++++++++++++ page/query.go | 68 ++++++ page/query_test.go | 189 +++++++++++++++ 11 files changed, 1225 insertions(+), 1 deletion(-) create mode 100644 docs/adr/20261027T184351001Z-preserve-typed-sort-values-in-versioned-page-cursors.md create mode 100644 page/cursor.go create mode 100644 page/cursor_test.go create mode 100644 page/example_test.go create mode 100644 page/page.go create mode 100644 page/query.go create mode 100644 page/query_test.go diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 266a719..265a02c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -41,3 +41,25 @@ jobs: distribution: goreleaser version: v2.19.0 args: release --snapshot --clean + postgres: + 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 + env: + PAGE_POSTGRES_DSN: postgres://postgres:page-test@postgres:5432/page_test?sslmode=disable + PAGE_REQUIRE_POSTGRES: "1" + steps: + - uses: actions/checkout@v7.0.1 + - uses: actions/setup-go@v7.0.0 + with: + go-version-file: go.mod + - run: go test -race ./page diff --git a/README.md b/README.md index 2d63b5c..6cf0721 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # corestack go -Shared Go building blocks. Includes `docs`, a checker for architecture decision records (ADRs) and implementation plans, and `lifecycle`, a service and worker coordinator. +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. ## docs @@ -20,6 +20,19 @@ 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 diff --git a/docs/adr/20261027T184351001Z-preserve-typed-sort-values-in-versioned-page-cursors.md b/docs/adr/20261027T184351001Z-preserve-typed-sort-values-in-versioned-page-cursors.md new file mode 100644 index 0000000..35a698c --- /dev/null +++ b/docs/adr/20261027T184351001Z-preserve-typed-sort-values-in-versioned-page-cursors.md @@ -0,0 +1,91 @@ +--- +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. diff --git a/go.mod b/go.mod index 0218f6a..d08f5c6 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,15 @@ module go.auvem.com/corestack go 1.27.1 require ( + github.com/lib/pq v1.10.9 + github.com/mattn/go-sqlite3 v1.14.32 + github.com/stephenafamo/bob v0.50.0 + github.com/stephenafamo/scan v0.9.0 github.com/yuin/goldmark v1.8.6 go.yaml.in/yaml/v3 v3.0.5 ) + +require ( + github.com/aarondl/opt v0.0.0-20250607033636-982744e1bd65 // indirect + github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494 // indirect +) diff --git a/go.sum b/go.sum index 1b18895..f772a14 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,147 @@ +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.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +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/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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +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.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +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/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.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +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-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +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/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/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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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/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.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= 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.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/page/cursor.go b/page/cursor.go new file mode 100644 index 0000000..f2024fe --- /dev/null +++ b/page/cursor.go @@ -0,0 +1,216 @@ +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 + } +} diff --git a/page/cursor_test.go b/page/cursor_test.go new file mode 100644 index 0000000..13dc8de --- /dev/null +++ b/page/cursor_test.go @@ -0,0 +1,220 @@ +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) + } + } +} diff --git a/page/example_test.go b/page/example_test.go new file mode 100644 index 0000000..44b25dc --- /dev/null +++ b/page/example_test.go @@ -0,0 +1,69 @@ +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 +} diff --git a/page/page.go b/page/page.go new file mode 100644 index 0000000..ff13297 --- /dev/null +++ b/page/page.go @@ -0,0 +1,184 @@ +// 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 +} diff --git a/page/query.go b/page/query.go new file mode 100644 index 0000000..07c688d --- /dev/null +++ b/page/query.go @@ -0,0 +1,68 @@ +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 +} diff --git a/page/query_test.go b/page/query_test.go new file mode 100644 index 0000000..13f442b --- /dev/null +++ b/page/query_test.go @@ -0,0 +1,189 @@ +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. +}