Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a99e364ac3 | ||
|
|
0034a561ba | ||
|
|
fe44cac3a2 | ||
|
|
f0439000cb | ||
|
|
c45e8abe08 | ||
|
|
e5298cff37 | ||
|
|
46c4416ed4 | ||
|
|
7d36785649 | ||
|
|
cc482d9a9b |
No files matched your search
@@ -0,0 +1,59 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
# Keep embedded Markdown templates in CI; paths-ignore cannot undo a match.
|
||||
- "*.md"
|
||||
- "docs/**"
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- "*.md"
|
||||
- "docs/**"
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_PASSWORD: page-test
|
||||
POSTGRES_DB: page_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres -d page_test"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@v7.0.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: gofmt
|
||||
run: test -z "$(gofmt -l .)"
|
||||
- run: go vet ./...
|
||||
- run: go test -race ./...
|
||||
env:
|
||||
CI: "true"
|
||||
MIGRATE_POSTGRES_DSN: postgres://postgres:page-test@postgres:5432/page_test?sslmode=disable
|
||||
PAGE_POSTGRES_DSN: postgres://postgres:page-test@postgres:5432/page_test?sslmode=disable
|
||||
PAGE_REQUIRE_POSTGRES: "1"
|
||||
- name: Require issue URLs in TODO comments
|
||||
run: |
|
||||
find . -type d \( -name .git -o -name node_modules -o -name vendor -o -name generated -o -name gen -o -name target -o -name dist -o -name build -o -name .svelte-kit -o -name .next \) -prune -o -type f \( -name '*.go' -o -name '*.ts' -o -name '*.tsx' -o -name '*.js' -o -name '*.svelte' -o -name '*.rs' -o -name '*.sql' -o -name '*.sh' -o -name '*.proto' -o -name '*.graphql' -o -name '*.yml' -o -name '*.yaml' -o -name '*.toml' \) -exec awk 'match($0, "((^|[^:])[/][/]|[/][*]|(^|[[:space:]])(#|--)|^[[:space:]]*([*]|<!--))") { comment=substr($0, RSTART + RLENGTH); todo=index(comment, "TO" "DO"); if (todo && substr(comment, todo + 4) !~ "https?:[/][/]") { print FILENAME ":" FNR ":" $0; bad=1 } } END { exit bad }' {} + # shortcut: line-based scan can flag comment-like strings and misses multiline issue links, use a parser when these need accurate coverage
|
||||
- name: Go lint
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.15.0 run ./...
|
||||
- uses: goreleaser/goreleaser-action@v7.3.0
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: v2.19.0
|
||||
args: check
|
||||
- uses: goreleaser/goreleaser-action@v7.3.0
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: v2.19.0
|
||||
args: release --snapshot --clean
|
||||
@@ -0,0 +1,15 @@
|
||||
name: Documentation
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
jobs:
|
||||
docs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- uses: actions/setup-go@v7.0.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
- run: go run ./cmd/corestack-docs check
|
||||
@@ -0,0 +1,29 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@v7.0.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Require issue URLs in TODO comments
|
||||
run: |
|
||||
find . -type d \( -name .git -o -name node_modules -o -name vendor -o -name generated -o -name gen -o -name target -o -name dist -o -name build -o -name .svelte-kit -o -name .next \) -prune -o -type f \( -name '*.go' -o -name '*.ts' -o -name '*.tsx' -o -name '*.js' -o -name '*.svelte' -o -name '*.rs' -o -name '*.sql' -o -name '*.sh' -o -name '*.proto' -o -name '*.graphql' -o -name '*.yml' -o -name '*.yaml' -o -name '*.toml' \) -exec awk 'match($0, "((^|[^:])[/][/]|[/][*]|(^|[[:space:]])(#|--)|^[[:space:]]*([*]|<!--))") { comment=substr($0, RSTART + RLENGTH); todo=index(comment, "TO" "DO"); if (todo && substr(comment, todo + 4) !~ "https?:[/][/]") { print FILENAME ":" FNR ":" $0; bad=1 } } END { exit bad }' {} + # shortcut: line-based scan can flag comment-like strings and misses multiline issue links, use a parser when these need accurate coverage
|
||||
- uses: goreleaser/goreleaser-action@v7.3.0
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: v2.19.0
|
||||
args: release --clean
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
# goreleaser fails on more than one token; an empty value counts as unset.
|
||||
GITHUB_TOKEN: ""
|
||||
@@ -0,0 +1 @@
|
||||
dist/
|
||||
@@ -0,0 +1,12 @@
|
||||
version: "2"
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- revive
|
||||
settings:
|
||||
revive:
|
||||
rules:
|
||||
- name: exported
|
||||
- name: package-comments
|
||||
exclusions:
|
||||
presets: []
|
||||
@@ -0,0 +1,29 @@
|
||||
version: 2
|
||||
project_name: corestack-docs
|
||||
|
||||
builds:
|
||||
- id: corestack-docs
|
||||
main: ./cmd/corestack-docs
|
||||
binary: corestack-docs
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos: [darwin, linux]
|
||||
goarch: [amd64, arm64]
|
||||
flags: [-trimpath]
|
||||
ldflags: [-s -w]
|
||||
|
||||
archives:
|
||||
- formats: [tar.gz]
|
||||
name_template: "corestack-docs_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
|
||||
checksum:
|
||||
name_template: checksums.txt
|
||||
|
||||
gitea_urls:
|
||||
api: https://gitea.auvem.com/api/v1
|
||||
download: https://gitea.auvem.com
|
||||
|
||||
release:
|
||||
gitea:
|
||||
owner: corestack
|
||||
name: go
|
||||
@@ -0,0 +1,51 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Commands
|
||||
- Check: `go build ./...`, `go vet ./...`, `go test -race ./...`, `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.15.0 run ./...`; from the project root, run the TODO check below.
|
||||
- Generate: No code generation is configured; format with `gofmt -w .`.
|
||||
- Documentation: `go run ./cmd/corestack-docs check`; create records with `go run ./cmd/corestack-docs new adr|plan "Title"`. This checker repository uses its local implementation as the source of truth.
|
||||
|
||||
## Scope and decisions
|
||||
- Before editing under a directory with its own AGENTS.md, read it. Scoped files: none currently; this root governs the repository, including nested Go modules unless nearer instructions override it.
|
||||
- Explicit user instructions win; nearer scoped rules override broader rules.
|
||||
- Distinguish confirmed requirements, proposed defaults and unresolved decisions; resolve material ambiguity before dependent work.
|
||||
- Proposed ADRs are context, not authority; read relevant accepted decisions before changing their scope.
|
||||
- Before final review and merge, reconcile affected records against the latest target branch plus the PR, following [/docs/adr/README.md](/docs/adr/README.md).
|
||||
- Documentation tier: ADRs only. Create a plan only when work needs durable execution context across areas, phases or sessions; then enable the plans tier.
|
||||
- In a sandbox, request required network/cache access before first codegen; on permission/cache errors retry once with that access. Never hand-edit generated output.
|
||||
- Shared databases and ports may belong to other worktrees/agents; read project rules before starting, stopping or resetting them.
|
||||
- Ask before lint/autofix changes that could alter runtime behavior.
|
||||
- Report commands actually run and their results; never claim an unrun check passed.
|
||||
- Name branches `<type>/<kebab>` without agent-name prefixes.
|
||||
|
||||
## Comments
|
||||
- Document package/module purpose, ownership and boundaries, and every public/exported contract in native doc comments; document generated surfaces at their source.
|
||||
- Explain why at invariants, auth decisions, concurrency/ordering, destructive edges and deliberate deviations; no narration, changelog comments or commented-out code.
|
||||
- Use `invariant: <property>`, `authz: <who and enforcement>`, `shortcut: <limit>, <when to upgrade>` on relevant code; retain Go `Deprecated:` and Rust `// SAFETY:`.
|
||||
- Every TODO needs an issue URL; reviewers recheck tagged comments on touched code.
|
||||
|
||||
## Project map
|
||||
|
||||
Go module `go.auvem.com/corestack`.
|
||||
|
||||
- Docs format (the spec `corestack-docs check` implements): `docs/docs-format.md`.
|
||||
- Decisions live in `docs/adr/`, plans in `docs/plans/`. Records are permanent; never rename them.
|
||||
- Add dependencies only to the module that needs them; heavy dependencies go in nested modules (see the [module-boundary ADR](/docs/adr/20261025T214616275Z-version-light-core-packages-in-the-root-module-and-nest-heavy-dependency-modules.md)).
|
||||
|
||||
## Go
|
||||
- Default backend language is Go; follow existing package boundaries.
|
||||
- Define interfaces at the consumer only when a second implementation or a test seam needs one.
|
||||
- Wrap contextual errors with `%w`; inspect causes with `errors.Is`/`errors.As`.
|
||||
- Do not add package-level mutable state or `init` side effects.
|
||||
- Put `context.Context` first in parameters; never store it in a struct.
|
||||
- Give every goroutine an owner and a shutdown path.
|
||||
- Run `gofmt`, `go vet ./...`, tests and the project's configured linter from the owning module.
|
||||
- Use `.golangci.yml` (revive exported/package-comments); the pinned linter command is listed above.
|
||||
|
||||
## TODO check
|
||||
|
||||
Run from the project root (also enforced in CI):
|
||||
|
||||
```sh
|
||||
find . -type d \( -name .git -o -name node_modules -o -name vendor -o -name generated -o -name gen -o -name target -o -name dist -o -name build -o -name .svelte-kit -o -name .next \) -prune -o -type f \( -name '*.go' -o -name '*.ts' -o -name '*.tsx' -o -name '*.js' -o -name '*.svelte' -o -name '*.rs' -o -name '*.sql' -o -name '*.sh' -o -name '*.proto' -o -name '*.graphql' -o -name '*.yml' -o -name '*.yaml' -o -name '*.toml' \) -exec awk 'match($0, "((^|[^:])[/][/]|[/][*]|(^|[[:space:]])(#|--)|^[[:space:]]*([*]|<!--))") { comment=substr($0, RSTART + RLENGTH); todo=index(comment, "TO" "DO"); if (todo && substr(comment, todo + 4) !~ "https?:[/][/]") { print FILENAME ":" FNR ":" $0; bad=1 } } END { exit bad }' {} + # shortcut: line-based scan can flag comment-like strings and misses multiline issue links, use a parser when these need accurate coverage
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2026, Elijah Duffy
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -1 +1,93 @@
|
||||
# corestack go
|
||||
|
||||
Shared Go building blocks. Includes `corestack-docs`, a checker for architecture decision records (ADRs) and implementation plans; `page`, Bob keyset pagination; and `lifecycle`, a service and worker coordinator.
|
||||
|
||||
## migrate
|
||||
|
||||
[`migrate`](migrate/README.md) configures Goose providers for embedded SQL and Go
|
||||
migrations, with structured logging and Postgres startup locking.
|
||||
|
||||
## corestack-docs
|
||||
|
||||
Install:
|
||||
|
||||
```sh
|
||||
go install go.auvem.com/corestack/cmd/corestack-docs@latest
|
||||
```
|
||||
|
||||
Use, from a repository root:
|
||||
|
||||
```sh
|
||||
corestack-docs new adr "Use Example Queue for background jobs" # creates docs/adr/<id>.md
|
||||
corestack-docs new plan "Adopt Example Queue" # creates docs/plans/<id>.md
|
||||
corestack-docs check [--root DIR] # prints path:line: message; exit 1 on problems
|
||||
```
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
## lifecycle/cli
|
||||
|
||||
`go.auvem.com/corestack/lifecycle/cli` adapts native `urfave/cli/v3` commands.
|
||||
Bind `Serve(config, modules)` to run selected services, or `Action(config, modules,
|
||||
fn)` for a one-off with selected setup/cleanup and module services suppressed.
|
||||
See the [executable serve/inspect example](lifecycle/cli/example_test.go).
|
||||
|
||||
`Run(ctx, cliRoot, args)` accepts argv including the program name and returns the
|
||||
exit code and inspectable error: success/help/graceful first signal is 0, usage is
|
||||
2, and runtime/lifecycle failure is 1. Runtime failures dominate mixed errors.
|
||||
Run prints diagnostics without exiting; the caller owns process exit. Nil signals
|
||||
subscribe to SIGINT/SIGTERM during adapted actions; injected channels stay caller
|
||||
owned. The private lifecycle App does not expose managed-worker admission.
|
||||
|
||||
Use fresh command trees and leave `ExitErrHandler`, `OnUsageError` and
|
||||
`CommandNotFound` unset; Run owns those hooks. Other native hooks run outside the
|
||||
adapted lifecycle action. Native environment/custom validation and generated-help
|
||||
parse failures can return 1 with native diagnostics; custom pre-lifecycle
|
||||
validators may return `urfavecli.Exit(err, 2)` for usage. Help and flags use native
|
||||
urfave/cli declarations, without a configuration or environment facade.
|
||||
|
||||
## Status
|
||||
|
||||
No support is guaranteed. Breaking changes, including to the format rules, may happen before 1.0 and are listed in the release notes.
|
||||
|
||||
## License
|
||||
|
||||
BSD-3-Clause, see [LICENSE](LICENSE).
|
||||
@@ -0,0 +1,66 @@
|
||||
// Command corestack-docs creates and checks ADR and plan records. See docs/docs-format.md.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"go.auvem.com/corestack/internal/docs"
|
||||
)
|
||||
|
||||
const usage = `usage:
|
||||
corestack-docs new adr|plan "Title"
|
||||
corestack-docs check [--root DIR]
|
||||
`
|
||||
|
||||
func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr, time.Now())) }
|
||||
|
||||
// run returns the exit status: 0 success, 1 problems or failure, 2 usage error.
|
||||
func run(args []string, stdout, stderr io.Writer, now time.Time) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprint(stderr, usage)
|
||||
return 2
|
||||
}
|
||||
switch args[0] {
|
||||
case "new":
|
||||
if len(args) != 3 || (args[1] != "adr" && args[1] != "plan") {
|
||||
fmt.Fprint(stderr, usage)
|
||||
return 2
|
||||
}
|
||||
path, err := docs.New(".", args[1], args[2], now)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "corestack-docs: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintln(stdout, path)
|
||||
return 0
|
||||
case "check":
|
||||
fs := flag.NewFlagSet("corestack-docs check", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
root := fs.String("root", ".", "repository root")
|
||||
if err := fs.Parse(args[1:]); err != nil || fs.NArg() != 0 {
|
||||
if fs.NArg() != 0 {
|
||||
fmt.Fprint(stderr, usage)
|
||||
}
|
||||
return 2
|
||||
}
|
||||
res, err := docs.Check(*root)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "corestack-docs: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
for _, p := range res.Problems {
|
||||
fmt.Fprintln(stdout, p)
|
||||
}
|
||||
if len(res.Problems) > 0 {
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(stdout, "ok: %d records checked\n", res.Records)
|
||||
return 0
|
||||
}
|
||||
fmt.Fprint(stderr, usage)
|
||||
return 2
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var now = time.Date(2026, 10, 23, 20, 45, 12, 345_000_000, time.UTC)
|
||||
|
||||
func do(t *testing.T, args ...string) (code int, stdout, stderr string) {
|
||||
t.Helper()
|
||||
var out, errw bytes.Buffer
|
||||
code = run(args, &out, &errw, now)
|
||||
return code, out.String(), errw.String()
|
||||
}
|
||||
|
||||
func TestCLI(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Chdir(root)
|
||||
|
||||
if code, out, _ := do(t, "check"); code != 0 || out != "ok: 0 records checked\n" {
|
||||
t.Fatalf("empty check: %d %q", code, out)
|
||||
}
|
||||
code, out, _ := do(t, "new", "adr", "Use X")
|
||||
if code != 0 || out != "docs/adr/20261023T204512345Z-use-x.md\n" {
|
||||
t.Fatalf("new: %d %q", code, out)
|
||||
}
|
||||
code, out, _ = do(t, "check")
|
||||
var want strings.Builder
|
||||
for _, line := range []string{"6", "8", "20", "24", "28", "32"} { // numeric, not lexical, order
|
||||
want.WriteString("docs/adr/20261023T204512345Z-use-x.md:" + line + ": unfinished placeholder {{docs:todo}}\n")
|
||||
}
|
||||
if code != 1 || out != want.String() {
|
||||
t.Fatalf("check with markers: %d %q", code, out)
|
||||
}
|
||||
// --root checks another directory; the working directory is not used.
|
||||
t.Chdir(t.TempDir())
|
||||
if code, _, _ := do(t, "check", "--root", root); code != 1 {
|
||||
t.Errorf("--root: %d", code)
|
||||
}
|
||||
if code, out, _ := do(t, "check"); code != 0 || !strings.HasPrefix(out, "ok: ") {
|
||||
t.Errorf("cwd root: %d %q", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIExitCodes(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
must := func(err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
must(os.MkdirAll("docs/adr", 0o755))
|
||||
must(os.WriteFile(filepath.Join("docs", "adr", "bad.md"), nil, 0o644))
|
||||
for _, tt := range []struct {
|
||||
args []string
|
||||
code int
|
||||
}{
|
||||
{nil, 2},
|
||||
{[]string{"bogus"}, 2},
|
||||
{[]string{"new"}, 2},
|
||||
{[]string{"new", "adr"}, 2},
|
||||
{[]string{"new", "note", "T"}, 2},
|
||||
{[]string{"new", "adr", "T", "extra"}, 2},
|
||||
{[]string{"new", "adr", "!!!"}, 1},
|
||||
{[]string{"check", "--bogus"}, 2},
|
||||
{[]string{"check", "extra"}, 2},
|
||||
{[]string{"check", "--root", "missing-dir"}, 1},
|
||||
{[]string{"check"}, 1}, // bad.md is not a valid name
|
||||
} {
|
||||
if code, _, stderr := do(t, tt.args...); code != tt.code {
|
||||
t.Errorf("docs %v = %d, want %d (stderr %q)", tt.args, code, tt.code, stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
// Package authoring owns the standalone tool's bounded migration source format.
|
||||
// It requires one writer and a complete source backup; it never accesses a DB.
|
||||
package authoring
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/format"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
const marker = "// Code generated by corestack-goose. DO NOT EDIT.\n"
|
||||
const goTemplate = `package migrations
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
func migration{{.CamelName}}(version int64) *goose.Migration {
|
||||
return goose.NewGoMigration(version, &goose.GoFunc{RunTx: up{{.CamelName}}}, &goose.GoFunc{RunTx: down{{.CamelName}}})
|
||||
}
|
||||
func up{{.CamelName}}(ctx context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
func down{{.CamelName}}(ctx context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
`
|
||||
|
||||
type migration struct {
|
||||
name, factory string
|
||||
version int64
|
||||
data []byte
|
||||
}
|
||||
|
||||
type change struct {
|
||||
source, dest string
|
||||
data []byte
|
||||
replace bool
|
||||
}
|
||||
|
||||
type sourceSet struct {
|
||||
dir string
|
||||
migrations []migration
|
||||
register []byte
|
||||
}
|
||||
|
||||
// Create stages Goose's supported templates, then publishes a preflighted set.
|
||||
// Go names use lowercase ASCII words separated by underscores. All versions and
|
||||
// identifiers must be unique across the directory; existing bodies are preserved.
|
||||
func Create(dir, name, kind string) error {
|
||||
if !validName(name) || (kind != "sql" && kind != "go") {
|
||||
return errors.New("create: expected lowercase_words name and sql|go")
|
||||
}
|
||||
set, err := scan(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stage, err := os.MkdirTemp("", "corestack-goose-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(stage)
|
||||
var tmpl *template.Template
|
||||
if kind == "go" {
|
||||
tmpl, err = template.New("migration").Parse(goTemplate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := goose.CreateWithTemplate(nil, stage, tmpl, name, kind); err != nil {
|
||||
return fmt.Errorf("stage creation: %w", err)
|
||||
}
|
||||
entries, err := os.ReadDir(stage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
return errors.New("stage creation: expected one migration")
|
||||
}
|
||||
filename := entries[0].Name()
|
||||
data, err := os.ReadFile(filepath.Join(stage, filename))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if kind == "go" {
|
||||
filename = strings.TrimSuffix(filename, ".go") + "_migration.go"
|
||||
data, err = format.Source(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
item, err := parseMigration(filename, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items := append(slices.Clone(set.migrations), item)
|
||||
if err := validate(items); err != nil {
|
||||
return err
|
||||
}
|
||||
register, err := aggregate(items)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changes := []change{{dest: filename, data: data}}
|
||||
changes = appendRegister(changes, set, register)
|
||||
return publish(set, changes, writeExclusive)
|
||||
}
|
||||
|
||||
// Fix preserves sequential IDs and allocates timestamp IDs after their maximum.
|
||||
// It never rewrites migration bodies or database history. Commit or separately
|
||||
// back up all sources first; on failure restore that backup before retrying.
|
||||
func Fix(dir string) error {
|
||||
set, err := scan(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items := slices.Clone(set.migrations)
|
||||
var highest int64
|
||||
for _, item := range items {
|
||||
if !timestamp(item.version) && item.version > highest {
|
||||
highest = item.version
|
||||
}
|
||||
}
|
||||
var changes []change
|
||||
for i := range items {
|
||||
if !timestamp(items[i].version) {
|
||||
continue
|
||||
}
|
||||
if highest == math.MaxInt64 {
|
||||
return errors.New("fix: sequential version overflow")
|
||||
}
|
||||
highest++
|
||||
old := items[i].name
|
||||
items[i].version = highest
|
||||
items[i].name = fmt.Sprintf("%05d_%s", highest, strings.SplitN(old, "_", 2)[1])
|
||||
changes = append(changes, change{source: old, dest: items[i].name, data: items[i].data})
|
||||
}
|
||||
if err := validate(items); err != nil {
|
||||
return err
|
||||
}
|
||||
register, err := aggregate(items)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changes = appendRegister(changes, set, register)
|
||||
return publish(set, changes, writeExclusive)
|
||||
}
|
||||
|
||||
func timestamp(version int64) bool {
|
||||
t, err := time.Parse("20060102150405", strconv.FormatInt(version, 10))
|
||||
return err == nil && t.Unix() > 0
|
||||
}
|
||||
|
||||
func validName(name string) bool {
|
||||
for _, word := range strings.Split(name, "_") {
|
||||
if word == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range word {
|
||||
if r < 'a' || r > 'z' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseMigration(name string, data []byte) (migration, error) {
|
||||
item := migration{name: name, data: data}
|
||||
ext := filepath.Ext(name)
|
||||
parts := strings.SplitN(strings.TrimSuffix(name, ext), "_", 2)
|
||||
if len(parts) != 2 {
|
||||
return item, fmt.Errorf("unsupported migration filename %s", name)
|
||||
}
|
||||
raw := parts[0]
|
||||
if strings.IndexFunc(raw, func(r rune) bool { return r < '0' || r > '9' }) >= 0 {
|
||||
return item, fmt.Errorf("invalid version in %s", name)
|
||||
}
|
||||
v, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || v <= 0 {
|
||||
return item, fmt.Errorf("invalid positive int64 version in %s", name)
|
||||
}
|
||||
item.version = v
|
||||
if ext == ".sql" {
|
||||
return item, nil
|
||||
}
|
||||
logical, ok := strings.CutSuffix(parts[1], "_migration")
|
||||
if !ok || !validName(logical) {
|
||||
return item, fmt.Errorf("unsupported Go filename %s: use <version>_<lowercase_words>_migration.go", name)
|
||||
}
|
||||
var camel strings.Builder
|
||||
for _, word := range strings.Split(logical, "_") {
|
||||
camel.WriteString(strings.ToUpper(word[:1]) + word[1:])
|
||||
}
|
||||
item.factory = "migration" + camel.String()
|
||||
file, err := parser.ParseFile(token.NewFileSet(), name, data, 0)
|
||||
if err != nil {
|
||||
return item, fmt.Errorf("parse %s: %w", name, err)
|
||||
}
|
||||
if file.Name.Name != "migrations" {
|
||||
return item, fmt.Errorf("%s: expected package migrations", name)
|
||||
}
|
||||
count := 0
|
||||
for _, decl := range file.Decls {
|
||||
fn, ok := decl.(*ast.FuncDecl)
|
||||
if !ok || fn.Name.Name != item.factory {
|
||||
continue
|
||||
}
|
||||
var signature bytes.Buffer
|
||||
if err := format.Node(&signature, token.NewFileSet(), fn.Type); err != nil {
|
||||
return item, err
|
||||
}
|
||||
if fn.Recv != nil || signature.String() != "func(version int64) *goose.Migration" || fn.Body == nil {
|
||||
return item, fmt.Errorf("%s: expected %s(version int64) *goose.Migration factory", name, item.factory)
|
||||
}
|
||||
count++
|
||||
}
|
||||
if count != 1 {
|
||||
return item, fmt.Errorf("%s: expected one %s factory", name, item.factory)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func validate(items []migration) error {
|
||||
slices.SortFunc(items, func(a, b migration) int {
|
||||
if a.version < b.version {
|
||||
return -1
|
||||
}
|
||||
if a.version > b.version {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
factories := make(map[string]bool)
|
||||
for i, item := range items {
|
||||
if i > 0 {
|
||||
previous := items[i-1]
|
||||
if previous.version == item.version {
|
||||
return fmt.Errorf("duplicate version: %s and %s", previous.name, item.name)
|
||||
}
|
||||
if previous.name >= item.name {
|
||||
return fmt.Errorf("lexical/numeric order disagreement: %s and %s; use consistent padding", previous.name, item.name)
|
||||
}
|
||||
}
|
||||
if item.factory != "" {
|
||||
if factories[item.factory] {
|
||||
return fmt.Errorf("duplicate factory %s", item.factory)
|
||||
}
|
||||
factories[item.factory] = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func aggregate(items []migration) ([]byte, error) {
|
||||
var out strings.Builder
|
||||
out.WriteString(marker + "\n// Package migrations contains application-owned SQL and Go migrations.\n// corestack-goose owns this aggregate; registrations are provider-local.\npackage migrations\n\nimport \"github.com/pressly/goose/v3\"\n\n// GoMigrations returns fresh provider-local registrations on every call.\nfunc GoMigrations() []*goose.Migration {\nreturn []*goose.Migration{\n")
|
||||
for _, item := range items {
|
||||
if item.factory != "" {
|
||||
fmt.Fprintf(&out, "%s(%d),\n", item.factory, item.version)
|
||||
}
|
||||
}
|
||||
out.WriteString("}\n}\n")
|
||||
return format.Source([]byte(out.String()))
|
||||
}
|
||||
|
||||
func scan(dir string) (sourceSet, error) {
|
||||
set := sourceSet{dir: dir}
|
||||
absolute, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return set, err
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(absolute)
|
||||
if err != nil {
|
||||
return set, err
|
||||
}
|
||||
if resolved != absolute {
|
||||
return set, fmt.Errorf("%s: symlink paths are unsupported", dir)
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return set, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if name != "register.go" && filepath.Ext(name) != ".go" && filepath.Ext(name) != ".sql" {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return set, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return set, fmt.Errorf("%s: expected regular file", name)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
return set, err
|
||||
}
|
||||
if name == "register.go" {
|
||||
set.register = data
|
||||
continue
|
||||
}
|
||||
item, err := parseMigration(name, data)
|
||||
if err != nil {
|
||||
return set, err
|
||||
}
|
||||
set.migrations = append(set.migrations, item)
|
||||
}
|
||||
if err := validate(set.migrations); err != nil {
|
||||
return set, err
|
||||
}
|
||||
expected, err := aggregate(set.migrations)
|
||||
if err != nil {
|
||||
return set, err
|
||||
}
|
||||
if set.register != nil {
|
||||
normalized, err := format.Source(set.register)
|
||||
if err != nil || !bytes.HasPrefix(set.register, []byte(marker)) || !bytes.Equal(normalized, expected) {
|
||||
return set, errors.New("register.go: unsupported or drifted aggregate; restore the complete source backup")
|
||||
}
|
||||
} else {
|
||||
for _, item := range set.migrations {
|
||||
if item.factory != "" {
|
||||
return set, errors.New("register.go missing: restore the complete source backup")
|
||||
}
|
||||
}
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
|
||||
func appendRegister(changes []change, set sourceSet, data []byte) []change {
|
||||
if bytes.Equal(data, set.register) {
|
||||
return changes
|
||||
}
|
||||
return append(changes, change{dest: "register.go", data: data, replace: set.register != nil})
|
||||
}
|
||||
|
||||
func writeExclusive(path string, data []byte) error {
|
||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o666)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, writeErr := io.Copy(file, bytes.NewReader(data))
|
||||
return errors.Join(writeErr, file.Close())
|
||||
}
|
||||
|
||||
// invariant: publish complete destinations before deleting sources; a failure
|
||||
// stops the plan and requires manual source restoration, never an automatic retry.
|
||||
func publish(set sourceSet, changes []change, write func(string, []byte) error) error {
|
||||
if len(changes) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, op := range changes {
|
||||
if !op.replace {
|
||||
if _, err := os.Lstat(filepath.Join(set.dir, op.dest)); !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("destination %s exists or cannot be checked: %w", op.dest, errors.Join(err, os.ErrExist))
|
||||
}
|
||||
}
|
||||
}
|
||||
stage, err := os.MkdirTemp(set.dir, ".corestack-goose-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(stage)
|
||||
for i, op := range changes {
|
||||
if err := writeExclusive(filepath.Join(stage, strconv.Itoa(i)), op.data); err != nil {
|
||||
return fmt.Errorf("stage %s: %w", op.dest, err)
|
||||
}
|
||||
}
|
||||
var completed []string
|
||||
for i, op := range changes {
|
||||
dest := filepath.Join(set.dir, op.dest)
|
||||
if op.replace {
|
||||
var current []byte
|
||||
info, statErr := os.Lstat(dest)
|
||||
if statErr != nil || !info.Mode().IsRegular() {
|
||||
err = fmt.Errorf("aggregate is no longer regular: %w", errors.Join(statErr, os.ErrInvalid))
|
||||
} else {
|
||||
current, err = os.ReadFile(dest)
|
||||
if err == nil && !bytes.Equal(current, set.register) {
|
||||
err = errors.New("aggregate changed since preflight")
|
||||
}
|
||||
if err == nil {
|
||||
err = os.Rename(filepath.Join(stage, strconv.Itoa(i)), dest)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
err = write(dest, op.data)
|
||||
}
|
||||
if err == nil {
|
||||
completed = append(completed, op.dest)
|
||||
if op.source != "" {
|
||||
err = os.Remove(filepath.Join(set.dir, op.source))
|
||||
if err == nil {
|
||||
completed = append(completed, "removed "+op.source)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
var outstanding []string
|
||||
for _, pending := range changes[i:] {
|
||||
outstanding = append(outstanding, fmt.Sprintf("%s -> %s", pending.source, pending.dest))
|
||||
}
|
||||
return fmt.Errorf("authoring stopped: %w; completed %v; inspect current/partial destination %s; outstanding %v; restore complete source backup before retry", err, completed, op.dest, outstanding)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package authoring
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func directory(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
func put(t *testing.T, dir, name string, data []byte) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func snapshot(t *testing.T, dir string) map[string]string {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := map[string]string{}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
result[entry.Name()] = "<directory>"
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result[entry.Name()] = string(data)
|
||||
}
|
||||
return result
|
||||
}
|
||||
func seedGo(t *testing.T, dir, name string, version int64) {
|
||||
t.Helper()
|
||||
if err := Create(dir, name, "go"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set, err := scan(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item := set.migrations[len(set.migrations)-1]
|
||||
old := item.name
|
||||
item.name = fmt.Sprintf("%05d_%s", version, strings.SplitN(old, "_", 2)[1])
|
||||
item.version = version
|
||||
if err := os.Rename(filepath.Join(dir, old), filepath.Join(dir, item.name)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set.migrations[len(set.migrations)-1] = item
|
||||
data, err := aggregate(set.migrations)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
put(t, dir, "register.go", data)
|
||||
}
|
||||
func TestCreateFixAndPreserveEdits(t *testing.T) {
|
||||
dir := directory(t)
|
||||
seedGo(t, dir, "test", 20261028093000)
|
||||
name := "20261028093000_test_migration.go"
|
||||
before := snapshot(t, dir)[name]
|
||||
edited := strings.Replace(before, "return nil", "// application body\n return tx.QueryRowContext(ctx, \"SELECT 1\").Err()", 1)
|
||||
put(t, dir, name, []byte(edited))
|
||||
put(t, dir, "00007_existing.sql", []byte("-- +goose Up\nSELECT 1;\n-- +goose Down\nSELECT 1;\n"))
|
||||
put(t, dir, "20261028103000_next.sql", []byte("-- +goose Up\nSELECT 2;\n-- +goose Down\nSELECT 2;\n"))
|
||||
if err := Fix(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := snapshot(t, dir)
|
||||
if after["00008_test_migration.go"] != edited || after["00009_next.sql"] == "" || !strings.Contains(after["register.go"], "migrationTest(8)") {
|
||||
t.Fatal(after)
|
||||
}
|
||||
if err := Fix(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(after, snapshot(t, dir)) {
|
||||
t.Fatal("repeated fix changed set")
|
||||
}
|
||||
if err := Create(dir, "new_sql", "sql"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Fix(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot(t, dir)["00010_new_sql.sql"] == "" {
|
||||
t.Fatal("creation failed to preserve sequence")
|
||||
}
|
||||
}
|
||||
func TestPreflightRejections(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
prepare func(*testing.T, string)
|
||||
want string
|
||||
}{
|
||||
{"duplicate", func(t *testing.T, d string) { put(t, d, "00001_one.sql", nil); put(t, d, "1_two.sql", nil) }, "duplicate version"},
|
||||
{"invalid", func(t *testing.T, d string) { put(t, d, "-1_bad.sql", nil) }, "invalid version"},
|
||||
{"zero", func(t *testing.T, d string) { put(t, d, "00000_bad.sql", nil) }, "positive int64"},
|
||||
{"overflow", func(t *testing.T, d string) { put(t, d, "9223372036854775808_bad.sql", nil) }, "positive int64"},
|
||||
{"order", func(t *testing.T, d string) { put(t, d, "2_two.sql", nil); put(t, d, "10_ten.sql", nil) }, "order disagreement"},
|
||||
{"output-order", func(t *testing.T, d string) { put(t, d, "1_one.sql", nil); put(t, d, "20261028093000_two.sql", nil) }, "order disagreement"},
|
||||
{"factory", func(t *testing.T, d string) {
|
||||
seedGo(t, d, "example", 1)
|
||||
p := filepath.Join(d, "00001_example_migration.go")
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
put(t, d, filepath.Base(p), bytes.ReplaceAll(b, []byte("version int64"), []byte("version int")))
|
||||
}, "expected migrationExample"},
|
||||
{"package", func(t *testing.T, d string) {
|
||||
seedGo(t, d, "example", 1)
|
||||
b := snapshot(t, d)["00001_example_migration.go"]
|
||||
put(t, d, "00001_example_migration.go", []byte(strings.Replace(b, "package migrations", "package other", 1)))
|
||||
}, "package migrations"},
|
||||
{"stock", func(t *testing.T, d string) { put(t, d, "00001_example.go", []byte("package migrations")) }, "unsupported Go filename"},
|
||||
{"missing-aggregate", func(t *testing.T, d string) {
|
||||
seedGo(t, d, "example", 1)
|
||||
if err := os.Remove(filepath.Join(d, "register.go")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, "register.go missing"},
|
||||
{"drift", func(t *testing.T, d string) {
|
||||
seedGo(t, d, "example", 1)
|
||||
b := snapshot(t, d)["register.go"]
|
||||
put(t, d, "register.go", []byte(strings.Replace(b, "migrationExample(1)", "migrationExample(2)", 1)))
|
||||
}, "drifted aggregate"},
|
||||
{"handwritten", func(t *testing.T, d string) { put(t, d, "register.go", []byte("package migrations")) }, "drifted aggregate"},
|
||||
{"destination-dir", func(t *testing.T, d string) {
|
||||
put(t, d, "20261028093000_one.sql", nil)
|
||||
if err := os.Mkdir(filepath.Join(d, "00001_one.sql"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, "regular file"},
|
||||
{"sequential-overflow", func(t *testing.T, d string) {
|
||||
put(t, d, "0000020261028093000_one.sql", nil)
|
||||
put(t, d, "9223372036854775807_last.sql", nil)
|
||||
}, "sequential version overflow"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := directory(t)
|
||||
tc.prepare(t, dir)
|
||||
before := snapshot(t, dir)
|
||||
// Directories are checked separately; every file must survive preflight unchanged.
|
||||
err := Fix(dir)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("wanted %s: %v", tc.want, err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, snapshot(t, dir)) {
|
||||
t.Fatal("preflight failure changed files")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestCreateValidationAndCollisions(t *testing.T) {
|
||||
for _, name := range []string{"", "../bad", "Test", "bad__name", "bad2", "bad-name"} {
|
||||
if err := Create(directory(t), name, "go"); err == nil {
|
||||
t.Fatalf("accepted %q", name)
|
||||
}
|
||||
}
|
||||
if err := Create(directory(t), "valid", "other"); err == nil {
|
||||
t.Fatal("accepted unsupported type")
|
||||
}
|
||||
dir := directory(t)
|
||||
seedGo(t, dir, "example", 1)
|
||||
before := snapshot(t, dir)
|
||||
if err := Create(dir, "example", "go"); err == nil || !strings.Contains(err.Error(), "duplicate factory") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, snapshot(t, dir)) {
|
||||
t.Fatal("rejected create changed sources")
|
||||
}
|
||||
}
|
||||
func TestSymlinksRejected(t *testing.T) {
|
||||
dir := directory(t)
|
||||
link := filepath.Join(directory(t), "link")
|
||||
if err := os.Symlink(dir, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Fix(link); err == nil || !strings.Contains(err.Error(), "symlink") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := filepath.Join(directory(t), "source")
|
||||
if err := os.WriteFile(source, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(source, filepath.Join(dir, "00001_one.sql")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Fix(dir); err == nil || !strings.Contains(err.Error(), "regular file") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestTimestampClassification(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
v int64
|
||||
want bool
|
||||
}{{1, false}, {19700101000000, false}, {19700101000001, true}, {19691231235959, false}, {20260230093000, false}, {20261028093000, true}} {
|
||||
if got := timestamp(tc.v); got != tc.want {
|
||||
t.Fatalf("%d timestamp=%v", tc.v, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestPartialPublicationAndManualRecovery(t *testing.T) {
|
||||
dir := directory(t)
|
||||
seedGo(t, dir, "example", 20261028093000)
|
||||
before := snapshot(t, dir)
|
||||
set, err := scan(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item := set.migrations[0]
|
||||
item.name = "00001_example_migration.go"
|
||||
item.version = 1
|
||||
aggregateData, err := aggregate([]migration{item})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changes := []change{{source: set.migrations[0].name, dest: item.name, data: item.data}, {dest: "00002_next.sql", data: []byte("example")}, {dest: "register.go", replace: true, data: aggregateData}}
|
||||
failure := errors.New("injected disk failure")
|
||||
err = publish(set, changes, func(path string, data []byte) error {
|
||||
if strings.HasSuffix(path, "00002_next.sql") {
|
||||
if err := writeExclusive(path, []byte("partial")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return failure
|
||||
}
|
||||
return writeExclusive(path, data)
|
||||
})
|
||||
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "completed [00001_example_migration.go removed 20261028093000_example_migration.go]") || !strings.Contains(err.Error(), "outstanding") || !strings.Contains(err.Error(), "restore complete source backup") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot(t, dir)["00002_next.sql"] != "partial" {
|
||||
t.Fatal("partial destination not reported/preserved")
|
||||
}
|
||||
if err := Fix(dir); err == nil {
|
||||
t.Fatal("inconsistent interrupted set accepted")
|
||||
}
|
||||
// Manual recovery restores all paths, including untracked sources and aggregate.
|
||||
for name := range snapshot(t, dir) {
|
||||
if err := os.Remove(filepath.Join(dir, name)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for name, data := range before {
|
||||
put(t, dir, name, []byte(data))
|
||||
}
|
||||
if err := Fix(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot(t, dir)["00001_example_migration.go"] == "" {
|
||||
t.Fatal("recovered fix failed")
|
||||
}
|
||||
}
|
||||
func TestExclusiveDestinationAndAggregateRecheck(t *testing.T) {
|
||||
dir := directory(t)
|
||||
if err := Fix(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
set, err := scan(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
put(t, dir, "00001_collision.sql", []byte("existing"))
|
||||
err = publish(set, []change{{dest: "00001_collision.sql", data: []byte("replace")}}, writeExclusive)
|
||||
if err == nil || snapshot(t, dir)["00001_collision.sql"] != "existing" {
|
||||
t.Fatal("overwrote destination", err)
|
||||
}
|
||||
err = writeExclusive(filepath.Join(dir, "00001_collision.sql"), []byte("replace"))
|
||||
if !errors.Is(err, os.ErrExist) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = publish(set, []change{{dest: "00002_ok.sql"}, {dest: "register.go", replace: true, data: []byte("new")}}, func(path string, data []byte) error {
|
||||
put(t, dir, "register.go", []byte("editor changed"))
|
||||
return writeExclusive(path, data)
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "aggregate changed since preflight") || snapshot(t, dir)["register.go"] != "editor changed" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovalFailureStopsPublication(t *testing.T) {
|
||||
dir := directory(t)
|
||||
source := "20261028093000_one.sql"
|
||||
if err := os.Mkdir(filepath.Join(dir, source), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
put(t, filepath.Join(dir, source), "block-removal", nil)
|
||||
err := publish(sourceSet{dir: dir}, []change{{source: source, dest: "00001_one.sql", data: []byte("complete destination")}, {dest: "00002_later.sql"}}, writeExclusive)
|
||||
if err == nil || !strings.Contains(err.Error(), "completed [00001_one.sql]") || !strings.Contains(err.Error(), "outstanding") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "00002_later.sql")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("continued after remove failure", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "00001_one.sql"))
|
||||
if err != nil || string(data) != "complete destination" {
|
||||
t.Fatalf("destination lost: %s %v", data, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Command corestack-goose authors provider-local SQL and Go migrations offline.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
urfavecli "github.com/urfave/cli/v3"
|
||||
"go.auvem.com/corestack/cmd/corestack-goose/internal/authoring"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := command().Run(context.Background(), os.Args); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func command() *urfavecli.Command {
|
||||
root := &urfavecli.Command{Name: "corestack-goose", Usage: "Author migrations before deployment"}
|
||||
for _, name := range []string{"create", "fix"} {
|
||||
root.Commands = append(root.Commands, &urfavecli.Command{Name: name,
|
||||
Flags: []urfavecli.Flag{&urfavecli.StringFlag{Name: "dir", Value: ".", Usage: "Migration directory (defaults to the current working directory)"}},
|
||||
Action: func(_ context.Context, cmd *urfavecli.Command) error {
|
||||
if name == "create" {
|
||||
if cmd.NArg() != 2 {
|
||||
return fmt.Errorf("usage: create [--dir <dir>] <name> sql|go")
|
||||
}
|
||||
return authoring.Create(cmd.String("dir"), cmd.Args().Get(0), cmd.Args().Get(1))
|
||||
}
|
||||
if cmd.NArg() != 0 {
|
||||
return fmt.Errorf("usage: fix [--dir <dir>]")
|
||||
}
|
||||
return authoring.Fix(cmd.String("dir"))
|
||||
},
|
||||
})
|
||||
}
|
||||
return root
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGeneratedConsumerAndArtifactBoundary(t *testing.T) {
|
||||
root, err := filepath.Abs("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
execute := func(cwd, name string, args ...string) []byte {
|
||||
t.Helper()
|
||||
cmd := exec.CommandContext(t.Context(), name, args...)
|
||||
cmd.Dir = cwd
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%s %v: %v\n%s", name, args, err, out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
tool := filepath.Join(dir, "corestack-goose")
|
||||
execute(root, "go", "build", "-o", tool, "./cmd/corestack-goose")
|
||||
migrations := filepath.Join(dir, "migrations")
|
||||
if err := os.Mkdir(migrations, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
osName := "windows"
|
||||
if runtime.GOOS == "windows" {
|
||||
osName = "linux"
|
||||
}
|
||||
for i, tc := range []struct{ name, kind string }{{"initial", "sql"}, {"test", "go"}, {"create_test", "go"}, {osName, "go"}} {
|
||||
if i%2 == 0 {
|
||||
execute(migrations, tool, "create", tc.name, tc.kind)
|
||||
execute(migrations, tool, "fix")
|
||||
} else {
|
||||
execute(dir, tool, "create", "--dir", migrations, tc.name, tc.kind)
|
||||
execute(dir, tool, "fix", "--dir", migrations)
|
||||
}
|
||||
}
|
||||
execute(dir, tool, "fix", "--dir", migrations)
|
||||
entries, err := os.ReadDir(migrations)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantFiles := []string{"00001_initial.sql", "00002_test_migration.go", "00003_create_test_migration.go", "00004_" + osName + "_migration.go", "register.go"}
|
||||
if len(entries) != len(wantFiles) {
|
||||
t.Fatalf("migration files: %v", entries)
|
||||
}
|
||||
for i, entry := range entries {
|
||||
if entry.Name() != wantFiles[i] {
|
||||
t.Fatalf("migration file: got %s, want %s", entry.Name(), wantFiles[i])
|
||||
}
|
||||
}
|
||||
entries, err = os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 2 || entries[0].Name() != "corestack-goose" || entries[1].Name() != "migrations" {
|
||||
t.Fatalf("unexpected writes outside migration directory: %v", entries)
|
||||
}
|
||||
source, err := os.ReadFile("testdata/consumer/main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "main.go"), source, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
module := fmt.Sprintf("module example.com/consumer\n\ngo 1.27.1\n\nrequire go.auvem.com/corestack v0.0.0\nreplace go.auvem.com/corestack => %s\n", filepath.ToSlash(root))
|
||||
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(module), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binary := filepath.Join(dir, "consumer")
|
||||
execute(dir, "go", "build", "-mod=mod", "-o", binary, ".")
|
||||
out := execute(dir, binary, filepath.Join(dir, "consumer.db"))
|
||||
if !strings.Contains(string(out), "version=4 pending=false versions=1,2,3,4 fresh=true isolated-pending=true") {
|
||||
t.Fatal(string(out))
|
||||
}
|
||||
// Decode package identities rather than guessing from substring matches in go list.
|
||||
deps := execute(dir, "go", "list", "-deps", "-json", ".")
|
||||
decoder := json.NewDecoder(bytes.NewReader(deps))
|
||||
adapterFound := false
|
||||
for decoder.More() {
|
||||
var dep struct{ ImportPath string }
|
||||
if err := decoder.Decode(&dep); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dep.ImportPath == "go.auvem.com/corestack/migrate/lifecycle" {
|
||||
adapterFound = true
|
||||
}
|
||||
if dep.ImportPath == "go.auvem.com/corestack/cmd/corestack-goose" || strings.HasPrefix(dep.ImportPath, "go.auvem.com/corestack/cmd/corestack-goose/") {
|
||||
t.Fatalf("production imports authoring: %s", dep.ImportPath)
|
||||
}
|
||||
}
|
||||
if !adapterFound {
|
||||
t.Fatal("production fixture did not import runtime adapter")
|
||||
}
|
||||
toolSymbols := execute(dir, "go", "tool", "nm", tool)
|
||||
toolEntries := map[string]bool{}
|
||||
for _, line := range strings.Split(string(toolSymbols), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 3 {
|
||||
toolEntries[fields[len(fields)-1]] = true
|
||||
}
|
||||
}
|
||||
for _, entry := range []string{"github.com/pressly/goose/v3.CreateWithTemplate", "go.auvem.com/corestack/cmd/corestack-goose/internal/authoring.Create", "go.auvem.com/corestack/cmd/corestack-goose/internal/authoring.Fix"} {
|
||||
if !toolEntries[entry] {
|
||||
t.Fatalf("standalone authoring entrypoint missing: %s", entry)
|
||||
}
|
||||
}
|
||||
runtimeFound := false
|
||||
symbols := execute(dir, "go", "tool", "nm", binary)
|
||||
for _, line := range strings.Split(string(symbols), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
symbol := fields[len(fields)-1]
|
||||
if symbol == "go.auvem.com/corestack/migrate/lifecycle.Commands" {
|
||||
runtimeFound = true
|
||||
}
|
||||
if strings.HasPrefix(symbol, "go.auvem.com/corestack/cmd/corestack-goose/") {
|
||||
t.Fatalf("authoring symbol linked: %s", symbol)
|
||||
}
|
||||
for _, entry := range []string{"Create", "CreateWithTemplate", "Fix", "SetSequential"} {
|
||||
full := "github.com/pressly/goose/v3." + entry
|
||||
if symbol == full || strings.HasPrefix(symbol, full+".") {
|
||||
t.Fatalf("upstream authoring entrypoint linked: %s", symbol)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !runtimeFound {
|
||||
t.Fatal("runtime command symbol missing from production executable")
|
||||
}
|
||||
t.Log("standalone create/fix; unchanged generated SQL+safe-suffix Go compiled and applied; runtime dependency identities and linker symbols verified")
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
// Command consumer verifies generated registrations and the production boundary.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"example.com/consumer/migrations"
|
||||
"github.com/pressly/goose/v3"
|
||||
urfavecli "github.com/urfave/cli/v3"
|
||||
"go.auvem.com/corestack/lifecycle"
|
||||
"go.auvem.com/corestack/lifecycle/cli"
|
||||
"go.auvem.com/corestack/migrate"
|
||||
adapter "go.auvem.com/corestack/migrate/lifecycle"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed migrations
|
||||
var files embed.FS
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
var db *sql.DB
|
||||
dbModule := lifecycle.Module{Name: "database", Setup: func(context.Context) error { var err error; db, err = sql.Open("sqlite", os.Args[1]); return err }, Teardown: func(context.Context) error { return db.Close() }}
|
||||
resolver := func(context.Context) (*goose.Provider, error) {
|
||||
return migrate.New(db, goose.DialectSQLite3, files, "migrations", logger, migrations.GoMigrations()...)
|
||||
}
|
||||
root := &urfavecli.Command{Name: "consumer", Commands: []*urfavecli.Command{adapter.Commands(lifecycle.Config{Logger: logger}, []lifecycle.Module{dbModule}, resolver)}}
|
||||
code, err := cli.Run(ctx, root, []string{"consumer", "migrate", "up"})
|
||||
must(err)
|
||||
if code != 0 {
|
||||
panic(code)
|
||||
}
|
||||
db, err = sql.Open("sqlite", os.Args[1])
|
||||
must(err)
|
||||
defer db.Close()
|
||||
p, err := resolver(ctx)
|
||||
must(err)
|
||||
version, err := p.GetDBVersion(ctx)
|
||||
must(err)
|
||||
if version != 4 {
|
||||
panic(version)
|
||||
}
|
||||
pending, err := p.HasPending(ctx)
|
||||
must(err)
|
||||
if pending {
|
||||
panic("pending after apply")
|
||||
}
|
||||
statuses, err := p.Status(ctx)
|
||||
must(err)
|
||||
if len(statuses) != 4 {
|
||||
panic(len(statuses))
|
||||
}
|
||||
for i, status := range statuses {
|
||||
if status.Source.Version != int64(i+1) || status.State != goose.StateApplied {
|
||||
panic(status)
|
||||
}
|
||||
}
|
||||
a, b := migrations.GoMigrations(), migrations.GoMigrations()
|
||||
if len(a) != 3 || len(b) != 3 {
|
||||
panic("missing safe-suffix Go migrations")
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] == b[i] || a[i].Version != int64(i+2) || b[i].Version != int64(i+2) {
|
||||
panic("registration/version isolation")
|
||||
}
|
||||
}
|
||||
isolatedDB, err := sql.Open("sqlite", ":memory:")
|
||||
must(err)
|
||||
defer isolatedDB.Close()
|
||||
isolated, err := migrate.New(isolatedDB, goose.DialectSQLite3, files, "migrations", logger, migrations.GoMigrations()...)
|
||||
must(err)
|
||||
pending, err = isolated.HasPending(ctx)
|
||||
must(err)
|
||||
if !pending {
|
||||
panic("provider isolation")
|
||||
}
|
||||
fmt.Println("version=4 pending=false versions=1,2,3,4 fresh=true isolated-pending=true")
|
||||
}
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
---
|
||||
title: Adopt a checked format for ADRs and implementation plans
|
||||
date: 2026-10-23
|
||||
status: accepted
|
||||
implementation: implemented
|
||||
summary: Use one documented ADR and plan format, enforced by a shared Go CLI (docs check).
|
||||
tags:
|
||||
- documentation
|
||||
- adr
|
||||
- tooling
|
||||
supersedes: []
|
||||
superseded_by: []
|
||||
related: []
|
||||
tracking: []
|
||||
implemented_by:
|
||||
- https://gitea.auvem.com/corestack/go/pulls/1
|
||||
---
|
||||
|
||||
# Adopt a checked format for ADRs and implementation plans
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
We record architecture decisions as ADRs and track delivery work as implementation plans. Across our repositories, four independent implementations of this practice grew up: each had its own filename scheme, frontmatter keys, status vocabulary and checking script (or none). The same concept was spelled differently from repository to repository, cross-references were relative paths that broke when files moved, and "accepted" did not say whether anything had been built. Tooling and agents had to learn each variant, and fixes to one checker did not reach the others.
|
||||
|
||||
We need one format that people and tools can write without surprises, that separates *the decision* from *its delivery*, and that is verified mechanically so records do not rot. The format must be public and documented well enough that someone can write valid files from the specification alone.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
* One vocabulary and one layout everywhere, so a record means the same thing in every repository.
|
||||
* Stable identity: a record's ID and path never change, so references stay valid.
|
||||
* The decision status and the implementation status are tracked separately.
|
||||
* Checks run locally and in CI with no service dependency.
|
||||
* Low upkeep: one tool to maintain, not one per repository.
|
||||
|
||||
## Considered Options
|
||||
|
||||
* MADR 4 template plus generic Markdown and link linting.
|
||||
* Keep a checking script per repository, converging informally.
|
||||
* Extract and publish one existing checker.
|
||||
* Define one format and implement one shared Go CLI that creates and checks records.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: "Define one format and implement one shared Go CLI that creates and checks records", because it is the only option that gives a single enforced vocabulary and identity scheme while keeping the checks free of service dependencies.
|
||||
|
||||
The format is specified in [docs-format](/docs/docs-format.md). It uses the MADR 4 layout, with `### Consequences` under Decision Outcome made mandatory, and adds the parts MADR leaves open: timestamp-based permanent IDs in filenames, a fixed frontmatter schema, a separate `implementation` field, reciprocal supersession, durable repo-root references, and a plan record with its own lifecycle. The CLI, `docs`, offers `docs new adr|plan` and `docs check`.
|
||||
|
||||
### Consequences
|
||||
|
||||
* Good, because the format has one specification, one checker and one set of error messages.
|
||||
* Good, because records are validated, so dead links, missing sections and contradictory statuses fail CI instead of accumulating.
|
||||
* Good, because the permanent ID survives title edits.
|
||||
* Bad, because the format is ours: we own the specification and the tool, and breaking changes before 1.0 will require updating adopters.
|
||||
* Neutral, because the check validates structure only; it cannot tell whether a decision is sound.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### MADR 4 template plus generic Markdown and link linting
|
||||
|
||||
* Good, because it is a public, widely understood template and nothing custom is built.
|
||||
* Bad, because MADR is a template with no checker of its own, and generic linters enforce no ID, status, supersession or reference rules.
|
||||
|
||||
### Keep a checking script per repository
|
||||
|
||||
* Good, because there is no new shared artifact and each team keeps full control.
|
||||
* Bad, because the four existing variants already diverged; nothing stops further divergence.
|
||||
* Bad, because each script is maintained, tested and fixed separately, and most repositories would simply not have one.
|
||||
|
||||
### Extract and publish one existing checker
|
||||
|
||||
* Good, because it is cheaper: an existing Go checker already handles timestamp IDs, exclusive creation and reciprocal supersession.
|
||||
* Bad, because it has no plan record, no `implementation` field and no public specification, and it is tied to one repository's conventions.
|
||||
|
||||
### Define one format and implement one shared Go CLI that creates and checks records
|
||||
|
||||
* Good, because one specification and one implementation can be tested thoroughly and installed with `go install`.
|
||||
* Good, because the same binary creates records, so new files start with the required structure.
|
||||
* Bad, because we commit to maintaining a small public tool and a specification.
|
||||
|
||||
## More Information
|
||||
|
||||
MADR 4: https://adr.github.io/madr/. The specification is [docs-format](/docs/docs-format.md); the implementation lands in the same pull request that records this decision.
|
||||
|
||||
Note (2026-10-28): the CLI was renamed from `docs` (`cmd/docs`) to `corestack-docs` (`cmd/corestack-docs`) so `go install` does not produce an ambiguous `docs` binary. The decision is unchanged.
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: "Version light core packages in the root module and nest heavy-dependency modules"
|
||||
date: 2026-10-25
|
||||
status: accepted
|
||||
implementation: implemented
|
||||
summary: Keep light core packages on root-module versions and isolate heavy or independently versioned packages in nested modules from their first commit.
|
||||
tags:
|
||||
- modules
|
||||
- versioning
|
||||
supersedes: []
|
||||
superseded_by: []
|
||||
related: []
|
||||
tracking:
|
||||
- https://gitea.auvem.com/corestack/go/issues/2
|
||||
implemented_by:
|
||||
- https://gitea.auvem.com/corestack/go/pulls/4
|
||||
---
|
||||
|
||||
# Version light core packages in the root module and nest heavy-dependency modules
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
The repository currently has one Go module, `go.auvem.com/corestack`. A plain `vX.Y.Z` tag versions every package in that module together and triggers a release of the `docs` binary. Light core packages can share this version stream and one changelog per tag; packages with heavy dependencies or independent release needs benefit from their own module boundaries.
|
||||
|
||||
Carving a package out after it has shipped in the root module is the expensive case: older root versions still contain it, while the new nested module supplies the same import path. Consumers can encounter ambiguous imports until coordinated releases and dependency upgrades remove the overlap. Starting a package as a nested module from its first commit avoids that migration.
|
||||
|
||||
The fixed dependency list in AGENTS.md described only the docs checker. It would block the planned Bob and Goose dependencies for `page` and `migrate`; dependency placement needs a module-boundary rule instead of a repository-wide allowlist.
|
||||
|
||||
## Considered Options
|
||||
|
||||
* Keep every package in the root module: simplest releases, but couples heavy and independently versioned packages to core releases and makes later extraction costly.
|
||||
* Give every package a nested module immediately: avoids later extraction, but adds module and release bookkeeping to light core packages that can share a version.
|
||||
* Keep light core packages in the root module and start heavy or independently versioned packages as nested modules: retain simple core releases while avoiding the costly extraction case.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Choose the mixed boundary: light core packages share the root module; heavy-dependency or independently versioned packages begin as nested modules.
|
||||
|
||||
* Root module, plain `v*` tags: `lifecycle`, `lifecycle/cli`, `migrate`, `page`, and `cmd/docs`. They share one version stream and one changelog per root tag. Before 1.0, breaking changes use minor version bumps.
|
||||
* Nested modules from their first commit: `gql`, `connect`, and any future package that brings heavy dependencies, such as gqlgen or protobuf, or is likely to need independent versioning. Each has its own `go.mod` and directory-prefixed tags, for example `gql/v0.1.0` and `connect/v0.1.0`.
|
||||
* Add dependencies only to the module that needs them. The named core packages remain in the root module; heavy dependencies belong in the relevant nested module.
|
||||
* Keep `cmd/docs` in the root module. The [release workflow](/.gitea/workflows/release.yml) listens only for plain `v*` tags, and [GoReleaser](/.goreleaser.yaml) builds `./cmd/docs`, so only root-module tags publish binaries. Prefixed library tags never publish binaries.
|
||||
|
||||
This decision establishes the boundary before the planned packages are added. The rule is implemented in [AGENTS.md](/AGENTS.md), and the existing release configuration already follows it. Future packages apply the rule when introduced; no empty modules are needed.
|
||||
|
||||
### Consequences
|
||||
|
||||
* Light core packages retain coordinated versions and releases. Consumers do not compile dependencies of packages they do not import; module graph pruning helps limit the dependency graph they load.
|
||||
* Heavy-dependency and independently versioned packages avoid overlapping ownership of an import path with older root-module versions.
|
||||
* Each nested module needs its own dependency maintenance, checks, and prefixed release tags. That bookkeeping is reserved for packages that need the boundary.
|
||||
* Root-module consumers share version bumps even when only another core package changes. Moving a shipped root package into a nested module later still requires coordinated releases and consumer upgrades.
|
||||
|
||||
## More Information
|
||||
|
||||
Note (2026-10-28): `cmd/docs` was renamed to `cmd/corestack-docs`; read references to `cmd/docs` above as `cmd/corestack-docs`. The decision is unchanged.
|
||||
+75
@@ -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.
|
||||
@@ -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.
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: "Run native urfave/cli commands inside selected lifecycle modules"
|
||||
date: 2026-10-28
|
||||
status: accepted
|
||||
implementation: implemented
|
||||
summary: Use native urfave/cli commands and flags with thin lifecycle action adapters and an explicit exit boundary.
|
||||
tags:
|
||||
- lifecycle
|
||||
- cli
|
||||
supersedes: []
|
||||
superseded_by: []
|
||||
related:
|
||||
- /docs/adr/20261025T214616275Z-version-light-core-packages-in-the-root-module-and-nest-heavy-dependency-modules.md
|
||||
- /docs/adr/20261027T184038082Z-supervise-module-lifecycle-and-managed-workers-within-one-shutdown-budget.md
|
||||
tracking:
|
||||
- https://gitea.auvem.com/corestack/go/issues/8
|
||||
implemented_by:
|
||||
- https://gitea.auvem.com/corestack/go/pulls/16
|
||||
---
|
||||
|
||||
# Run native urfave/cli commands inside selected lifecycle modules
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
[Issue 8](https://gitea.auvem.com/corestack/go/issues/8) needs command-specific resource setup, signal-aware work, cleanup on completion, automatic help and distinct usage failures. The owner selected `github.com/urfave/cli/v3` v3.14.0 for every command, subcommand, flag and help surface; callers declare native flags. One-offs skip module services; serve runs them. The root-module boundary and lifecycle ordering, cancellation and shared shutdown budget already stand. This clean-room implementation uses no go-toolkit/appcli source.
|
||||
|
||||
The owner accepted the three-function API, adapter-owned signal subscription, private invocation App boundary, native validation limitations and generated-help diagnostic/exit exceptions after independent review. The corrected decision was verified at [commit 1b756511](https://gitea.auvem.com/corestack/go/commit/1b7565113c20757c8a049fe44e9a035cf72fd4b7), with R1–R3 addressed and no blocking or major findings; the implementation dispatch explicitly records owner acceptance. Implementation is published in [PR 16](https://gitea.auvem.com/corestack/go/pulls/16) after independent standard and fresh high implementation reviews with no outstanding findings.
|
||||
|
||||
## Considered Options
|
||||
|
||||
* Native urfave/cli command tree with action adapters: shares native flags/help and existing lifecycle coordination; adds only selection, signal ownership and exit classification.
|
||||
* A new command/flag schema wrapping urfave/cli: could centralize declarations, but duplicates its API and increases conversion and maintenance obligations without a requested capability.
|
||||
* Standard-library flag parsing: smaller dependency graph, but would require our own subcommands/help and contradict the owner's dependency choice.
|
||||
* Per-binary urfave/cli and lifecycle wiring: no library API to maintain, but repeats signal cleanup, one-off service suppression and exit policy in each consumer.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Choose the first option in root package `go.auvem.com/corestack/lifecycle/cli`, with no lifecycle core change. Import urfave/cli as `urfavecli` to distinguish its native types from this package. The entire new public surface is:
|
||||
|
||||
```go
|
||||
// Run executes a caller-owned native tree and returns its process code and error.
|
||||
// It prints diagnostics but never exits the process; args includes argv[0].
|
||||
func Run(ctx context.Context, cliRoot *urfavecli.Command, args []string) (int, error)
|
||||
|
||||
// Serve returns an action running exactly these modules, including their services.
|
||||
func Serve(config lifecycle.Config, modules []lifecycle.Module) urfavecli.ActionFunc
|
||||
|
||||
// Action returns a one-off action using exactly these modules, without services.
|
||||
func Action(config lifecycle.Config, modules []lifecycle.Module,
|
||||
fn urfavecli.ActionFunc) urfavecli.ActionFunc
|
||||
```
|
||||
|
||||
The functions capture module declarations in their supplied order; callers list every needed module explicitly. There is no registry, name lookup, implicit dependency expansion or DAG. Callbacks close over caller-owned resource handles populated by Setup; the one-off receives the selected native command for parsed flag access. There is no public App, worker method, configuration framework or flag/environment facade. Empty module lists are valid. Nil one-off functions, nil Run context/root, invalid lifecycle config and invalid selected modules are runtime errors before resource acquisition; validate every selected declaration before starting setup. Bindings do not start work until invoked. A command tree and its mutable resources have one execution owner; construct fresh trees for independent runs and do not mutate captured declarations concurrently.
|
||||
|
||||
`Serve` constructs a fresh lifecycle App, registers selected modules and calls its Run. `Action` copies selected modules with Run cleared, then registers an internal final module with no-op Setup/Teardown and the command function as Run. Choose its nonempty name by checking all selected names and varying a private suffix until unused; no public module name is reserved. All selected setups precede work; returning from the one-off leaves no selected services to keep the App alive. Lifecycle closes admission, cancels the callback context and performs reverse cleanup. No success sentinel, cancellation-error filtering, parent-context cancellation or second shutdown timer is added. Action failures are lifecycle failures and preserve their chains. Core setup failure/partial cleanup, parent cancellation, second signal, abandonment and joined-error behavior remain authoritative. Selected module services cannot run during one-offs. The invocation App is private: modules or callbacks that need its App.Go cannot obtain it through these adapters and are incompatible with this narrow API. Capturing another App does not confer this invocation's supervision. Supported modules manage resources and optional services without that access; raw goroutines remain caller-owned and outside managed-worker completion. Exposing worker access would require a separate owner decision.
|
||||
|
||||
Each invoked adapter subscribes to SIGINT/SIGTERM using a private channel buffered for two signals when Config.Signals is nil, and defers signal.Stop. With a supplied channel, subscription, filtering and cleanup remain caller-owned as in lifecycle; a closed supplied channel disables signals. The adapter never closes either channel. No subscription or relay goroutine is needed during parsing/help. Lifecycle alone consumes/counts signals and cancels command work; do not also use signal.NotifyContext, which would turn graceful Ctrl-C into parent cancellation. After adapter return, subscription cleanup restores the prior process signal behavior; the caller owns process exit and abandoned resources.
|
||||
|
||||
Run owns the tree's ExitErrHandler, OnUsageError and CommandNotFound hooks, rejecting conflicting caller hooks as runtime configuration errors rather than silently overriding them. It installs a non-exiting root handler and usage/not-found handling on the caller-declared tree before urfave/cli runs; no urfave/cli package globals are changed. CommandNotFound returns void, so its callback records a local usage cause for this invocation; Run combines that cause with the native result even when urfave/cli returns nil, retaining any simultaneous runtime error. Native actions, Before/After, ArgValidator, flags, arguments and help templates remain caller declarations. Hooks outside the lifecycle action are outside its signal/shutdown/resource guarantee: use them for prompt validation, and put resource work inside selected modules or the adapted action. Leave group Actions unset for native group help/unknown-command handling; positional arguments on actionable leaves follow native urfave/cli declarations and validators.
|
||||
|
||||
The exit mapping is:
|
||||
|
||||
| Result | Code |
|
||||
| --- | --- |
|
||||
| No returned or locally recorded error, including valid help, empty group help, normal completion or graceful first signal with successful cleanup | 0 |
|
||||
| Lifecycle setup/action/service/teardown error, parent cancellation, budget/second-signal abandonment, or other unclassified native error | 1 |
|
||||
| Native OnUsageError path on caller-declared commands (invalid/missing flag value, required flag/argument, mutually exclusive flags, native argument parsing), or locally recorded unknown command/help topic | 2 |
|
||||
| Parse failure in a urfave/cli-generated help command, including its aliases | 1 |
|
||||
| Explicit `urfavecli.Exit(err, 2)` from pre-lifecycle caller validation | 2 |
|
||||
|
||||
A lifecycle failure always remains code 1, including action-returned ExitCoder values and joins containing cleanup failures; do not infer success by matching context.Canceled. Non-2 ExitCoder values outside lifecycle normalize to 1; an error never becomes success through ExitCode 0. Use private provenance wrappers for usage and lifecycle failures. Native After hooks may aggregate failures in urfavecli.MultiError, whose Errors() children are invisible to standard traversal. A small private bridge traverses both standard Unwrap forms and native MultiError.Errors(), recursively exposing children through Unwrap() []error while also retaining each original error/wrapper as a traversal child. It delegates Error() to the original rather than duplicating its text. Do not flatten or replace native wrappers: errors.Is must still match their identity and errors.As must still find their types, alongside nested causes and private markers.
|
||||
|
||||
Classify every semantic branch, carrying its usage/lifecycle provenance through wrappers and aggregates; aggregate containers are not extra unclassified failures. Any lifecycle branch or unclassified runtime branch dominates usage branches, including an Exit(err, 2) action plus failing After, usage plus runtime After, and nested parent/leaf After failures. Looking up only the first ExitCoder or marker is insufficient. Run returns the traversable error after cleanup and emits at most one adapter-owned final diagnostic to cliRoot.ErrWriter. This is not a single total diagnostic guarantee: native help parse handling and caller hooks may also print. Usage hooks request relevant native help through urfave/cli's Show*Help functions. Native help goes to cliRoot.Writer and diagnostics to cliRoot.ErrWriter, defaulting to stdout/stderr; callers may supply writers there. No logger/writer registry or logging policy is added. Rendering retains urfave/cli's own writer-error behavior, without promising stronger delivery guarantees.
|
||||
|
||||
Accepted owner exception: urfave/cli creates generated help nodes lazily after the pre-run hook walk; their parse failures bypass OnUsageError. Accept code 1 with native diagnostics for malformed generated help invocations (root, nested and aliases); Run's final diagnostic may repeat the native error. Valid help remains code 0, and unknown help topics remain code 2 through the recorded CommandNotFound cause. Uniform code 2 and one total diagnostic would require separate feasibility evidence and owner approval. Do not initialize by reparsing, copy native parser/help code, or detect diagnostics through error strings.
|
||||
|
||||
urfave/cli v3.14.0 does not expose all validation errors through OnUsageError: invalid environment values in Flag.PostParse bypass it, as do ArgValidator and flag-action errors. Unclassified errors from those paths are code 1. Callers needing code 2 from custom validators return `urfavecli.Exit(err, 2)` before lifecycle entry; native environment-source failures remain code 1 unless the caller validates explicitly. Do not inspect error strings, reparse arguments, wrap every flag type or promise universal parse/runtime discrimination.
|
||||
|
||||
A schematic consumer uses ordinary Go wiring; the implementation Example must be executable and show both modes:
|
||||
|
||||
```go
|
||||
cfg := lifecycle.Config{Logger: logger}
|
||||
cliRoot := &urfavecli.Command{Name: "app", Commands: []*urfavecli.Command{
|
||||
{Name: "serve", Action: cli.Serve(cfg, []lifecycle.Module{database, server})},
|
||||
{Name: "inspect", Flags: []urfavecli.Flag{&urfavecli.BoolFlag{Name: "verbose"}},
|
||||
Action: cli.Action(cfg, []lifecycle.Module{database},
|
||||
func(ctx context.Context, cmd *urfavecli.Command) error {
|
||||
return inspect(ctx, db, cmd.Bool("verbose"))
|
||||
})},
|
||||
}}
|
||||
code, err := cli.Run(context.Background(), cliRoot, os.Args)
|
||||
_ = err // already diagnosed; available for inspection by an embedding caller
|
||||
os.Exit(code) // caller-owned process boundary, after all adapter cleanup
|
||||
```
|
||||
|
||||
The dependency belongs in root go.mod/go.sum under the accepted boundary ADR. At v3.14.0 `go list -deps` reports no nonstandard production package except urfave/cli itself; its [module requirements](https://github.com/urfave/cli/blob/v3.14.0/go.mod) are for tests, with YAML already present here. This is a small CLI dependency, not a reason to introduce a nested module. The native exported types deliberately couple consumers to urfave/cli v3; we own integration tests when upgrading it.
|
||||
|
||||
### Consequences
|
||||
|
||||
* Scope is one PR, approximately 200–350 production lines plus focused tests and docs: new lifecycle/cli/cli.go (package/public comments and adapters), cli_test.go, example_test.go, root go.mod/go.sum and this ADR. No existing callers, fixtures, lifecycle core, migration package, release/config framework or GUI changes are anticipated. The reusable migration command belongs exclusively to issue 9.
|
||||
* There are no persistence writers, migrations, remote services, new auth boundaries or persistent workers. Active work consists of lifecycle-owned callbacks, its existing shutdown deadline, and a temporary OS signal subscription only while an adapted action executes. Callers own command output, cooperative callbacks, partial setup cleanup and eventual process exit; importing the package starts nothing.
|
||||
* Tests must prove selected-only setup and registration/reverse order, no one-off services, teardown on success/failure, serve services, fixed code mapping with preserved typed causes and mixed cleanup errors, native flags/subcommands/help without module setup, invalid environment classification, injected first/second signals, parent cancellation and the shared deadline. Test with injected channels and explicitly release blocked goroutines; do not send real signals. Exercise native callbacks returning ExitCoder without process exit. Add focused action/lifecycle-plus-After, usage-plus-runtime-After and nested After tests asserting code 1, original wrapper identity/types and all child causes. Cover root/nested generated help parse errors and aliases (code 1/native diagnostics), valid help (code 0), unknown commands/help topics returning native nil (code 2), and synthetic-name collisions without reserving names.
|
||||
* Native [aggregation and parse handling](https://github.com/urfave/cli/blob/v3.14.0/command_run.go), [MultiError API](https://github.com/urfave/cli/blob/v3.14.0/errors.go) and [lazy help setup](https://github.com/urfave/cli/blob/v3.14.0/command_setup.go) establish integration obligations; they do not alone verify adapter behavior. Retain these cases in upgrade tests. The simpler alternative is narrower hook support; retaining native hooks with a small traversal bridge preserves the requested cause guarantees. Accepting the generated-help exception avoids a second parser/help subsystem.
|
||||
* Independent ADR review and owner acceptance are complete. The three adapters, focused tests, executable serve/inspect example and public documentation are implemented in [PR 16](https://gitea.auvem.com/corestack/go/pulls/16). Independent standard and fresh high implementation reviews of `e0673630d5876ece6f2419b4b63ce9f643d5a082` found no Blocking, Major or Minor issues; required local build, vet, race, lint, docs, TODO and formatting checks passed on the reviewed code. Local PostgreSQL integrations skipped without DSNs; final-head CI, PostgreSQL integration evidence and merge readiness are tracked in the PR. Publication is complete, and this record was reconciled against latest `origin/main` at `f0439000cbed0ffdccad0521748bcf1738af4a0c` plus the PR; this records delivery evidence, not CI success, merge or release.
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
---
|
||||
title: "Separate migration authoring tools from lifecycle runtime adapters"
|
||||
date: 2026-10-28
|
||||
status: accepted
|
||||
implementation: implemented
|
||||
summary: Keep startup and native migration commands in a thin lifecycle adapter and source creation and reconciliation in a standalone development tool.
|
||||
tags:
|
||||
- migrations
|
||||
- lifecycle
|
||||
- cli
|
||||
supersedes: []
|
||||
superseded_by: []
|
||||
related:
|
||||
- /docs/adr/20261025T214616275Z-version-light-core-packages-in-the-root-module-and-nest-heavy-dependency-modules.md
|
||||
- /docs/adr/20261027T184038082Z-supervise-module-lifecycle-and-managed-workers-within-one-shutdown-budget.md
|
||||
- /docs/adr/20261028T210148136Z-run-native-urfave-commands-inside-selected-lifecycle-modules.md
|
||||
tracking:
|
||||
- https://gitea.auvem.com/corestack/go/issues/9
|
||||
implemented_by:
|
||||
- https://gitea.auvem.com/corestack/go/pulls/18
|
||||
---
|
||||
|
||||
# Separate migration authoring tools from lifecycle runtime adapters
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
[Issue 9](https://gitea.auvem.com/corestack/go/issues/9) needs caller-selected apply/require startup policy, reusable database-only migration commands, and SQL/Go creation and timestamp-to-sequential reconciliation without manually adapting registrations. The owner selected a standalone `cmd/corestack-goose` tool, available through go install/go run without tags, with authoring code absent from production applications. Existing `migrate.New` disables global registration; stock Goose Go output registers through init and cannot satisfy that contract.
|
||||
|
||||
Preserve the accepted lifecycle ordering, reverse cleanup, caller-owned resources, native urfave/cli commands and shutdown budget. Keep migrate usable without lifecycle.
|
||||
|
||||
## Considered Options
|
||||
|
||||
* Application-specific runtime wiring and manually adapted Goose output: least library code, but misses reusable commands and unchanged generated-Go registration.
|
||||
* Application build tags for authoring: complicates consumer builds; declined in favor of a standalone tool.
|
||||
* Thin runtime adapter and standalone authoring tool with a generated registration aggregate: meets the artifact boundary with a bounded source convention.
|
||||
* Rewrite arbitrary Go registrations or infer them through globals/init: broader compatibility and parser/state cost than required; reject unsupported layouts instead.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Choose the third option in the root module, with no new dependencies or lifecycle core changes. The owner accepted this decision, including `up-to` and single-step `down`, after independent ADR review. See [PR 18](https://gitea.auvem.com/corestack/go/pulls/18) for implementation and review evidence.
|
||||
|
||||
Runtime lives in `go.auvem.com/corestack/migrate/lifecycle` (examples alias migratelifecycle), importing migrate's dependencies and `lifecycle/cli`; neither migrate nor lifecycle imports it. Authoring lives only in `cmd/corestack-goose` and its internal implementation, never imported by runtime packages or generated consumer code.
|
||||
|
||||
The public runtime surface is:
|
||||
|
||||
```go
|
||||
type Policy string
|
||||
const (
|
||||
Apply Policy = "apply"
|
||||
Require Policy = "require"
|
||||
)
|
||||
func Module(name string, policy Policy,
|
||||
provider func(context.Context) (*goose.Provider, error)) (lifecycle.Module, error)
|
||||
func Commands(config lifecycle.Config, modules []lifecycle.Module,
|
||||
provider func(context.Context) (*goose.Provider, error)) *urfavecli.Command
|
||||
```
|
||||
|
||||
Module validates name, policy and nonnil resolver without resolving. Setup resolves a nonnil provider after earlier database setup, then calls Up for Apply or HasPending for Require. Pending migrations fail setup before later modules/services; errors retain contextual causes. Zero/unknown policy fails. The module has no Run and no-op Teardown: the database module owns partial acquisition cleanup and closing. Never call Provider.Close, which closes the supplied database in Goose v3.29.0.
|
||||
|
||||
Ordinary closures bind resources: database Setup opens db; the resolver calls migrate.New with db, embedded files, dialect, logger and `migrations.GoMigrations()...`. Register database before migration and dependent services. Declaration starts nothing. Each resolution constructs fresh Go registrations, without a registry, stored context, global mutable state or init. Resolution acquires no independently owned resource; a resolver that does must clean up its failure locally.
|
||||
|
||||
Commands returns a fresh native `migrate` group with these leaves:
|
||||
|
||||
| Leaf | Goose provider method |
|
||||
| --- | --- |
|
||||
| `up` | Up |
|
||||
| `up-to <version>` | UpTo |
|
||||
| `down` | Down (one migration) |
|
||||
| `down-to <version>` | DownTo |
|
||||
| `status` | Status |
|
||||
|
||||
Each uses `lifecycle/cli.Action` over exactly the supplied database modules, suppressing services and excluding the startup migration module and unrelated modules. After setup, resolve the provider and invoke Goose directly. Nil resolver/provider and construction failures are runtime errors. Existing adapter validation, cancellation, reverse cleanup, signals and exit classification apply. Use fresh trees/resource bindings for independent invocations; concurrent mutation/reuse is unsupported.
|
||||
|
||||
Help and invalid arguments must not open the database. Native ArgValidator enforces no positional arguments for up/down/status and exactly one nonnegative decimal int64 for up-to/down-to; reject negative, overflow, missing and extra values with `urfavecli.Exit(err, 2)` before lifecycle entry. Zero behavior follows Goose, including DownTo(0) rolling back all migrations. Leave the group Action unset for native help/not-found behavior; existing generated-help exceptions apply. Status writes a version/state/source table to the selected command Writer and propagates write errors. Other leaves use configured Goose logging; no extra output/config schema is added.
|
||||
|
||||
Require is a point-in-time check, not a startup transaction or deployment lock; HasPending takes no Goose session lock. Require and status can initialize the version table: neither runs migration bodies nor promises read-only database access. Apply and runtime writes retain migrate.New's PostgreSQL session lock and external serialization for other dialects. No new worker, retry or lock is introduced.
|
||||
|
||||
Install the standalone tool with `go install go.auvem.com/corestack/cmd/corestack-goose@<release>` or use `go run go.auvem.com/corestack/cmd/corestack-goose ...` from the consumer module with its selected dependency version:
|
||||
|
||||
```text
|
||||
corestack-goose create [--dir <dir>] <name> sql|go
|
||||
corestack-goose fix [--dir <dir>]
|
||||
```
|
||||
|
||||
Both commands default to the current working directory; `--dir` overrides it. The directory must exist. Generated Go uses package migrations and names restricted to lowercase ASCII words separated by underscores; reject duplicate identifiers before writes. Create stages Goose CreateWithTemplate output using stock SQL/custom Go templates. SQL filenames stay unchanged; Go appends `_migration` to produce `<version>_<name>_migration.go`, preventing allowed names such as test, create_test, linux or windows from triggering build filtering. Factory identifiers derive from the logical name, excluding that suffix; create and fix agree. Authoring is offline with a fixed source format and no Goose global registration.
|
||||
|
||||
Each generated Go file declares an unexported factory taking a version and returning a fresh `*goose.Migration` with editable transactional up/down bodies. Tool-owned `register.go` exports `GoMigrations() []*goose.Migration`, returning fresh Migration/GoFunc values and a new slice of factory calls with literal versions matching filename prefixes. Maintain this sole explicit registration-version surface even for SQL-only sets (empty slice). Consumer output imports only Goose and the standard library; applications pass registrations to migrate.New. The embedded-directory requirement applies to Go-only sets too.
|
||||
|
||||
Fix scans SQL and house Go filenames together, preserves sequential IDs, sorts timestamp IDs numerically, and allocates after the highest sequential ID with at least five-digit padding. Use Goose's timestamp classification (valid UTC timestamp after Unix epoch); reject invalid/duplicate/overflowed IDs and numeric/lexical ordering disagreement. One mapping drives filename changes and aggregate literals; factory names remain version-independent. Preserve migration bytes and bodies. Validate factory identity/signature and package agreement. Replace register.go only with its owned marker and canonical agreement with current filenames/factories. Reject handwritten/drifted layouts with actionable diagnostics.
|
||||
|
||||
Create and fix preflight the complete planned set: regular files/directories rather than symlink paths, names, version mappings, identifiers, aggregate agreement, destination collisions and output syntax. Open new destinations exclusively, including renamed copies. Replace existing register.go only when tool-owned and unchanged since preflight. Require a single authoring process and no concurrent editor mutation; this provides no isolation against racing writers.
|
||||
|
||||
There is no atomic multi-file guarantee. Stage first, fully publish each destination before removing its source, and stop on the first write/remove failure, reporting completed/outstanding paths. Interruption may leave duplicates, aggregate mismatches or temporary files; no automatic retry or rollback. Before fix, commit or separately back up the complete source set, including untracked migrations and tracked edits; ordinary restore cannot recover untracked files or unsaved changes. On failure inspect reported paths and restore from that commit/backup before retrying; preflight rejects inconsistent sets. This manual recovery needs no persistent journal. Fix changes source IDs, never database history: use it before IDs reach a shared database; preserve deployed IDs and add migrations afterward. The tool cannot prove deployment history.
|
||||
|
||||
### Consequences
|
||||
|
||||
* Goose retains runtime schema/version history. Development-only source writers create files, copy/remove reconciled sources and replace the owned aggregate. Imports start nothing; no persistent worker or new auth boundary is introduced.
|
||||
* The fixed source convention avoids arbitrary Go rewriting and automated crash recovery, at the cost of rejecting unsupported layouts and requiring manual source restoration after failures. Consumer guidance lives in [migrate/README.md](/migrate/README.md).
|
||||
* Delivery checks cover Apply/Require startup ordering; all five leaves against SQLite; lifecycle, context, error and Writer failures; help/usage without database setup; version validation and Goose zero-target behavior.
|
||||
* Build and test the standalone tool and runtime-only production fixture separately. Production dependencies and binary symbols must exclude this project's authoring package and file-writing entry points; upstream Goose authoring source may remain in the dependency.
|
||||
* The generated-consumer flow creates SQL/Go, fixes IDs, compiles unedited registrations (including build-sensitive names), applies to SQLite and verifies versions, pending state and provider isolation. Safety tests cover collisions, unsupported/drifted aggregates, duplicate/mixed IDs, repeated fix and injected partial-write failure followed by source restoration.
|
||||
|
||||
## More Information
|
||||
|
||||
Pinned Goose v3.29.0 [CreateWithTemplate](https://github.com/pressly/goose/blob/v3.29.0/create.go) supplies templates and exclusive creation but can leave partial output, motivating staging. Its [Fix](https://github.com/pressly/goose/blob/v3.29.0/fix.go) lacks aggregate maintenance and no-overwrite guarantees, so only creation is reused. [NewGoMigration](https://github.com/pressly/goose/blob/v3.29.0/migration.go) and [provider options](https://github.com/pressly/goose/blob/v3.29.0/provider_options.go) support fresh provider-local registration. [Provider operations](https://github.com/pressly/goose/blob/v3.29.0/provider.go) and [initialization](https://github.com/pressly/goose/blob/v3.29.0/provider_run.go) establish ownership and version-table behavior. Native [argument validation](https://github.com/urfave/cli/blob/v3.14.0/command_run.go) precedes Action.
|
||||
|
||||
Reconciled against `origin/main` at `0034a561ba5eade4ee6fb29436c76e4e34fa3a56` plus [PR 18](https://gitea.auvem.com/corestack/go/pulls/18). The PR records independent reviews, passing local build/vet/race/pinned-lint/docs/TODO/format checks, standalone install/run, generated-consumer SQLite flow and production artifact checks. PostgreSQL integrations were skipped locally without DSNs; CI, merge and release status remain in the PR.
|
||||
@@ -0,0 +1,29 @@
|
||||
Baseline copied from corestack/standards at a36288e7cec933a32f4819a1308201027c9f57ec; this project owns and may adapt it.
|
||||
|
||||
# Documentation policy
|
||||
|
||||
The [corestack/go docs-format specification](https://gitea.auvem.com/corestack/go/src/branch/main/docs/docs-format.md) owns record format, templates, and validation. In scaffolded projects, generate records and run checks with the pinned documentation command in the root AGENTS.md; do not maintain local format templates.
|
||||
|
||||
The copied policy is a project-owned baseline that the project may adapt; scaffold re-runs never refresh it unless the owner explicitly asks. The project owns its locked checker pin and upgrades: update the tag and ensure compliance; scaffold re-runs retain the existing pin unless the owner asks.
|
||||
|
||||
## Choose the smallest useful tier
|
||||
|
||||
| Tier | Use when | Scaffold output |
|
||||
| --- | --- | --- |
|
||||
| Neither | A single-purpose CLI, library, or script has no consequential architectural choice or durable execution context yet. | No record directories and no docs-check CI step; root instructions tell agents to ask the owner to run scaffold-project to enable records when a consequential choice arises. |
|
||||
| ADRs only | Consequential choices need durable rationale, but execution fits a short issue or PR. | Local policy in docs/adr/README.md, ADRs as warranted and docs-check CI. |
|
||||
| ADRs + plans | Work spans areas, phases, or sessions and needs durable execution context. | Local policy in docs/adr/README.md, a docs/plans/README.md pointer, ADRs as warranted, one file per plan, and docs-check CI. |
|
||||
|
||||
Project size is a signal, not an exemption: a small security-sensitive library can warrant an ADR. Add the CI docs-check step exactly when the selected tier enables records. Change tiers when actual work warrants it; do not create empty record directories in anticipation.
|
||||
|
||||
## Decisions and execution
|
||||
|
||||
Write an ADR for a consequential choice among real alternatives: difficult to reverse, crossing areas, changing a boundary, data model or security posture, or establishing a convention. Routine details and choices already governed by an accepted ADR or practice guide do not need another record. Read relevant accepted decisions and verify their claims against code before dependent work. Proposed ADRs are context, not authority; only the owner accepts a decision.
|
||||
|
||||
Write a plan when work spanning areas, phases or sessions needs durable execution context. Otherwise keep planning in a PR, issue or chat. Each plan has its own file; the directory is the index. Never create a central living plan or living PRD checklist that parallel worktrees continually edit. Closed plans are history, never standing instructions.
|
||||
|
||||
Use `write-adr` and `review-adr` when available: they guide reasoning while deferring textual format to repository conventions. Seek independent review and owner acceptance before treating a proposal as binding. Neither a self-check nor an independent recommendation accepts an ADR. ADRs may share a PR with implementation; no separate ADR PR or extra sections are required by this policy.
|
||||
|
||||
## Reconcile before final review and merge
|
||||
|
||||
Compare the latest target branch plus the PR with the affected decisions and plans, not just the working branch's previous metadata. Update ADR implementation state and `implemented_by` with the real PR URL when delivery evidence supports it. Close finished plans with completion evidence and an Outcome; keep work active when scope remains. Never invent PR links, completed checks, owner acceptance, or delivery evidence. Run `corestack-docs check` using the pinned documentation command in the root AGENTS.md after reconciliation.
|
||||
@@ -0,0 +1,245 @@
|
||||
# Docs format
|
||||
|
||||
This is the complete specification for architecture decision records (ADRs) and implementation plans checked by `corestack-docs check`. It is standalone: a file that follows this document is valid without running the tool. The words MUST, MUST NOT and MAY are used in their RFC 2119 sense.
|
||||
|
||||
The ADR body uses the [MADR 4](https://adr.github.io/madr/) layout; `Consequences` is mandatory. The frontmatter, naming and cross-reference rules are specific to this format.
|
||||
|
||||
## 1. Files and directories
|
||||
|
||||
- ADRs live in `docs/adr/`, plans in `docs/plans/`, relative to the repository root. Both are flat: subdirectories are not scanned and are errors.
|
||||
- A missing directory is treated as empty. `docs/adr` and `docs/plans` MUST be real directories: a symlink in their place is an error, and neither may resolve outside the repository through a symlinked parent such as `docs`.
|
||||
- Entries whose name starts with `.` (such as `.DS_Store` or editor swap files) are ignored. `README.md` in either directory is skipped. Every other entry MUST be a regular file whose name matches section 2 and ends in `.md`. Any other file, directory or name is an error. There are no other exceptions: no templates and no companion files; every `.md` file is a record.
|
||||
- Files are UTF-8 without a byte order mark. LF and CRLF line endings are both accepted.
|
||||
|
||||
## 2. Names and IDs
|
||||
|
||||
A record file is named `YYYYMMDDTHHMMSSmmmZ-slug.md`, for example `20261023T204512345Z-use-example-queue.md`.
|
||||
|
||||
- The timestamp is UTC to the millisecond and MUST be a real date and time.
|
||||
- The slug is lowercase `a-z0-9` words joined by single hyphens: no leading, trailing or doubled hyphen, 1 to 80 characters.
|
||||
- The filename without `.md` (the stem) is the record's permanent ID. Never rename a record, even to fix its slug; change the title instead.
|
||||
- IDs MUST be unique across `docs/adr` and `docs/plans` (compared by the timestamp-and-slug stem).
|
||||
- Tools create `docs/<type>/<stem>.md` exclusively (`O_EXCL`) and advance the timestamp by 1 ms on `EEXIST`, or while any record in either directory has the same stem. This is tool behavior and does not affect validity.
|
||||
|
||||
## 3. Frontmatter
|
||||
|
||||
Every record starts with a YAML frontmatter block: the first line is exactly `---`, and the block ends at the next line that is exactly `---`. The block MUST be a mapping containing exactly the keys listed for the record type, no more, no fewer, each once. Key order is free. Empty lists are written `[]`. A key with no value (null) is an error.
|
||||
|
||||
A plain `~`, `null`, `Null` or `NULL` is null, the same as an empty value, and is an error. Every other scalar is a string with no type resolution: its parsed text, whether quoted or not. For example, `date` may be quoted or unquoted, `title: 2026` is the string `2026`, and `title: "null"` is the string `null`. A block scalar (`|`, `>`) is its parsed text. Anchors, aliases, explicit tags and multiple YAML documents are errors. "Single line" means the parsed value contains no line break.
|
||||
|
||||
### 3.1 ADR keys
|
||||
|
||||
| Key | Type | Rule |
|
||||
| --- | --- | --- |
|
||||
| `title` | string | Non-empty, single line. |
|
||||
| `date` | `YYYY-MM-DD` | Valid date. Equals the date in the filename (UTC). |
|
||||
| `status` | enum | `proposed`, `accepted`, `rejected`, `deprecated`, `superseded`. |
|
||||
| `implementation` | enum | `not-started`, `in-progress`, `implemented`, `deferred`, `not-applicable`. |
|
||||
| `summary` | string | Non-empty, single line. |
|
||||
| `tags` | list of strings | Non-empty list. Each tag is lowercase `a-z0-9` words joined by single hyphens; no duplicates. |
|
||||
| `supersedes` | list of references | May be empty. |
|
||||
| `superseded_by` | list of references | May be empty. |
|
||||
| `related` | list of references | May be empty. |
|
||||
| `tracking` | list of references | May be empty. |
|
||||
| `implemented_by` | list of references | May be empty. |
|
||||
|
||||
### 3.2 Plan keys
|
||||
|
||||
| Key | Type | Rule |
|
||||
| --- | --- | --- |
|
||||
| `status` | enum | `draft`, `active`, `completed`, `cancelled`. |
|
||||
| `tracking` | list of references | May be empty unless `active`. |
|
||||
| `adrs` | list of references | May be empty. |
|
||||
| `completion` | list of references | May be empty unless `completed` or `cancelled`. |
|
||||
|
||||
A plan has no `title` key; its title is its H1.
|
||||
|
||||
### 3.3 References in frontmatter
|
||||
|
||||
Every list item is a string. No list may contain duplicates. Which kinds of reference each key accepts:
|
||||
|
||||
| Key | Accepts |
|
||||
| --- | --- |
|
||||
| `supersedes`, `superseded_by`, plan `adrs` | Repo-root paths to ADRs (`/docs/adr/<id>.md`). |
|
||||
| `related` | Repo-root paths to any record (`/docs/adr/...` or `/docs/plans/...`). |
|
||||
| ADR `tracking` | `http` or `https` URLs, or repo-root paths to plans (`/docs/plans/<id>.md`). |
|
||||
| Plan `tracking` | `http` or `https` URLs. |
|
||||
| `implemented_by`, `completion` | `http` or `https` URLs (pull request, commit, release), or repo-root paths to existing files. |
|
||||
|
||||
See section 7 for what a valid path or URL is. A record MUST NOT reference itself. An ADR whose delivery is tracked by a plan lists the plan in `tracking`; other record-to-record links go in `related`.
|
||||
|
||||
Reference lists MUST be durable: an item that is a bare issue number such as `#123` or `owner/repo#123`, or one of the placeholders "this PR", "this pull request" or "this commit" (any case), is rejected. Use a full URL. This applies to whole list items in frontmatter reference lists only; `title`, `summary` and body text are not inspected for it.
|
||||
|
||||
## 4. Status
|
||||
|
||||
`status` is the decision; `implementation` is delivery of that decision. They are independent except where stated.
|
||||
|
||||
ADR `status`:
|
||||
|
||||
- `proposed`: under discussion; not binding.
|
||||
- `accepted`: the decision stands. Accepting is the owner's act.
|
||||
- `rejected`: considered and declined. Requires `implementation: not-applicable`.
|
||||
- `deprecated`: once accepted, no longer recommended, with no replacement.
|
||||
- `superseded`: replaced by another ADR. Requires non-empty `superseded_by`.
|
||||
|
||||
ADR `implementation`:
|
||||
|
||||
- `not-started`, `in-progress`, `deferred`: delivery state.
|
||||
- `implemented`: requires non-empty `implemented_by`.
|
||||
- `not-applicable`: nothing to deliver (required for `rejected`; allowed for any other status).
|
||||
|
||||
Per-status requirements, summarized:
|
||||
|
||||
| Condition | Requirement |
|
||||
| --- | --- |
|
||||
| `status: superseded` | `superseded_by` non-empty. |
|
||||
| `superseded_by` non-empty | `status: superseded`. |
|
||||
| `status: rejected` | `implementation: not-applicable`. |
|
||||
| `implementation: implemented` | `implemented_by` non-empty. |
|
||||
|
||||
Plan `status`:
|
||||
|
||||
| Status | Meaning | `tracking` | `completion` |
|
||||
| --- | --- | --- | --- |
|
||||
| `draft` | Being written. | any | MUST be empty |
|
||||
| `active` | Work under way. | MUST be non-empty | MUST be empty |
|
||||
| `completed` | All steps done. | any | MUST be non-empty |
|
||||
| `cancelled` | Abandoned. | any | MUST be non-empty |
|
||||
|
||||
## 5. Supersession
|
||||
|
||||
- If ADR A lists B in `supersedes`, then B MUST list A in `superseded_by`, and the reverse. Both records exist and are ADRs.
|
||||
- Supersession MUST NOT form a cycle.
|
||||
- The superseding record (A, the one whose `supersedes` lists the other) MUST have status `accepted`, or `superseded` or `deprecated` (it was accepted once and has since moved on). It MUST NOT be `proposed` or `rejected`.
|
||||
- While a replacement is `proposed`, list the ADR it would replace in `related`. On acceptance, in the same change, move it to `supersedes` and set the target's `status: superseded` and `superseded_by`.
|
||||
- A superseded record keeps its original `date` and filename; only its `status` and `superseded_by` change.
|
||||
|
||||
## 6. Body
|
||||
|
||||
The body is Markdown (CommonMark) after the frontmatter. Headings inside fenced code blocks or HTML comments do not count. Only headings at the top level of the document count. Required section headings are matched case-sensitively and exactly, at level 2 (`##`). A section runs until the next heading of level 1 or 2, so its subsections belong to it. A required section MUST appear exactly once and contain non-empty prose: at least one paragraph (including paragraphs inside blockquotes), list item or table cell of text. HTML comments and code (fenced, indented or inline) do not count as prose. Extra sections are allowed anywhere.
|
||||
|
||||
Heading text is the raw source after the `#` markers, trimmed, with any closing `#` run removed; it is compared byte for byte, with no inline-markup rendering. Setext headings are read as the equivalent ATX heading.
|
||||
|
||||
### 6.1 ADR
|
||||
|
||||
- The first heading is an H1 whose heading text equals `title`. There is one H1.
|
||||
- Required H2 sections: `Context and Problem Statement`, `Considered Options`, `Decision Outcome`.
|
||||
- `Decision Outcome` MUST contain exactly one `### Consequences` (H3) subsection with non-empty prose, and at least one prose block before its first H3 subsection. A level-2 `## Consequences` is an error.
|
||||
- Optional MADR sections (for example `Decision Drivers`, `Pros and Cons of the Options`, `More Information`) MAY be added.
|
||||
|
||||
### 6.2 Plan
|
||||
|
||||
- The first heading is a non-empty H1: the plan title. There is one H1.
|
||||
- Required H2 sections: `Scope`, `Steps`, `Validation`.
|
||||
- `## Outcome` is optional for every status. Closed plans still require `completion` (section 4).
|
||||
|
||||
### 6.3 Placeholders
|
||||
|
||||
A record is invalid if it still contains the exact marker `{{docs:todo}}` anywhere outside code, including inside a frontmatter string value or list item (write it quoted there: `summary: "{{docs:todo}}"`). A list item equal to the marker reports only the marker error, not the format error for its key. `corestack-docs new` writes this marker into each field and section it leaves for you to fill; replace it with content. No other text is treated as a placeholder, so words like TODO or TBD are fine. To mention the marker itself, put it in inline or fenced code.
|
||||
|
||||
## 7. References and links
|
||||
|
||||
A reference is either a repo-root path or a URL. This applies to frontmatter reference lists and to every Markdown link and image destination in the body (inline, reference-style and autolinks; bare URLs in text are not checked). In the body, link rules apply only to Markdown link destinations: forge autolink shortcuts in plain text (`#123`, `owner/repo#123`, `@user`, commit SHAs) and raw HTML (`<a href>`, `<img src>`) are not inspected. Every link reference definition is checked, whether or not it is used.
|
||||
|
||||
Repo-root path:
|
||||
|
||||
- Starts with a single `/` and is relative to the repository root, for example `/docs/adr/20261023T204512345Z-use-example-queue.md`. A leading `//` (protocol-relative `//host/...`) is rejected.
|
||||
- Repo-root links render correctly on Git forges (GitHub, Gitea) but not in every Markdown viewer.
|
||||
- The destination is percent-decoded before the existence check (`%20` is a space). A `?query` is an error.
|
||||
- The target MUST exist as a regular file inside the repository, and the path MUST match the target's case exactly, component by component, even on case-insensitive filesystems. Symlinks that resolve outside the repository are rejected.
|
||||
- In body links only, a `#fragment` suffix is allowed; the fragment is not verified. In frontmatter, a fragment is an error.
|
||||
- Rejected: relative paths (`foo.md`, `./foo.md`), any `..` segment and backslashes. Other non-existent targets, including machine paths such as `/Users/...` or `/home/...`, fail the existence rule; `file:` URLs fail the scheme rule.
|
||||
|
||||
URL:
|
||||
|
||||
- Scheme `http` or `https` with a host, for example `https://example.com/org/repo/pull/42`. Credentials in the URL (`user:pass@`) are rejected. URLs are not fetched.
|
||||
|
||||
Always allowed in body prose only: in-page anchors (`#section`) and `mailto:` links. Any other scheme is rejected.
|
||||
|
||||
## 8. What `corestack-docs check` reports
|
||||
|
||||
`corestack-docs check [--root DIR]` checks every record under `DIR` (default: the working directory) and prints one line per problem:
|
||||
|
||||
```
|
||||
path:line: message
|
||||
```
|
||||
|
||||
`path` is relative to the root using `/` separators. `line` is the best location of the problem (the key, heading or link), or `1` for file-level problems. Output is sorted by path, then line, then message. The exit status is `1` if any problem is reported, `2` for usage errors, and `0` otherwise, in which case a one-line summary of the records checked is printed.
|
||||
|
||||
It reports violations of everything above:
|
||||
|
||||
- unexpected files, directories or names; bad timestamps or slugs; duplicate IDs;
|
||||
- missing, malformed or unterminated frontmatter; unknown, missing, duplicate or null keys; wrong types or values; `date` not matching the filename;
|
||||
- status/implementation/field combinations from section 4;
|
||||
- supersession problems from section 5, and self-references;
|
||||
- missing, repeated or empty required sections; H1 problems; `{{docs:todo}}` markers;
|
||||
- invalid, unresolvable or non-durable references and links.
|
||||
|
||||
It does not judge whether a decision is good, whether a record should have been written, or whether a link's fragment exists.
|
||||
|
||||
## 9. Minimal valid examples
|
||||
|
||||
`docs/adr/20261023T204512345Z-use-example-queue.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Use Example Queue for background jobs
|
||||
date: 2026-10-23
|
||||
status: proposed
|
||||
implementation: not-started
|
||||
summary: Run background jobs on Example Queue.
|
||||
tags:
|
||||
- jobs
|
||||
supersedes: []
|
||||
superseded_by: []
|
||||
related: []
|
||||
tracking: []
|
||||
implemented_by: []
|
||||
---
|
||||
|
||||
# Use Example Queue for background jobs
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Jobs currently run inline and block requests.
|
||||
|
||||
## Considered Options
|
||||
|
||||
* Example Queue
|
||||
* Keep running jobs inline
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: Example Queue, because it decouples jobs from requests.
|
||||
|
||||
### Consequences
|
||||
|
||||
* We operate one more service.
|
||||
```
|
||||
|
||||
`docs/plans/20261023T210000000Z-adopt-example-queue.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
status: draft
|
||||
tracking: []
|
||||
adrs:
|
||||
- /docs/adr/20261023T204512345Z-use-example-queue.md
|
||||
completion: []
|
||||
---
|
||||
|
||||
# Adopt Example Queue
|
||||
|
||||
## Scope
|
||||
|
||||
Move the email job to Example Queue.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add the queue client.
|
||||
2. Move the email job.
|
||||
|
||||
## Validation
|
||||
|
||||
The email job runs from the queue in staging.
|
||||
```
|
||||
@@ -0,0 +1,38 @@
|
||||
module go.auvem.com/corestack
|
||||
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/pressly/goose/v3 v3.29.0
|
||||
github.com/stephenafamo/bob v0.50.0
|
||||
github.com/stephenafamo/scan v0.9.0
|
||||
github.com/urfave/cli/v3 v3.14.0
|
||||
github.com/yuin/goldmark v1.8.6
|
||||
go.yaml.in/yaml/v3 v3.0.5
|
||||
modernc.org/sqlite v1.60.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aarondl/opt v0.0.0-20250607033636-982744e1bd65 // indirect
|
||||
github.com/dustin/go-humanize v1.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.3 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.1.0 // indirect
|
||||
github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sethvargo/go-retry v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/sync v0.24.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
golang.org/x/text v0.43.0 // indirect
|
||||
modernc.org/libc v1.78.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,216 @@
|
||||
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.3 h1:M/52tsoyTfwaxajLnXZm7PqkbMGRH8iSWP+8fcg9MQ4=
|
||||
github.com/Microsoft/go-winio v0.6.3/go.mod h1:ma0QpxizD4fyyzJ1C7MIblymTD2Nxz5Mho/3CepwIZg=
|
||||
github.com/aarondl/opt v0.0.0-20250607033636-982744e1bd65 h1:lbdPe4LBNmNDzeQFwNhEc88w90841qv737MI4+aXSYU=
|
||||
github.com/aarondl/opt v0.0.0-20250607033636-982744e1bd65/go.mod h1:+xKBXrTAUOvrDXO5PRwIr4E1wciHY3Glgl+6OkCXknU=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
||||
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
|
||||
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw=
|
||||
github.com/docker/docker v28.2.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc=
|
||||
github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
||||
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
|
||||
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
||||
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.3 h1:f8HoREISb3l5eO9coPgK7U3hKLhWGokOFVcThl5jrao=
|
||||
github.com/jackc/puddle/v2 v2.2.3/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
|
||||
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
|
||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
|
||||
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pganalyze/pg_query_go/v6 v6.1.0 h1:jG5ZLhcVgL1FAw4C/0VNQaVmX1SUJx71wBGdtTtBvls=
|
||||
github.com/pganalyze/pg_query_go/v6 v6.1.0/go.mod h1:nvTHIuoud6e1SfrUaFwHqT0i4b5Nr+1rPWVds3B5+50=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/pressly/goose/v3 v3.29.0 h1:dtEnBCT7CqkHIUCdgrXsrmeisaN8gFkRU4Y5OEwif4o=
|
||||
github.com/pressly/goose/v3 v3.29.0/go.mod h1:plA4aP4+u+Q3FMTgbFbhF8TuAWpAdfWpoFGkJsIFWh0=
|
||||
github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494 h1:wSmWgpuccqS2IOfmYrbRiUgv+g37W5suLLLxwwniTSc=
|
||||
github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494/go.mod h1:yipyliwI08eQ6XwDm1fEwKPdF/xdbkiHtrU+1Hg+vc4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/sethvargo/go-retry v0.5.0 h1:JYfFKmVJ8ER06GReN1efzc5cm52BrVsFVzmcHiKJwfY=
|
||||
github.com/sethvargo/go-retry v0.5.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg=
|
||||
github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQUJAsc=
|
||||
github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stephenafamo/bob v0.50.0 h1:CsG9JSfJt4RLZiuHdf39LK98Jx5QBkNAIymqtVwnCqY=
|
||||
github.com/stephenafamo/bob v0.50.0/go.mod h1:pz+z6oJa2PU6jGo+uWvaWcbNlXPVwkt5d9nW8kHWfJ0=
|
||||
github.com/stephenafamo/fakedb v0.0.0-20221230081958-0b86f816ed97 h1:XItoZNmhOih06TC02jK7l3wlpZ0XT/sPQYutDcGOQjg=
|
||||
github.com/stephenafamo/fakedb v0.0.0-20221230081958-0b86f816ed97/go.mod h1:bM3Vmw1IakoaXocHmMIGgJFYob0vuK+CFWiJHQvz0jQ=
|
||||
github.com/stephenafamo/scan v0.9.0 h1:11Rf0cW1KjMLoTEa5uNO9FTRFDFc4BgJJi9LIQz1cmQ=
|
||||
github.com/stephenafamo/scan v0.9.0/go.mod h1:FhIUJ8pLNyex36xGFiazDJJ5Xry0UkAi+RkWRrEcRMg=
|
||||
github.com/stephenafamo/sqlparser v0.0.0-20260122161205-e324475bd1fc h1:D/IjFvgl7MXcuKnXzGCOAMKTfzopgxdLYIy0HZx+CI4=
|
||||
github.com/stephenafamo/sqlparser v0.0.0-20260122161205-e324475bd1fc/go.mod h1:2ATW++wFz7Mvc/N+nUtQnU+9VIGAxrn8m9JCLDSWMsQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/testcontainers/testcontainers-go v0.38.0 h1:d7uEapLcv2P8AvH8ahLqDMMxda2W9gQN1nRbHS28HBw=
|
||||
github.com/testcontainers/testcontainers-go v0.38.0/go.mod h1:C52c9MoHpWO+C4aqmgSU+hxlR5jlEayWtgYrb8Pzz1w=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.38.0 h1:KFdx9A0yF94K70T6ibSuvgkQQeX1xKlZVF3hEagXEtY=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.38.0/go.mod h1:T/QRECND6N6tAKMxF1Za+G2tpwnGEHcODzHRsgIpw9M=
|
||||
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
|
||||
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
github.com/urfave/cli/v3 v3.14.0 h1:a8414NQlHJs0c/iBsulKLzlES0n/lEAskbL2LKpU4/s=
|
||||
github.com/urfave/cli/v3 v3.14.0/go.mod h1:vXn6HxPNccJSzQr2QvwVncOKrgYGIHU0HY5h8B2nQj4=
|
||||
github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfPe7z7go8Dvv1AJQDI3eQ/5xith3q2mFlo=
|
||||
github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07/go.mod h1:Ak17IJ037caFp4jpCw/iQQ7/W74Sqpb1YuKJU6HTKfM=
|
||||
github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4=
|
||||
github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY=
|
||||
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 h1:LxwW/9ctSCv+QkE/cLR7M91ZIkXNMqJtEMi1vCw9U8s=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0/go.mod h1:tOsftB4SslBwwErVEPaenU2RpThXWPIU8DoJHEC4dyw=
|
||||
go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM=
|
||||
go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU=
|
||||
go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI=
|
||||
go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw=
|
||||
go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY=
|
||||
go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU=
|
||||
go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM=
|
||||
go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
golang.org/x/exp v0.0.0-20261011100332-faecfb24eb49 h1:pQo5TwmxKJvI8k39uYS2Gqkk8OXWxX/TMSis2Twp1AQ=
|
||||
golang.org/x/exp v0.0.0-20261011100332-faecfb24eb49/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
|
||||
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.30.1 h1:ZXInYH2CD24GPWSwOFPwB6JrnFt4/6d7ceW1Ukd30Bk=
|
||||
modernc.org/cc/v4 v4.30.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.36.2 h1:j6d5HuXtBxF00vEcI5RE0O6EIrG6RyRpgMiGV2IK9ys=
|
||||
modernc.org/ccgo/v4 v4.36.2/go.mod h1:Wh8QlPZq/US2DrsCc31FxJVBOEEcN2BBfla6OQDHydA=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
|
||||
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.78.0 h1:Lpm+e9K6ruVYEGwWfuBO1xXtYEaXDouFOG1SuWjFDP4=
|
||||
modernc.org/libc v1.78.0/go.mod h1:wvxR55nOF2/66NkJoNV7/coXBTCvT3HR22rcqW6lOuA=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
|
||||
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,45 @@
|
||||
package docs
|
||||
|
||||
import "time"
|
||||
|
||||
var (
|
||||
adrStatuses = []string{"proposed", "accepted", "rejected", "deprecated", "superseded"}
|
||||
adrImpls = []string{"not-started", "in-progress", "implemented", "deferred", "not-applicable"}
|
||||
adrRefs = []refSpec{
|
||||
{"supersedes", kAdr}, {"superseded_by", kAdr}, {"related", kAdr | kPlan},
|
||||
{"tracking", kPlan | kURL}, {"implemented_by", kURL | kFile},
|
||||
}
|
||||
)
|
||||
|
||||
// adr checks the ADR frontmatter and returns its title for the body check.
|
||||
func (c *checker) adr(r *record, m *fm) (string, bool) {
|
||||
m.keys("title", "date", "status", "implementation", "summary", "tags", "supersedes", "superseded_by", "related", "tracking", "implemented_by")
|
||||
title, titleOK := m.text("title")
|
||||
if d, line, ok := m.scalar("date"); ok {
|
||||
if _, err := time.Parse(time.DateOnly, d); err != nil || len(d) != len(time.DateOnly) {
|
||||
c.add(r.path, line, "date must be a valid YYYY-MM-DD date")
|
||||
} else if d != r.date {
|
||||
c.add(r.path, line, "date %s does not match the filename date %s", d, r.date)
|
||||
}
|
||||
}
|
||||
status, statusOK := m.enum("status", adrStatuses...)
|
||||
impl, implOK := m.enum("implementation", adrImpls...)
|
||||
m.text("summary")
|
||||
m.tags()
|
||||
m.refs(adrRefs...)
|
||||
r.status = status
|
||||
if statusOK {
|
||||
if n := len(r.lists["superseded_by"]); status == "superseded" && n == 0 {
|
||||
m.problemf("status", "status superseded requires superseded_by")
|
||||
} else if status != "superseded" && n > 0 {
|
||||
m.problemf("superseded_by", "superseded_by requires status superseded")
|
||||
}
|
||||
if status == "rejected" && implOK && impl != "not-applicable" {
|
||||
m.problemf("implementation", "status rejected requires implementation not-applicable")
|
||||
}
|
||||
}
|
||||
if implOK && impl == "implemented" && len(r.lists["implemented_by"]) == 0 {
|
||||
m.problemf("implementation", "implementation implemented requires implemented_by")
|
||||
}
|
||||
return title, titleOK
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/text"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
var (
|
||||
adrSections = []string{"Context and Problem Statement", "Considered Options", "Decision Outcome"}
|
||||
planSections = []string{"Scope", "Steps", "Validation"}
|
||||
)
|
||||
|
||||
var atxRe = regexp.MustCompile(`(?m)^ {0,3}#{1,6}(?:[ \t]|$)`)
|
||||
|
||||
type heading struct {
|
||||
level int
|
||||
text string
|
||||
line int
|
||||
at int // index among the document's top-level nodes
|
||||
}
|
||||
|
||||
type doc struct {
|
||||
c *checker
|
||||
r *record
|
||||
src []byte
|
||||
startLine int
|
||||
kids []ast.Node
|
||||
heads []heading
|
||||
}
|
||||
|
||||
func (d *doc) lineAt(off int) int {
|
||||
return d.startLine + bytes.Count(d.src[:min(off, len(d.src))], []byte("\n"))
|
||||
}
|
||||
|
||||
// blockEnd is the end offset of the last line under n. Only block nodes have lines.
|
||||
func blockEnd(n ast.Node) (end int) {
|
||||
_ = ast.Walk(n, func(c ast.Node, enter bool) (ast.WalkStatus, error) {
|
||||
if enter && c.Type() == ast.TypeBlock {
|
||||
for i := range c.Lines().Len() {
|
||||
end = max(end, c.Lines().At(i).Stop)
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
return end
|
||||
}
|
||||
|
||||
// emptyHeadingAt is the offset of an empty ATX heading, which has no lines: the first `#`
|
||||
// line at or after from, the end of everything before it.
|
||||
func (d *doc) emptyHeadingAt(from int) int {
|
||||
if m := atxRe.FindIndex(d.src[from:]); m != nil {
|
||||
return from + m[0]
|
||||
}
|
||||
return from
|
||||
}
|
||||
|
||||
// lineOf is the line of the first text under n. Without text (an autolink, an image with
|
||||
// no alt text) it is the line where needle appears in the enclosing block, else the block's first line.
|
||||
func (d *doc) lineOf(n ast.Node, needle string) int {
|
||||
var off = -1
|
||||
_ = ast.Walk(n, func(c ast.Node, enter bool) (ast.WalkStatus, error) {
|
||||
if t, ok := c.(*ast.Text); ok && enter {
|
||||
off = t.Segment.Start
|
||||
return ast.WalkStop, nil
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
for p := n; off < 0 && p != nil; p = p.Parent() {
|
||||
if p.Type() == ast.TypeBlock && p.Lines().Len() > 0 {
|
||||
off = p.Lines().At(0).Start
|
||||
if i := bytes.Index(d.src[off:p.Lines().At(p.Lines().Len()-1).Stop], []byte(needle)); needle != "" && i >= 0 {
|
||||
off += i
|
||||
}
|
||||
}
|
||||
}
|
||||
if off < 0 {
|
||||
return d.startLine
|
||||
}
|
||||
return d.lineAt(off)
|
||||
}
|
||||
|
||||
func (c *checker) body(r *record, src string, startLine int, title string, titleOK bool) {
|
||||
b := []byte(src)
|
||||
root := goldmark.DefaultParser().Parse(text.NewReader(b))
|
||||
d := &doc{c: c, r: r, src: b, startLine: startLine}
|
||||
from := 0 // end of the blocks seen so far
|
||||
for n := root.FirstChild(); n != nil; n = n.NextSibling() {
|
||||
if h, ok := n.(*ast.Heading); ok {
|
||||
at := from
|
||||
if h.Lines().Len() > 0 {
|
||||
at = h.Lines().At(0).Start
|
||||
} else {
|
||||
at = d.emptyHeadingAt(from)
|
||||
from = at + bytes.IndexByte(d.src[at:], '\n') + 1 // the next line; ^ would match mid-line
|
||||
}
|
||||
d.heads = append(d.heads, heading{h.Level, strings.TrimSpace(string(h.Lines().Value(b))), d.lineAt(at), len(d.kids)})
|
||||
}
|
||||
from = max(from, blockEnd(n))
|
||||
d.kids = append(d.kids, n)
|
||||
}
|
||||
d.placeholders(root)
|
||||
d.links(root)
|
||||
d.title(title, titleOK)
|
||||
if r.kind == "adr" {
|
||||
for _, name := range adrSections {
|
||||
d.section(name)
|
||||
}
|
||||
d.decisionOutcome()
|
||||
} else {
|
||||
for _, name := range planSections {
|
||||
d.section(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// title checks the single leading H1.
|
||||
func (d *doc) title(title string, titleOK bool) {
|
||||
h1s := 0
|
||||
for _, h := range d.heads {
|
||||
if h.level == 1 {
|
||||
if h1s++; h1s > 1 {
|
||||
d.c.add(d.r.path, h.line, "more than one H1")
|
||||
}
|
||||
}
|
||||
}
|
||||
switch first := d.heads; {
|
||||
case len(first) == 0 || first[0].level != 1:
|
||||
d.c.add(d.r.path, d.startLine, "the first heading must be an H1")
|
||||
case d.r.kind == "plan" && first[0].text == "":
|
||||
d.c.add(d.r.path, first[0].line, "the H1 (plan title) must not be empty")
|
||||
case d.r.kind == "adr" && titleOK && first[0].text != title:
|
||||
d.c.add(d.r.path, first[0].line, "H1 %q must equal title %q", first[0].text, title)
|
||||
}
|
||||
}
|
||||
|
||||
// extent returns the top-level nodes after heading h up to the next heading of level <= upTo.
|
||||
func (d *doc) extent(h heading, upTo int) []ast.Node {
|
||||
end := len(d.kids)
|
||||
for _, o := range d.heads {
|
||||
if o.at > h.at && o.level <= upTo {
|
||||
end = o.at
|
||||
break
|
||||
}
|
||||
}
|
||||
return d.kids[h.at+1 : end]
|
||||
}
|
||||
|
||||
// section finds the one H2 called name and checks that it has prose.
|
||||
func (d *doc) section(name string) (heading, bool) {
|
||||
var found []heading
|
||||
for _, h := range d.heads {
|
||||
if h.level == 2 && h.text == name {
|
||||
found = append(found, h)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(found) == 0:
|
||||
d.c.add(d.r.path, 1, "missing required section %q", name)
|
||||
case len(found) > 1:
|
||||
d.c.add(d.r.path, found[1].line, "section %q appears more than once", name)
|
||||
case !d.hasProse(d.extent(found[0], 2)):
|
||||
d.c.add(d.r.path, found[0].line, "section %q has no prose", name)
|
||||
return found[0], true
|
||||
default:
|
||||
return found[0], true
|
||||
}
|
||||
return heading{}, false
|
||||
}
|
||||
|
||||
func (d *doc) decisionOutcome() {
|
||||
for _, h := range d.heads {
|
||||
if h.level == 2 && h.text == "Consequences" {
|
||||
d.c.add(d.r.path, h.line, "Consequences must be a ### subsection of Decision Outcome, not a ## section")
|
||||
}
|
||||
}
|
||||
var outcome []heading
|
||||
for _, h := range d.heads {
|
||||
if h.level == 2 && h.text == "Decision Outcome" {
|
||||
outcome = append(outcome, h)
|
||||
}
|
||||
}
|
||||
if len(outcome) != 1 {
|
||||
return
|
||||
}
|
||||
h := outcome[0]
|
||||
nodes := d.extent(h, 2)
|
||||
// Prose before the first subsection is the decision itself.
|
||||
pre := nodes
|
||||
for i, n := range nodes {
|
||||
if h, ok := n.(*ast.Heading); ok && h.Level == 3 {
|
||||
pre = nodes[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if !d.hasProse(pre) {
|
||||
d.c.add(d.r.path, h.line, "Decision Outcome needs prose before its first subsection")
|
||||
}
|
||||
var cons []heading
|
||||
for _, s := range d.heads {
|
||||
if s.level == 3 && s.text == "Consequences" && s.at > h.at && s.at < h.at+len(nodes)+1 {
|
||||
cons = append(cons, s)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(cons) == 0:
|
||||
d.c.add(d.r.path, h.line, "Decision Outcome must contain a ### Consequences subsection")
|
||||
case len(cons) > 1:
|
||||
d.c.add(d.r.path, cons[1].line, "### Consequences appears more than once")
|
||||
case !d.hasProse(d.extent(cons[0], 3)):
|
||||
d.c.add(d.r.path, cons[0].line, "### Consequences has no prose")
|
||||
}
|
||||
}
|
||||
|
||||
// hasProse reports whether nodes hold a paragraph with text outside code, images and raw HTML.
|
||||
func (d *doc) hasProse(nodes []ast.Node) bool {
|
||||
for _, n := range nodes {
|
||||
found := false
|
||||
_ = ast.Walk(n, func(c ast.Node, enter bool) (ast.WalkStatus, error) {
|
||||
switch c.(type) {
|
||||
case *ast.Paragraph, *ast.TextBlock:
|
||||
if enter && strings.TrimSpace(d.inlineText(c)) != "" {
|
||||
found = true
|
||||
return ast.WalkStop, nil
|
||||
}
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
if found {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (d *doc) inlineText(n ast.Node) string {
|
||||
var s strings.Builder
|
||||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
switch t := c.(type) {
|
||||
case *ast.Text:
|
||||
s.Write(t.Segment.Value(d.src))
|
||||
case *ast.String:
|
||||
s.Write(t.Value)
|
||||
case *ast.CodeSpan, *ast.RawHTML, *ast.Image:
|
||||
default:
|
||||
s.WriteString(d.inlineText(c))
|
||||
}
|
||||
}
|
||||
return s.String()
|
||||
}
|
||||
|
||||
// codeRanges returns the source ranges of code blocks and code spans.
|
||||
func codeRanges(root ast.Node) (code [][2]int) {
|
||||
_ = ast.Walk(root, func(n ast.Node, enter bool) (ast.WalkStatus, error) {
|
||||
switch n.(type) {
|
||||
case *ast.FencedCodeBlock, *ast.CodeBlock:
|
||||
for i := range n.Lines().Len() {
|
||||
seg := n.Lines().At(i)
|
||||
code = append(code, [2]int{seg.Start, seg.Stop})
|
||||
}
|
||||
case *ast.CodeSpan:
|
||||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
if t, ok := c.(*ast.Text); ok {
|
||||
code = append(code, [2]int{t.Segment.Start, t.Segment.Stop})
|
||||
}
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
return code
|
||||
}
|
||||
|
||||
// placeholders reports every marker outside code.
|
||||
func (d *doc) placeholders(root ast.Node) {
|
||||
code := codeRanges(root)
|
||||
for off := 0; ; {
|
||||
i := strings.Index(string(d.src[off:]), marker)
|
||||
if i < 0 {
|
||||
return
|
||||
}
|
||||
at := off + i
|
||||
off = at + len(marker)
|
||||
inCode := false
|
||||
for _, r := range code {
|
||||
inCode = inCode || at >= r[0] && at < r[1]
|
||||
}
|
||||
if !inCode {
|
||||
d.c.add(d.r.path, d.lineAt(at), "unfinished placeholder %s", marker)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dest decodes a link destination as CommonMark does before it is used.
|
||||
func dest(b []byte) string {
|
||||
return string(util.ResolveEntityNames(util.ResolveNumericReferences(util.UnescapePunctuations(b))))
|
||||
}
|
||||
|
||||
// links checks every link, image and autolink destination and every reference definition.
|
||||
func (d *doc) links(root ast.Node) {
|
||||
_ = ast.Walk(root, func(n ast.Node, enter bool) (ast.WalkStatus, error) {
|
||||
if !enter {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
switch t := n.(type) {
|
||||
case *ast.Link:
|
||||
d.c.checkLink(d.r, d.lineOf(n, "]("+string(t.Destination)), dest(t.Destination))
|
||||
case *ast.Image:
|
||||
d.c.checkLink(d.r, d.lineOf(n, "]("+string(t.Destination)), dest(t.Destination))
|
||||
case *ast.AutoLink:
|
||||
d.c.checkLink(d.r, d.lineOf(n, "<"+string(t.Label(d.src))), string(t.URL(d.src)))
|
||||
case *ast.LinkReferenceDefinition:
|
||||
d.c.checkLink(d.r, d.lineAt(t.Lines().At(0).Start), dest(t.Destination))
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Package docs creates and checks the ADR and plan records described in docs/docs-format.md.
|
||||
package docs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
//go:embed templates/*.md
|
||||
var templates embed.FS
|
||||
|
||||
var dirs = []struct{ kind, dir string }{{"adr", "docs/adr"}, {"plan", "docs/plans"}}
|
||||
|
||||
// Problem is one violation of the format. Line is 1 for file-level problems.
|
||||
type Problem struct {
|
||||
Path string
|
||||
Line int
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (p Problem) String() string { return fmt.Sprintf("%s:%d: %s", p.Path, p.Line, p.Msg) }
|
||||
|
||||
// Result is the outcome of Check: the number of records read and the sorted problems.
|
||||
type Result struct {
|
||||
Records int
|
||||
Problems []Problem
|
||||
}
|
||||
|
||||
type record struct {
|
||||
path, kind, stem, date string
|
||||
status string
|
||||
lists map[string][]item
|
||||
fm *fm
|
||||
}
|
||||
|
||||
type checker struct {
|
||||
root, realRoot string
|
||||
problems []Problem
|
||||
}
|
||||
|
||||
func (c *checker) add(path string, line int, format string, args ...any) {
|
||||
c.problems = append(c.problems, Problem{path, line, fmt.Sprintf(format, args...)})
|
||||
}
|
||||
|
||||
// Check validates every record under root. The error is for I/O failures only.
|
||||
func Check(root string) (Result, error) {
|
||||
real, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
c := &checker{root: root, realRoot: real}
|
||||
var recs []*record
|
||||
for _, d := range dirs {
|
||||
if st, err := os.Lstat(filepath.Join(root, d.dir)); os.IsNotExist(err) {
|
||||
continue
|
||||
} else if err == nil && !st.IsDir() {
|
||||
c.add(d.dir, 1, "must be a real directory (symlinks are not allowed)")
|
||||
continue
|
||||
} else if r, err := filepath.EvalSymlinks(filepath.Join(root, d.dir)); err == nil && !within(real, r) {
|
||||
c.add(d.dir, 1, "must be inside the repository (a symlinked parent directory is not allowed)")
|
||||
continue
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Join(root, d.dir))
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, ".") || name == "README.md" {
|
||||
continue
|
||||
}
|
||||
rel := d.dir + "/" + name
|
||||
if !e.Type().IsRegular() {
|
||||
c.add(rel, 1, "must be a regular file (subdirectories, symlinks and other entries are not allowed)")
|
||||
continue
|
||||
}
|
||||
stem, at, msg := parseName(name)
|
||||
if msg != "" {
|
||||
c.add(rel, 1, "%s", msg)
|
||||
continue
|
||||
}
|
||||
r := &record{path: rel, kind: d.kind, stem: stem, date: at.Format("2006-01-02"), lists: map[string][]item{}}
|
||||
if err := c.record(r); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
recs = append(recs, r)
|
||||
}
|
||||
}
|
||||
c.graph(recs)
|
||||
slices.SortFunc(c.problems, func(a, b Problem) int {
|
||||
return strings.Compare(fmt.Sprintf("%s\x00%09d\x00%s", a.Path, a.Line, a.Msg), fmt.Sprintf("%s\x00%09d\x00%s", b.Path, b.Line, b.Msg))
|
||||
})
|
||||
return Result{len(recs), c.problems}, nil
|
||||
}
|
||||
|
||||
func (c *checker) record(r *record) error {
|
||||
data, err := os.ReadFile(filepath.Join(c.root, filepath.FromSlash(r.path)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if bytes.HasPrefix(data, []byte("\xef\xbb\xbf")) {
|
||||
c.add(r.path, 1, "file must not start with a byte order mark")
|
||||
return nil
|
||||
}
|
||||
if !utf8.Valid(data) {
|
||||
c.add(r.path, 1, "file must be valid UTF-8")
|
||||
return nil
|
||||
}
|
||||
src := strings.ReplaceAll(string(data), "\r\n", "\n")
|
||||
front, body, bodyLine, msg := splitFrontmatter(src)
|
||||
if msg != "" {
|
||||
c.add(r.path, 1, "%s", msg)
|
||||
return nil
|
||||
}
|
||||
m, ok := c.parseFrontmatter(r, front)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
r.fm = m
|
||||
var title string
|
||||
var titleOK bool
|
||||
if r.kind == "adr" {
|
||||
title, titleOK = c.adr(r, m)
|
||||
} else {
|
||||
c.plan(r, m)
|
||||
}
|
||||
c.body(r, body, bodyLine, title, titleOK)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
aID = "20261023T204512345Z-use-queue"
|
||||
aPath = "docs/adr/" + aID + ".md"
|
||||
bPath = "docs/adr/20261024T000000000Z-second.md"
|
||||
pPath = "docs/plans/20261023T210000000Z-adopt-queue.md"
|
||||
|
||||
// Line numbers in the expectations below refer to these documents.
|
||||
adrBase = `---
|
||||
title: Use Queue
|
||||
date: 2026-10-23
|
||||
status: proposed
|
||||
implementation: not-started
|
||||
summary: Run jobs on a queue.
|
||||
tags:
|
||||
- jobs
|
||||
supersedes: []
|
||||
superseded_by: []
|
||||
related: []
|
||||
tracking: []
|
||||
implemented_by: []
|
||||
---
|
||||
|
||||
# Use Queue
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Jobs block requests.
|
||||
|
||||
## Considered Options
|
||||
|
||||
* Queue
|
||||
* Inline
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: Queue.
|
||||
|
||||
### Consequences
|
||||
|
||||
* One more service.
|
||||
`
|
||||
// bBase is a second ADR (dated 2026-10-24) that may supersede or be superseded.
|
||||
bBase = `---
|
||||
title: Second
|
||||
date: 2026-10-24
|
||||
status: accepted
|
||||
implementation: not-started
|
||||
summary: Second.
|
||||
tags:
|
||||
- jobs
|
||||
supersedes: []
|
||||
superseded_by: []
|
||||
related: []
|
||||
tracking: []
|
||||
implemented_by: []
|
||||
---
|
||||
|
||||
# Second
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Why.
|
||||
|
||||
## Considered Options
|
||||
|
||||
* A
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: A.
|
||||
|
||||
### Consequences
|
||||
|
||||
* Some.
|
||||
`
|
||||
planBase = `---
|
||||
status: draft
|
||||
tracking: []
|
||||
adrs: []
|
||||
completion: []
|
||||
---
|
||||
|
||||
# Adopt Queue
|
||||
|
||||
## Scope
|
||||
|
||||
Move the job.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Do it.
|
||||
|
||||
## Validation
|
||||
|
||||
It runs.
|
||||
`
|
||||
)
|
||||
|
||||
// sub replaces old with new in s once; a stale expectation fails loudly.
|
||||
func sub(s string, pairs ...string) string {
|
||||
for i := 0; i < len(pairs); i += 2 {
|
||||
if !strings.Contains(s, pairs[i]) {
|
||||
panic("fixture does not contain " + pairs[i])
|
||||
}
|
||||
s = strings.Replace(s, pairs[i], pairs[i+1], 1)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func tree(t *testing.T, files, links map[string]string) string {
|
||||
t.Helper()
|
||||
root, outside := t.TempDir(), t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(outside, "f.md"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for p, content := range files {
|
||||
full := filepath.Join(root, p)
|
||||
if strings.HasSuffix(p, "/") {
|
||||
must(t, os.MkdirAll(full, 0o755))
|
||||
continue
|
||||
}
|
||||
must(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
||||
must(t, os.WriteFile(full, []byte(content), 0o644))
|
||||
}
|
||||
for p, target := range links {
|
||||
must(t, os.MkdirAll(filepath.Dir(filepath.Join(root, p)), 0o755))
|
||||
must(t, os.Symlink(strings.Replace(target, "@outside", outside, 1), filepath.Join(root, p)))
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func must(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var wantRe = regexp.MustCompile(`^(.*?:\d+): (.*)$`)
|
||||
|
||||
// expect requires got and want to pair up one to one. A want is "path:line: message substring"; the path may be a suffix.
|
||||
func expect(t *testing.T, got []Problem, want []string) {
|
||||
t.Helper()
|
||||
left := append([]Problem(nil), got...)
|
||||
for _, w := range want {
|
||||
m := wantRe.FindStringSubmatch(w)
|
||||
found := false
|
||||
for i, p := range left {
|
||||
if strings.HasSuffix(fmt.Sprintf("%s:%d", p.Path, p.Line), m[1]) && strings.Contains(p.Msg, m[2]) {
|
||||
left = append(left[:i], left[i+1:]...)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("missing problem %q in %v", w, got)
|
||||
}
|
||||
}
|
||||
for _, p := range left {
|
||||
t.Errorf("unexpected problem %s", p)
|
||||
}
|
||||
}
|
||||
|
||||
var support = map[string]string{
|
||||
bPath: bBase,
|
||||
pPath: planBase,
|
||||
"docs/spec.txt": "spec",
|
||||
"docs/sp ace.md": "space",
|
||||
}
|
||||
|
||||
// withSupport adds the shared targets to a case's files.
|
||||
func withSupport(files map[string]string) map[string]string {
|
||||
all := map[string]string{}
|
||||
for k, v := range support {
|
||||
all[k] = v
|
||||
}
|
||||
for k, v := range files {
|
||||
all[k] = v
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func TestCheck(t *testing.T) {
|
||||
long := strings.Repeat("a", 81)
|
||||
tests := []struct {
|
||||
name string
|
||||
files map[string]string
|
||||
links map[string]string
|
||||
want []string
|
||||
}{
|
||||
// 1. Files and directories
|
||||
{name: "dot entries and README are skipped", files: map[string]string{"docs/adr/.DS_Store": "x", "docs/adr/README.md": "x", "docs/plans/README.md": "x"}},
|
||||
{name: "subdirectory", files: map[string]string{"docs/adr/sub/": ""}, want: []string{"docs/adr/sub:1: regular file"}},
|
||||
{name: "non-md file", files: map[string]string{"docs/plans/notes.txt": "x"}, want: []string{"docs/plans/notes.txt:1: name must be"}},
|
||||
{name: "template-like name", files: map[string]string{"docs/adr/template.md": "x"}, want: []string{"docs/adr/template.md:1: name must be"}},
|
||||
{name: "symlinked record", files: map[string]string{"docs/spec.txt": "x"}, links: map[string]string{"docs/adr/20261023T204512345Z-s.md": "@outside/f.md"}, want: []string{"20261023T204512345Z-s.md:1: regular file"}},
|
||||
{name: "CRLF accepted", files: map[string]string{aPath: strings.ReplaceAll(adrBase, "\n", "\r\n")}},
|
||||
{name: "BOM", files: map[string]string{aPath: "\xef\xbb\xbf" + adrBase}, want: []string{aPath + ":1: byte order mark"}},
|
||||
{name: "invalid UTF-8", files: map[string]string{aPath: adrBase + "\xff\n"}, want: []string{aPath + ":1: UTF-8"}},
|
||||
|
||||
// 2. Names and IDs
|
||||
{name: "invalid timestamp", files: map[string]string{"docs/adr/20261323T204512345Z-x.md": adrBase}, want: []string{"20261323T204512345Z-x.md:1: invalid UTC timestamp"}},
|
||||
{name: "slug double hyphen", files: map[string]string{"docs/adr/20261023T204512345Z-a--b.md": adrBase}, want: []string{"a--b.md:1: slug must be"}},
|
||||
{name: "slug uppercase", files: map[string]string{"docs/adr/20261023T204512345Z-Ab.md": adrBase}, want: []string{"Ab.md:1: slug must be"}},
|
||||
{name: "slug trailing hyphen", files: map[string]string{"docs/adr/20261023T204512345Z-ab-.md": adrBase}, want: []string{"ab-.md:1: slug must be"}},
|
||||
{name: "slug 80 characters", files: map[string]string{"docs/adr/20261023T204512345Z-" + long[:80] + ".md": adrBase}},
|
||||
{name: "slug 81 characters", files: map[string]string{"docs/adr/20261023T204512345Z-" + long + ".md": adrBase}, want: []string{".md:1: longer than 80"}},
|
||||
{name: "duplicate ID across directories", files: map[string]string{"docs/adr/20261023T210000000Z-adopt-queue.md": adrBase, pPath: planBase},
|
||||
want: []string{"docs/adr/20261023T210000000Z-adopt-queue.md:1: duplicate ID", pPath + ":1: duplicate ID"}},
|
||||
|
||||
// 3. Frontmatter
|
||||
{name: "no frontmatter", files: map[string]string{aPath: "# Use Queue\n"}, want: []string{aPath + ":1: --- frontmatter"}},
|
||||
{name: "unterminated frontmatter", files: map[string]string{aPath: "---\ntitle: x\n"}, want: []string{aPath + ":1: not closed"}},
|
||||
{name: "empty frontmatter", files: map[string]string{aPath: "---\n---\n# x\n"}, want: []string{aPath + ":2: empty"}},
|
||||
{name: "frontmatter is a list", files: map[string]string{aPath: "---\n- a\n---\n"}, want: []string{aPath + ":2: mapping"}},
|
||||
{name: "invalid YAML", files: map[string]string{aPath: sub(adrBase, "title: Use Queue", "title: [")}, want: []string{aPath + ":3: invalid YAML"}},
|
||||
{name: "multiple YAML documents", files: map[string]string{aPath: sub(adrBase, "title: Use Queue\n", "title: Use Queue\n...\nx: y\n")}, want: []string{aPath + ":2: single YAML document"}},
|
||||
{name: "anchor and alias", files: map[string]string{aPath: sub(adrBase, "supersedes: []\nsuperseded_by: []", "supersedes: &a []\nsuperseded_by: *a")}, want: []string{aPath + ":9: anchors", aPath + ":10: aliases"}},
|
||||
{name: "explicit tag", files: map[string]string{aPath: sub(adrBase, "summary: Run", "summary: !!str Run")}, want: []string{aPath + ":6: explicit"}},
|
||||
{name: "unknown key", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by: []\nextra: x")}, want: []string{aPath + ":14: unknown key"}},
|
||||
{name: "missing key", files: map[string]string{aPath: sub(adrBase, "related: []\n", "")}, want: []string{aPath + ":1: missing key \"related\""}},
|
||||
{name: "duplicate key", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by: []\nsummary: again")}, want: []string{aPath + ":14: duplicate key"}},
|
||||
{name: "null tilde", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", "summary: ~")}, want: []string{aPath + ":6: must not be null"}},
|
||||
{name: "null empty", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", "summary:")}, want: []string{aPath + ":6: must not be null"}},
|
||||
{name: "null word", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", "summary: Null")}, want: []string{aPath + ":6: must not be null"}},
|
||||
{name: "scalars are literal text", files: map[string]string{aPath: sub(adrBase, "title: Use Queue", "title: 2026", "# Use Queue", "# 2026", "date: 2026-10-23", `date: "2026-10-23"`, "summary: Run jobs on a queue.", `summary: "null"`)}},
|
||||
{name: "null lowercase", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", "summary: null")}, want: []string{aPath + ":6: must not be null"}},
|
||||
{name: "null uppercase", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", "summary: NULL")}, want: []string{aPath + ":6: must not be null"}},
|
||||
{name: "list item is null", files: map[string]string{aPath: sub(adrBase, "related: []", "related:\n - ~")}, want: []string{aPath + ":12: must not be null"}},
|
||||
|
||||
// 3.1 ADR keys
|
||||
{name: "empty title", files: map[string]string{aPath: sub(adrBase, "title: Use Queue", `title: ""`)}, want: []string{aPath + ":2: title must be a non-empty single line"}},
|
||||
{name: "multi-line summary", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", "summary: |\n a\n b")}, want: []string{aPath + ":6: summary must be a non-empty single line"}},
|
||||
{name: "title is a list", files: map[string]string{aPath: sub(adrBase, "title: Use Queue", "title: [a]")}, want: []string{aPath + ":2: title must be a string"}},
|
||||
{name: "invalid date", files: map[string]string{aPath: sub(adrBase, "date: 2026-10-23", "date: 2026-13-40")}, want: []string{aPath + ":3: valid YYYY-MM-DD"}},
|
||||
{name: "date differs from filename", files: map[string]string{aPath: sub(adrBase, "date: 2026-10-23", "date: 2026-10-24")}, want: []string{aPath + ":3: does not match"}},
|
||||
{name: "invalid status", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: done")}, want: []string{aPath + ":4: status must be one of"}},
|
||||
{name: "invalid implementation", files: map[string]string{aPath: sub(adrBase, "implementation: not-started", "implementation: done")}, want: []string{aPath + ":5: implementation must be one of"}},
|
||||
{name: "empty tags", files: map[string]string{aPath: sub(adrBase, "tags:\n - jobs", "tags: []")}, want: []string{aPath + ":7: tags must not be empty"}},
|
||||
{name: "tags is a string", files: map[string]string{aPath: sub(adrBase, "tags:\n - jobs", "tags: jobs")}, want: []string{aPath + ":7: tags must be a list"}},
|
||||
{name: "bad tag", files: map[string]string{aPath: sub(adrBase, " - jobs", " - Bad_Tag")}, want: []string{aPath + ":8: tag"}},
|
||||
{name: "duplicate tag", files: map[string]string{aPath: sub(adrBase, " - jobs", " - jobs\n - jobs")}, want: []string{aPath + ":9: duplicate tag"}},
|
||||
|
||||
// 3.2 Plan keys
|
||||
{name: "plan has unknown title key", files: map[string]string{pPath: sub(planBase, "status: draft", "status: draft\ntitle: x")}, want: []string{pPath + ":3: unknown key"}},
|
||||
{name: "plan invalid status", files: map[string]string{pPath: sub(planBase, "status: draft", "status: done")}, want: []string{pPath + ":2: status must be one of"}},
|
||||
|
||||
// 3.3 References
|
||||
{name: "related accepts ADR and plan", files: map[string]string{aPath: sub(adrBase, "related: []", "related:\n - /"+bPath+"\n - /"+pPath)}},
|
||||
{name: "ADR tracking accepts URL and plan", files: map[string]string{aPath: sub(adrBase, "tracking: []", "tracking:\n - https://example.com/issues/1\n - /"+pPath)}},
|
||||
{name: "ADR tracking rejects ADR path", files: map[string]string{aPath: sub(adrBase, "tracking: []", "tracking:\n - /"+bPath)}, want: []string{aPath + ":13: tracking"}},
|
||||
{name: "plan tracking rejects path", files: map[string]string{pPath: sub(planBase, "tracking: []", "tracking:\n - /"+aPath)}, want: []string{pPath + ":4: tracking"}},
|
||||
{name: "plan tracking accepts URL", files: map[string]string{pPath: sub(planBase, "tracking: []", "tracking:\n - https://example.com/issues/1")}},
|
||||
{name: "plan adrs accepts ADR", files: map[string]string{aPath: adrBase, pPath: sub(planBase, "adrs: []", "adrs:\n - /"+aPath)}},
|
||||
{name: "plan adrs rejects plan", files: map[string]string{pPath: sub(planBase, "adrs: []", "adrs:\n - /"+pPath)}, want: []string{pPath + ":5: adrs"}},
|
||||
{name: "implemented_by accepts URL and file", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - https://example.com/pr/1\n - /docs/spec.txt")}},
|
||||
{name: "implemented_by missing file", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - /docs/missing.txt")}, want: []string{aPath + ":14: does not exist"}},
|
||||
{name: "reference to missing ADR", files: map[string]string{aPath: sub(adrBase, "related: []", "related:\n - /docs/adr/20261023T000000000Z-gone.md")}, want: []string{aPath + ":12: does not exist"}},
|
||||
{name: "duplicate reference", files: map[string]string{aPath: sub(adrBase, "related: []", "related:\n - /"+bPath+"\n - /"+bPath)}, want: []string{aPath + ":13: duplicate reference"}},
|
||||
{name: "self reference", files: map[string]string{aPath: sub(adrBase, "related: []", "related:\n - /"+aPath)}, want: []string{aPath + ":12: itself"}},
|
||||
{name: "bare issue number", files: map[string]string{aPath: sub(adrBase, "tracking: []", "tracking:\n - \"#123\"")}, want: []string{aPath + ":13: not durable"}},
|
||||
{name: "owner repo issue number", files: map[string]string{aPath: sub(adrBase, "tracking: []", "tracking:\n - example/repo#123")}, want: []string{aPath + ":13: not durable"}},
|
||||
{name: "this PR placeholder", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - This PR")}, want: []string{aPath + ":14: not durable"}},
|
||||
{name: "fragment in frontmatter", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - /docs/spec.txt#x")}, want: []string{aPath + ":14: fragments"}},
|
||||
{name: "credentials in frontmatter URL", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - https://u:p@example.com/x")}, want: []string{aPath + ":14: credentials"}},
|
||||
{name: "mailto in frontmatter", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - mailto:a@example.com")}, want: []string{aPath + ":14: scheme"}},
|
||||
{name: "anchor in frontmatter", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - \"#anchor\"")}, want: []string{aPath + ":14: relative"}},
|
||||
{name: "symlink escape in frontmatter", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - /docs/out/f.md")}, links: map[string]string{"docs/out": "@outside"}, want: []string{aPath + ":14: outside the repository"}},
|
||||
{name: "percent-encoded self reference", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - /docs/adr/20261023T204512345Z-use%2Dqueue.md")}, want: []string{aPath + ":14: itself"}},
|
||||
{name: "supersedes rejects plan path", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: accepted", "supersedes: []", "supersedes:\n - /"+pPath)}, want: []string{aPath + ":10: supersedes"}},
|
||||
{name: "relative path in frontmatter", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - ../docs/spec.txt")}, want: []string{aPath + ":14: relative"}},
|
||||
|
||||
// 4. Status
|
||||
{name: "superseded needs superseded_by", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: superseded")}, want: []string{aPath + ":4: requires superseded_by"}},
|
||||
{name: "superseded_by needs superseded", files: map[string]string{aPath: sub(adrBase, "superseded_by: []", "superseded_by:\n - /"+bPath), bPath: sub(bBase, "supersedes: []", "supersedes:\n - /"+aPath)}, want: []string{aPath + ":10: requires status superseded"}},
|
||||
{name: "rejected needs not-applicable", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: rejected")}, want: []string{aPath + ":5: not-applicable"}},
|
||||
{name: "rejected not-applicable", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: rejected", "implementation: not-started", "implementation: not-applicable")}},
|
||||
{name: "implemented needs implemented_by", files: map[string]string{aPath: sub(adrBase, "implementation: not-started", "implementation: implemented")}, want: []string{aPath + ":5: requires implemented_by"}},
|
||||
{name: "implemented with implemented_by", files: map[string]string{aPath: sub(adrBase, "implementation: not-started", "implementation: implemented", "implemented_by: []", "implemented_by:\n - https://example.com/pr/1")}},
|
||||
{name: "active plan needs tracking", files: map[string]string{pPath: sub(planBase, "status: draft", "status: active")}, want: []string{pPath + ":2: requires tracking"}},
|
||||
{name: "active plan valid", files: map[string]string{pPath: sub(planBase, "status: draft", "status: active", "tracking: []", "tracking:\n - https://example.com/issues/1")}},
|
||||
{name: "active plan with completion", files: map[string]string{pPath: sub(planBase, "status: draft", "status: active", "tracking: []", "tracking:\n - https://example.com/issues/1", "completion: []", "completion:\n - https://example.com/pr/1")}, want: []string{pPath + ":6: completion must be empty"}},
|
||||
{name: "draft plan with completion", files: map[string]string{pPath: sub(planBase, "completion: []", "completion:\n - https://example.com/pr/1")}, want: []string{pPath + ":5: completion must be empty"}},
|
||||
{name: "completed plan needs completion", files: map[string]string{pPath: sub(planBase, "status: draft", "status: completed")}, want: []string{pPath + ":2: requires completion"}},
|
||||
{name: "cancelled plan needs completion", files: map[string]string{pPath: sub(planBase, "status: draft", "status: cancelled")}, want: []string{pPath + ":2: requires completion"}},
|
||||
{name: "completed plan valid", files: map[string]string{pPath: sub(planBase, "status: draft", "status: completed", "completion: []", "completion:\n - https://example.com/pr/1")}},
|
||||
|
||||
// 5. Supersession
|
||||
{name: "supersession valid", files: map[string]string{
|
||||
aPath: sub(adrBase, "status: proposed", "status: superseded", "superseded_by: []", "superseded_by:\n - /"+bPath),
|
||||
bPath: sub(bBase, "supersedes: []", "supersedes:\n - /"+aPath)}},
|
||||
{name: "supersedes not reciprocated", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: accepted", "supersedes: []", "supersedes:\n - /"+bPath)}, want: []string{aPath + ":10: must list this record in superseded_by"}},
|
||||
{name: "superseded_by not reciprocated", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: superseded", "superseded_by: []", "superseded_by:\n - /"+bPath)}, want: []string{aPath + ":11: must list this record in supersedes"}},
|
||||
{name: "superseder is proposed", files: map[string]string{
|
||||
aPath: sub(adrBase, "supersedes: []", "supersedes:\n - /"+bPath),
|
||||
bPath: sub(bBase, "status: accepted", "status: superseded", "superseded_by: []", "superseded_by:\n - /"+aPath)}, want: []string{aPath + ":9: must be accepted"}},
|
||||
{name: "superseder is deprecated", files: map[string]string{
|
||||
aPath: sub(adrBase, "status: proposed", "status: superseded", "superseded_by: []", "superseded_by:\n - /"+bPath),
|
||||
bPath: sub(bBase, "status: accepted", "status: deprecated", "supersedes: []", "supersedes:\n - /"+aPath)}},
|
||||
{name: "superseder is rejected", files: map[string]string{
|
||||
aPath: sub(adrBase, "status: proposed", "status: rejected", "implementation: not-started", "implementation: not-applicable", "supersedes: []", "supersedes:\n - /"+bPath),
|
||||
bPath: sub(bBase, "status: accepted", "status: superseded", "superseded_by: []", "superseded_by:\n - /"+aPath)}, want: []string{aPath + ":9: must be accepted"}},
|
||||
{name: "supersession cycle", files: map[string]string{
|
||||
aPath: sub(adrBase, "status: proposed", "status: superseded", "supersedes: []", "supersedes:\n - /"+bPath, "superseded_by: []", "superseded_by:\n - /"+bPath),
|
||||
bPath: sub(bBase, "status: accepted", "status: superseded", "supersedes: []", "supersedes:\n - /"+aPath, "superseded_by: []", "superseded_by:\n - /"+aPath)}, want: []string{bPath + ":10: cycle"}},
|
||||
|
||||
// 6. Body
|
||||
{name: "no H1", files: map[string]string{aPath: sub(adrBase, "# Use Queue\n\n", "")}, want: []string{aPath + ":15: first heading must be an H1"}},
|
||||
{name: "H1 differs from title", files: map[string]string{aPath: sub(adrBase, "# Use Queue", "# Other")}, want: []string{aPath + ":16: must equal title"}},
|
||||
{name: "two H1s", files: map[string]string{aPath: adrBase + "\n# Again\n"}, want: []string{aPath + ":35: more than one H1"}},
|
||||
{name: "missing required section", files: map[string]string{aPath: sub(adrBase, "## Considered Options\n\n* Queue\n* Inline\n\n", "")}, want: []string{aPath + ":1: missing required section \"Considered Options\""}},
|
||||
{name: "repeated section", files: map[string]string{aPath: adrBase + "\n## Considered Options\n\nMore.\n"}, want: []string{aPath + ":35: appears more than once"}},
|
||||
{name: "heading in code does not count", files: map[string]string{aPath: sub(adrBase, "## Considered Options", "```\n## Considered Options\n```")}, want: []string{aPath + ":1: missing required section \"Considered Options\""}},
|
||||
{name: "section with only a comment", files: map[string]string{aPath: sub(adrBase, "* Queue\n* Inline", "<!-- none -->")}, want: []string{aPath + ":22: has no prose"}},
|
||||
{name: "section with only code", files: map[string]string{aPath: sub(adrBase, "* Queue\n* Inline", "```\nQueue\n```")}, want: []string{aPath + ":22: has no prose"}},
|
||||
{name: "section with only inline code", files: map[string]string{aPath: sub(adrBase, "* Queue\n* Inline", "`Queue`")}, want: []string{aPath + ":22: has no prose"}},
|
||||
{name: "subsection prose counts for the section", files: map[string]string{aPath: sub(adrBase, "Jobs block requests.", "### Detail\n\nJobs block requests.")}},
|
||||
{name: "setext, closing hashes and blockquote", files: map[string]string{aPath: sub(adrBase, "## Context and Problem Statement", "## Context and Problem Statement ##", "## Considered Options\n\n* Queue\n* Inline", "Considered Options\n------\n\n> Queue or inline.")}},
|
||||
{name: "extra sections allowed", files: map[string]string{aPath: adrBase + "\n## More Information\n\nSee above.\n"}},
|
||||
{name: "Consequences as H2", files: map[string]string{aPath: sub(adrBase, "### Consequences", "## Consequences")},
|
||||
want: []string{aPath + ":31: not a ## section", aPath + ":27: must contain a ### Consequences"}},
|
||||
{name: "missing Consequences", files: map[string]string{aPath: sub(adrBase, "### Consequences\n\n* One more service.\n", "")}, want: []string{aPath + ":27: must contain a ### Consequences"}},
|
||||
{name: "repeated Consequences", files: map[string]string{aPath: adrBase + "\n### Consequences\n\nAgain.\n"}, want: []string{aPath + ":35: appears more than once"}},
|
||||
{name: "Consequences without prose", files: map[string]string{aPath: sub(adrBase, "* One more service.", "<!-- later -->")}, want: []string{aPath + ":31: has no prose"}},
|
||||
{name: "Decision Outcome without own prose", files: map[string]string{aPath: sub(adrBase, "Chosen option: Queue.\n\n", "")}, want: []string{aPath + ":27: needs prose before"}},
|
||||
{name: "plan missing section", files: map[string]string{pPath: sub(planBase, "## Steps\n\n1. Do it.\n\n", "")}, want: []string{pPath + ":1: missing required section \"Steps\""}},
|
||||
{name: "plan empty H1", files: map[string]string{pPath: sub(planBase, "# Adopt Queue", "#")}, want: []string{pPath + ":8: H1"}},
|
||||
{name: "empty ADR H1 is on its own line", files: map[string]string{aPath: sub(adrBase, "# Use Queue", "#")}, want: []string{aPath + ":16: must equal title"}},
|
||||
{name: "empty H2 after a paragraph", files: map[string]string{aPath: adrBase + "\nText.\n\n##\n"}},
|
||||
{name: "H1 after an empty H2 after a paragraph", files: map[string]string{aPath: adrBase + "\nText.\n\n##\n#\n"}, want: []string{aPath + ":38: more than one H1"}},
|
||||
{name: "empty H1 after a thematic break", files: map[string]string{aPath: adrBase + "\n***\n#\n"}, want: []string{aPath + ":36: more than one H1"}},
|
||||
{name: "two empty H1s", files: map[string]string{aPath: adrBase + "\n#\n#\n"}, want: []string{aPath + ":35: more than one H1", aPath + ":36: more than one H1"}},
|
||||
{name: "headings inside blockquotes and lists do not count", files: map[string]string{aPath: adrBase + "\n> ## Consequences\n\n* ## Decision Outcome\n"}},
|
||||
{name: "H4 does not end the Decision Outcome prose", files: map[string]string{aPath: sub(adrBase, "Chosen option: Queue.", "#### Detail\n\nChosen.")}},
|
||||
{name: "plan second H1", files: map[string]string{pPath: planBase + "\n# Again\n"}, want: []string{pPath + ":22: more than one H1"}},
|
||||
{name: "plan repeated section", files: map[string]string{pPath: planBase + "\n## Scope\n\nMore.\n"}, want: []string{pPath + ":22: appears more than once"}},
|
||||
{name: "plan section without prose", files: map[string]string{pPath: sub(planBase, "1. Do it.", "<!-- later -->")}, want: []string{pPath + ":14: has no prose"}},
|
||||
{name: "plan Outcome is optional", files: map[string]string{pPath: planBase + "\n## Outcome\n\nDone.\n"}},
|
||||
|
||||
// 6.3 Placeholders
|
||||
{name: "marker in body", files: map[string]string{aPath: sub(adrBase, "Jobs block requests.", "{{docs:todo}}")}, want: []string{aPath + ":20: unfinished placeholder"}},
|
||||
{name: "marker in comment", files: map[string]string{aPath: adrBase + "\n<!-- {{docs:todo}} -->\n"}, want: []string{aPath + ":35: unfinished placeholder"}},
|
||||
{name: "marker in code", files: map[string]string{aPath: adrBase + "\nUse `{{docs:todo}}` as the marker.\n\n```\n{{docs:todo}}\n```\n"}},
|
||||
{name: "TODO and TBD are fine", files: map[string]string{aPath: adrBase + "\nTODO and TBD.\n"}},
|
||||
{name: "marker in frontmatter string", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", `summary: "{{docs:todo}}"`)}, want: []string{aPath + ":6: unfinished placeholder"}},
|
||||
{name: "marker as tag reports only the marker", files: map[string]string{aPath: sub(adrBase, " - jobs", ` - "{{docs:todo}}"`)}, want: []string{aPath + ":8: unfinished placeholder"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res, err := Check(tree(t, withSupport(tt.files), tt.links))
|
||||
must(t, err)
|
||||
expect(t, res.Problems, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSymlinkedDocsDir(t *testing.T) {
|
||||
res, err := Check(tree(t, nil, map[string]string{"docs/plans": "@outside"}))
|
||||
must(t, err)
|
||||
expect(t, res.Problems, []string{"docs/plans:1: real directory"})
|
||||
}
|
||||
|
||||
func TestCheckSymlinkedDocsParent(t *testing.T) {
|
||||
root := tree(t, nil, map[string]string{"docs": "@outside"})
|
||||
out, err := os.Readlink(filepath.Join(root, "docs"))
|
||||
must(t, err)
|
||||
must(t, os.MkdirAll(filepath.Join(out, "adr"), 0o755))
|
||||
res, err := Check(root)
|
||||
must(t, err)
|
||||
expect(t, res.Problems, []string{"docs/adr:1: inside the repository"})
|
||||
}
|
||||
|
||||
func TestCheckEmptyRoot(t *testing.T) {
|
||||
res, err := Check(t.TempDir())
|
||||
must(t, err)
|
||||
if res.Records != 0 || len(res.Problems) != 0 {
|
||||
t.Fatalf("got %+v", res)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
const marker = "{{docs:todo}}"
|
||||
|
||||
var yamlLineRe = regexp.MustCompile(`line (\d+)`)
|
||||
|
||||
type field struct{ key, val *yaml.Node }
|
||||
|
||||
type item struct {
|
||||
v string
|
||||
line int
|
||||
}
|
||||
|
||||
// fm reads the typed fields of one record's frontmatter mapping.
|
||||
type fm struct {
|
||||
c *checker
|
||||
r *record
|
||||
f map[string]field
|
||||
}
|
||||
|
||||
// splitFrontmatter returns the frontmatter text, the body and the file line the body starts on.
|
||||
func splitFrontmatter(src string) (front, body string, bodyLine int, msg string) {
|
||||
lines := strings.Split(src, "\n")
|
||||
if lines[0] != "---" {
|
||||
return "", "", 0, "file must start with a --- frontmatter line"
|
||||
}
|
||||
for i := 1; i < len(lines); i++ {
|
||||
if lines[i] == "---" {
|
||||
return strings.Join(lines[1:i], "\n"), strings.Join(lines[i+1:], "\n"), i + 2, ""
|
||||
}
|
||||
}
|
||||
return "", "", 0, "frontmatter is not closed by a --- line"
|
||||
}
|
||||
|
||||
// parseFrontmatter decodes the mapping and rejects anything but plain keys and values.
|
||||
func (c *checker) parseFrontmatter(r *record, front string) (*fm, bool) {
|
||||
report := func(line int, format string, args ...any) { c.add(r.path, line, format, args...) }
|
||||
dec := yaml.NewDecoder(strings.NewReader(front))
|
||||
var doc, extra yaml.Node
|
||||
if err := dec.Decode(&doc); err != nil || len(doc.Content) == 0 {
|
||||
if err == nil || errors.Is(err, io.EOF) {
|
||||
report(2, "frontmatter is empty")
|
||||
} else {
|
||||
line := 2
|
||||
if m := yamlLineRe.FindStringSubmatch(err.Error()); m != nil {
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
line += n - 1
|
||||
}
|
||||
report(line, "invalid YAML: %v", strings.TrimPrefix(err.Error(), "yaml: "))
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
if err := dec.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
report(2, "frontmatter must be a single YAML document")
|
||||
return nil, false
|
||||
}
|
||||
root := doc.Content[0]
|
||||
if root.Kind != yaml.MappingNode {
|
||||
report(root.Line+1, "frontmatter must be a mapping")
|
||||
return nil, false
|
||||
}
|
||||
ok := true
|
||||
var walk func(n *yaml.Node)
|
||||
walk = func(n *yaml.Node) {
|
||||
switch {
|
||||
case n.Kind == yaml.AliasNode:
|
||||
report(n.Line+1, "YAML aliases are not allowed")
|
||||
ok = false
|
||||
case n.Anchor != "":
|
||||
report(n.Line+1, "YAML anchors are not allowed")
|
||||
ok = false
|
||||
case n.Style&yaml.TaggedStyle != 0:
|
||||
report(n.Line+1, "explicit YAML tags are not allowed")
|
||||
ok = false
|
||||
}
|
||||
for _, ch := range n.Content {
|
||||
walk(ch)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
m := &fm{c: c, r: r, f: map[string]field{}}
|
||||
for i := 0; i+1 < len(root.Content); i += 2 {
|
||||
k, v := root.Content[i], root.Content[i+1]
|
||||
if k.Kind != yaml.ScalarNode {
|
||||
report(k.Line+1, "keys must be plain strings")
|
||||
ok = false
|
||||
} else if _, dup := m.f[k.Value]; dup {
|
||||
report(k.Line+1, "duplicate key %q", k.Value)
|
||||
ok = false
|
||||
} else {
|
||||
m.f[k.Value] = field{k, v}
|
||||
}
|
||||
markers(v, report)
|
||||
}
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// markers reports an unfinished placeholder in any string value or list item.
|
||||
func markers(n *yaml.Node, report func(int, string, ...any)) {
|
||||
if n.Kind == yaml.ScalarNode && strings.Contains(n.Value, marker) {
|
||||
report(n.Line+1, "unfinished placeholder %s", marker)
|
||||
}
|
||||
for _, ch := range n.Content {
|
||||
markers(ch, report)
|
||||
}
|
||||
}
|
||||
|
||||
// keys reports missing and unknown keys.
|
||||
func (m *fm) keys(want ...string) {
|
||||
for _, k := range want {
|
||||
if _, ok := m.f[k]; !ok {
|
||||
m.c.add(m.r.path, 1, "missing key %q", k)
|
||||
}
|
||||
}
|
||||
for k, f := range m.f {
|
||||
if !slices.Contains(want, k) {
|
||||
m.c.add(m.r.path, f.key.Line+1, "unknown key %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *fm) line(key string) int {
|
||||
if f, ok := m.f[key]; ok {
|
||||
return f.key.Line + 1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func (m *fm) isNull(key string, n *yaml.Node) bool {
|
||||
if n.Kind == yaml.ScalarNode && n.Tag == "!!null" {
|
||||
m.c.add(m.r.path, n.Line+1, "%s must not be null", key)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// scalar returns the literal text of a non-null scalar value.
|
||||
func (m *fm) scalar(key string) (string, int, bool) {
|
||||
f, ok := m.f[key]
|
||||
if !ok || m.isNull(key, f.val) {
|
||||
return "", 0, false
|
||||
}
|
||||
if f.val.Kind != yaml.ScalarNode {
|
||||
m.c.add(m.r.path, f.key.Line+1, "%s must be a string", key)
|
||||
return "", 0, false
|
||||
}
|
||||
return f.val.Value, f.val.Line + 1, true
|
||||
}
|
||||
|
||||
// text is a non-empty single-line string.
|
||||
func (m *fm) text(key string) (string, bool) {
|
||||
v, line, ok := m.scalar(key)
|
||||
if ok && (strings.TrimSpace(v) == "" || strings.ContainsAny(v, "\r\n")) {
|
||||
m.c.add(m.r.path, line, "%s must be a non-empty single line", key)
|
||||
return v, false
|
||||
}
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (m *fm) enum(key string, allowed ...string) (string, bool) {
|
||||
v, line, ok := m.scalar(key)
|
||||
if ok && !slices.Contains(allowed, v) {
|
||||
m.c.add(m.r.path, line, "%s must be one of %s", key, strings.Join(allowed, ", "))
|
||||
return v, false
|
||||
}
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// list returns the items of a sequence of non-null scalars.
|
||||
func (m *fm) list(key string) ([]item, bool) {
|
||||
f, ok := m.f[key]
|
||||
if !ok || m.isNull(key, f.val) {
|
||||
return nil, false
|
||||
}
|
||||
if f.val.Kind != yaml.SequenceNode {
|
||||
m.c.add(m.r.path, f.key.Line+1, "%s must be a list", key)
|
||||
return nil, false
|
||||
}
|
||||
var items []item
|
||||
for _, n := range f.val.Content {
|
||||
if m.isNull(key, n) {
|
||||
continue
|
||||
}
|
||||
if n.Kind != yaml.ScalarNode {
|
||||
m.c.add(m.r.path, n.Line+1, "%s items must be strings", key)
|
||||
continue
|
||||
}
|
||||
items = append(items, item{n.Value, n.Line + 1})
|
||||
}
|
||||
m.r.lists[key] = items
|
||||
return items, true
|
||||
}
|
||||
|
||||
// tags checks the ADR tags list.
|
||||
func (m *fm) tags() {
|
||||
items, ok := m.list("tags")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if len(items) == 0 {
|
||||
m.c.add(m.r.path, m.line("tags"), "tags must not be empty")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, it := range items {
|
||||
switch {
|
||||
case it.v == marker:
|
||||
case !slugRe.MatchString(it.v):
|
||||
m.c.add(m.r.path, it.line, "tag %q must be lowercase a-z0-9 words joined by single hyphens", it.v)
|
||||
case seen[it.v]:
|
||||
m.c.add(m.r.path, it.line, "duplicate tag %q", it.v)
|
||||
}
|
||||
seen[it.v] = true
|
||||
}
|
||||
}
|
||||
|
||||
func (m *fm) problemf(key, format string, args ...any) {
|
||||
m.c.add(m.r.path, m.line(key), format, args...)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// graph checks cross-record rules: unique IDs and supersession.
|
||||
func (c *checker) graph(recs []*record) {
|
||||
byStem := map[string][]*record{}
|
||||
byPath := map[string]*record{}
|
||||
for _, r := range recs {
|
||||
byStem[r.stem] = append(byStem[r.stem], r)
|
||||
byPath["/"+r.path] = r
|
||||
}
|
||||
for _, group := range byStem {
|
||||
for _, r := range group {
|
||||
for _, o := range group {
|
||||
if o != r {
|
||||
c.add(r.path, 1, "duplicate ID %s, also used by %s", r.stem, o.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
adrs := recs[:0:0]
|
||||
for _, r := range recs {
|
||||
if r.kind == "adr" && r.fm != nil {
|
||||
adrs = append(adrs, r)
|
||||
}
|
||||
}
|
||||
for _, r := range adrs {
|
||||
if len(r.lists["supersedes"]) > 0 && !slices.Contains([]string{"accepted", "superseded", "deprecated"}, r.status) {
|
||||
c.add(r.path, r.fm.line("supersedes"), "a record that supersedes another must be accepted, superseded or deprecated, not %s", r.status)
|
||||
}
|
||||
for _, e := range [][2]string{{"supersedes", "superseded_by"}, {"superseded_by", "supersedes"}} {
|
||||
for _, it := range r.lists[e[0]] {
|
||||
if o := byPath[it.v]; o != nil && o.kind == "adr" && !slices.ContainsFunc(o.lists[e[1]], func(x item) bool { return x.v == "/"+r.path }) {
|
||||
c.add(r.path, it.line, "%s lists %s, which must list this record in %s", e[0], it.v, e[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cycles over supersedes edges: report once, on the first record (in path order) that closes one.
|
||||
const (
|
||||
visiting = 1
|
||||
done = 2
|
||||
)
|
||||
state := map[*record]int{}
|
||||
var visit func(r *record)
|
||||
visit = func(r *record) {
|
||||
state[r] = visiting
|
||||
for _, it := range r.lists["supersedes"] {
|
||||
o := byPath[it.v]
|
||||
if o == nil || o.kind != "adr" {
|
||||
continue
|
||||
}
|
||||
switch state[o] {
|
||||
case visiting:
|
||||
c.add(r.path, it.line, "supersession cycle through %s", strings.TrimPrefix(it.v, "/"))
|
||||
case 0:
|
||||
visit(o)
|
||||
}
|
||||
}
|
||||
state[r] = done
|
||||
}
|
||||
for _, r := range adrs {
|
||||
if state[r] == 0 {
|
||||
visit(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
timestampLayout = "20060102T150405.000Z"
|
||||
maxSlug = 80
|
||||
)
|
||||
|
||||
var (
|
||||
nameRe = regexp.MustCompile(`^(\d{8}T\d{9}Z)-(.+)\.md$`)
|
||||
slugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
)
|
||||
|
||||
// parseName validates a record filename and returns its stem, UTC timestamp
|
||||
// and a problem message (empty when valid).
|
||||
func parseName(name string) (stem string, at time.Time, msg string) {
|
||||
m := nameRe.FindStringSubmatch(name)
|
||||
if m == nil {
|
||||
return "", at, "name must be YYYYMMDDTHHMMSSmmmZ-slug.md"
|
||||
}
|
||||
at, err := time.Parse(timestampLayout, m[1][:15]+"."+m[1][15:])
|
||||
if err != nil {
|
||||
return "", at, "name has an invalid UTC timestamp"
|
||||
}
|
||||
switch {
|
||||
case !slugRe.MatchString(m[2]):
|
||||
return "", at, "slug must be lowercase a-z0-9 words joined by single hyphens"
|
||||
case len(m[2]) > maxSlug:
|
||||
return "", at, fmt.Sprintf("slug is longer than %d characters", maxSlug)
|
||||
}
|
||||
return strings.TrimSuffix(name, ".md"), at, ""
|
||||
}
|
||||
|
||||
// slugify lowercases title and joins its ASCII letter and digit runs with hyphens.
|
||||
func slugify(title string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(title) {
|
||||
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
} else if b.Len() > 0 && !strings.HasSuffix(b.String(), "-") {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
}
|
||||
s := b.String()
|
||||
return strings.Trim(s[:min(len(s), maxSlug)], "-")
|
||||
}
|
||||
|
||||
var closingRe = regexp.MustCompile(`(^|[ \t])#+$`)
|
||||
|
||||
// New creates a record of the given kind ("adr" or "plan") under root and
|
||||
// returns its root-relative slash path. The file is created exclusively; the
|
||||
// timestamp advances by 1 ms on a collision.
|
||||
func New(root, kind, title string, now time.Time) (string, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" || strings.ContainsAny(title, "\r\n") {
|
||||
return "", errors.New("title must be non-empty and a single line")
|
||||
}
|
||||
if closingRe.MatchString(title) {
|
||||
return "", errors.New(`title must not end in a "#" run: it would be read as a closing sequence of the H1`)
|
||||
}
|
||||
slug := slugify(title)
|
||||
if slug == "" {
|
||||
return "", errors.New("title needs at least one ASCII letter or digit for the file name")
|
||||
}
|
||||
var dir string
|
||||
for _, d := range dirs {
|
||||
if d.kind == kind {
|
||||
dir = d.dir
|
||||
}
|
||||
}
|
||||
tmpl, err := templates.ReadFile("templates/" + kind + ".md")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unknown record type %q", kind)
|
||||
}
|
||||
quoted, err := json.Marshal(title) // JSON strings are valid YAML double-quoted scalars
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
used, err := stems(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
at := now.UTC().Truncate(time.Millisecond)
|
||||
for ; ; at = at.Add(time.Millisecond) {
|
||||
stem := strings.Replace(at.Format(timestampLayout), ".", "", 1) + "-" + slug
|
||||
if used[stem] {
|
||||
continue
|
||||
}
|
||||
rel := dir + "/" + stem + ".md"
|
||||
f, err := os.OpenFile(filepath.Join(root, filepath.FromSlash(rel)), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
content := strings.NewReplacer("@TITLE_YAML@", string(quoted), "@TITLE@", title, "@DATE@", at.Format(time.DateOnly)).Replace(string(tmpl))
|
||||
_, werr := f.WriteString(content)
|
||||
return rel, errors.Join(werr, f.Close())
|
||||
}
|
||||
}
|
||||
|
||||
// stems returns the IDs of every record-named file in both directories.
|
||||
func stems(root string) (map[string]bool, error) {
|
||||
used := map[string]bool{}
|
||||
for _, d := range dirs {
|
||||
entries, err := os.ReadDir(filepath.Join(root, d.dir))
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if stem, _, msg := parseName(e.Name()); msg == "" {
|
||||
used[stem] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return used, nil
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLinkDestinations covers the section 7 rules through one body link.
|
||||
func TestLinkDestinations(t *testing.T) {
|
||||
tests := []struct{ dest, want string }{ // want "" means valid
|
||||
{"/docs/spec.txt", ""},
|
||||
{"/docs/spec.txt#frag", ""},
|
||||
{"/docs/sp%20ace.md", ""},
|
||||
{"#anchor", ""},
|
||||
{"mailto:a@example.com", ""},
|
||||
{"https://example.com/a?b=1#c", ""},
|
||||
{`/docs/spec\.txt`, ""},
|
||||
{"/docs/spec.txt", ""},
|
||||
{"/docs/spec.txt", ""},
|
||||
{`/docs\\spec.txt`, "backslash"},
|
||||
{"/docs/SPEC.txt", "does not exist"},
|
||||
{"/Docs/spec.txt", "does not exist"},
|
||||
{"foo.md", "relative"},
|
||||
{"./foo.md", "relative"},
|
||||
{"../docs/spec.txt", "relative"},
|
||||
{"/docs/../docs/spec.txt", `".."`},
|
||||
{"/docs/%2e%2e/docs/spec.txt", `".."`},
|
||||
{`/docs\spec.txt`, "backslash"},
|
||||
{`C:\spec.txt`, "backslash"},
|
||||
{"//example.com/x", "protocol-relative"},
|
||||
{"/docs/spec.txt?x=1", "queries"},
|
||||
{"/docs/%zz", "percent-encoding"},
|
||||
{"/docs/missing.txt", "does not exist"},
|
||||
{"/Users/me/spec.txt", "does not exist"},
|
||||
{"/docs", "not a regular file"},
|
||||
{"/docs/out/f.md", "outside the repository"},
|
||||
{"file:///etc/passwd", "scheme"},
|
||||
{"ftp://example.com/x", "scheme"},
|
||||
{"javascript:alert(1)", "scheme"},
|
||||
{"https://u:p@example.com/x", "credentials"},
|
||||
{"https:///nohost", "host"},
|
||||
{"<>", "empty"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.dest, func(t *testing.T) {
|
||||
dest := tt.dest
|
||||
if strings.ContainsAny(dest, " ") {
|
||||
dest = "<" + dest + ">"
|
||||
}
|
||||
doc := adrBase + "\nSee [x](" + dest + ").\n"
|
||||
res, err := Check(tree(t, withSupport(map[string]string{aPath: doc}), map[string]string{"docs/out": "@outside"}))
|
||||
must(t, err)
|
||||
var want []string
|
||||
if tt.want != "" {
|
||||
want = []string{aPath + ":35: " + tt.want}
|
||||
}
|
||||
expect(t, res.Problems, want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLinkForms covers which Markdown constructs are link destinations.
|
||||
func TestLinkForms(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, body string
|
||||
want []string
|
||||
}{
|
||||
{"image", "", []string{":35: relative"}},
|
||||
{"autolink", "<ftp://example.com/x>", []string{":35: scheme"}},
|
||||
{"reference link and definition", "[x][r]\n\n[r]: foo.md", []string{":35: relative", ":37: relative"}},
|
||||
{"duplicate definition is checked", "[a]: /docs/spec.txt\n\n[a]: ../bad.md", []string{":37: relative"}},
|
||||
{"definition in a blockquote", "> [Zed]: ../bad.md", []string{":35: relative"}},
|
||||
{"definition-like line in code", "```\n[a]: x\n```\n\n[a]: ../bad.md", []string{":39: relative"}},
|
||||
{"definition-like paragraph continuation", "Some text\n[a]: ../x.md\n\n[a]: ../bad.md", []string{":36: relative", ":38: relative"}}, // [a] in the text is a reference link
|
||||
{"definition-like line in an HTML comment", "<!--\n[a]: x\n-->\n\n[a]: ../bad.md", []string{":39: relative"}},
|
||||
{"autolink on a later line", "Para one\nline two\n<ftp://example.com/x>", []string{":37: scheme"}},
|
||||
{"image without alt on a later line", "Para one\nline two\n", []string{":37: relative"}},
|
||||
{"unused definition", "[r]: foo.md", []string{":35: relative"}},
|
||||
{"link in a list", "* [x](foo.md)", []string{":35: relative"}},
|
||||
{"bare URL and raw HTML are not checked", "http://u:p@example.com and @user #123 abc1234\n\n<a href=\"foo.md\">x</a>", nil},
|
||||
{"link in code is not a link", "`[x](foo.md)`\n\n```\n[x](foo.md)\n```", nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res, err := Check(tree(t, withSupport(map[string]string{aPath: adrBase + "\n" + tt.body + "\n"}), nil))
|
||||
must(t, err)
|
||||
var want []string
|
||||
for _, w := range tt.want {
|
||||
want = append(want, aPath+w)
|
||||
}
|
||||
expect(t, res.Problems, want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpecExamples keeps the minimal examples in docs/docs-format.md valid.
|
||||
func TestSpecExamples(t *testing.T) {
|
||||
spec, err := os.ReadFile("../../docs/docs-format.md")
|
||||
must(t, err)
|
||||
root := t.TempDir()
|
||||
parts := strings.Split(string(spec[strings.Index(string(spec), "## 9."):]), "```markdown\n")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("expected two examples, got %d", len(parts)-1)
|
||||
}
|
||||
// The path of each example is the backticked name on the line before its fence.
|
||||
heads := strings.Split(string(spec[strings.Index(string(spec), "## 9."):]), "\n")
|
||||
var paths []string
|
||||
for _, l := range heads {
|
||||
if strings.HasPrefix(l, "`docs/") {
|
||||
paths = append(paths, strings.Trim(l, "`:"))
|
||||
}
|
||||
}
|
||||
if len(paths) != 2 {
|
||||
t.Fatalf("expected two example paths, got %v", paths)
|
||||
}
|
||||
for i, part := range parts[1:] {
|
||||
body, _, _ := strings.Cut(part, "```")
|
||||
full := filepath.Join(root, paths[i])
|
||||
must(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
||||
must(t, os.WriteFile(full, []byte(body), 0o644))
|
||||
}
|
||||
res, err := Check(root)
|
||||
must(t, err)
|
||||
expect(t, res.Problems, nil)
|
||||
if res.Records != 2 {
|
||||
t.Fatalf("records = %d", res.Records)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var now = time.Date(2026, 10, 23, 20, 45, 12, 345_000_000, time.UTC)
|
||||
|
||||
// TestNewTemplates checks that fresh records fail only on their markers and
|
||||
// become valid once those are replaced.
|
||||
func TestNewTemplates(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
kind, path string
|
||||
markers int
|
||||
}{
|
||||
{"adr", "docs/adr/20261023T204512345Z-use-x-y.md", 6},
|
||||
{"plan", "docs/plans/20261023T204512345Z-use-x-y.md", 3},
|
||||
} {
|
||||
t.Run(tt.kind, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path, err := New(root, tt.kind, `Use "X" & Y`, now)
|
||||
must(t, err)
|
||||
if path != tt.path {
|
||||
t.Fatalf("path = %s", path)
|
||||
}
|
||||
res, err := Check(root)
|
||||
must(t, err)
|
||||
if len(res.Problems) != tt.markers {
|
||||
t.Fatalf("got %v, want %d marker problems", res.Problems, tt.markers)
|
||||
}
|
||||
for _, p := range res.Problems {
|
||||
if !strings.Contains(p.Msg, marker) {
|
||||
t.Errorf("not a marker problem: %s", p)
|
||||
}
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, path))
|
||||
must(t, err)
|
||||
must(t, os.WriteFile(filepath.Join(root, path), []byte(strings.ReplaceAll(string(data), marker, "x")), 0o644))
|
||||
res, err = Check(root)
|
||||
must(t, err)
|
||||
expect(t, res.Problems, nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCollisions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
first, err := New(root, "adr", "Same", now)
|
||||
must(t, err)
|
||||
second, err := New(root, "adr", "Same", now)
|
||||
must(t, err)
|
||||
// The same stem in the other directory also advances the timestamp.
|
||||
third, err := New(root, "plan", "Same", now)
|
||||
must(t, err)
|
||||
want := []string{
|
||||
"docs/adr/20261023T204512345Z-same.md",
|
||||
"docs/adr/20261023T204512346Z-same.md",
|
||||
"docs/plans/20261023T204512347Z-same.md",
|
||||
}
|
||||
for i, got := range []string{first, second, third} {
|
||||
if got != want[i] {
|
||||
t.Errorf("file %d = %s, want %s", i, got, want[i])
|
||||
}
|
||||
}
|
||||
// Advancing across midnight keeps the date equal to the filename.
|
||||
root = t.TempDir()
|
||||
_, err = New(root, "adr", "Edge", time.Date(2026, 10, 23, 23, 59, 59, 999_000_000, time.UTC))
|
||||
must(t, err)
|
||||
got, err := New(root, "adr", "Edge", time.Date(2026, 10, 23, 23, 59, 59, 999_000_000, time.UTC))
|
||||
must(t, err)
|
||||
if got != "docs/adr/20261024T000000000Z-edge.md" {
|
||||
t.Fatalf("got %s", got)
|
||||
}
|
||||
res, err := Check(root)
|
||||
must(t, err)
|
||||
for _, p := range res.Problems {
|
||||
if !strings.Contains(p.Msg, marker) {
|
||||
t.Errorf("unexpected %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsBadTitles(t *testing.T) {
|
||||
for _, title := range []string{"", " ", "a\nb", "!!!", "Drop C #", "#", "C ##"} {
|
||||
if path, err := New(t.TempDir(), "adr", title, now); err == nil {
|
||||
t.Errorf("title %q created %s", title, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlugify(t *testing.T) {
|
||||
for in, want := range map[string]string{"Use X, Y & Z!": "use-x-y-z", " --Hello-- ": "hello", "Ünï": "n"} {
|
||||
if got := slugify(in); got != want {
|
||||
t.Errorf("slugify(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
if got := slugify(strings.Repeat("ab ", 40)); len(got) != maxSlug || strings.HasSuffix(got, "-") {
|
||||
t.Errorf("long slug = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package docs
|
||||
|
||||
var planRefs = []refSpec{{"tracking", kURL}, {"adrs", kAdr}, {"completion", kURL | kFile}}
|
||||
|
||||
func (c *checker) plan(r *record, m *fm) {
|
||||
m.keys("status", "tracking", "adrs", "completion")
|
||||
status, ok := m.enum("status", "draft", "active", "completed", "cancelled")
|
||||
m.refs(planRefs...)
|
||||
r.status = status
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
closed := status == "completed" || status == "cancelled"
|
||||
if status == "active" && len(r.lists["tracking"]) == 0 {
|
||||
m.problemf("status", "status active requires tracking")
|
||||
}
|
||||
if closed && len(r.lists["completion"]) == 0 {
|
||||
m.problemf("status", "status %s requires completion", status)
|
||||
}
|
||||
if !closed && len(r.lists["completion"]) > 0 {
|
||||
m.problemf("completion", "completion must be empty while status is %s", status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// kinds is the set of reference forms a frontmatter key accepts.
|
||||
type kinds uint8
|
||||
|
||||
const (
|
||||
kAdr kinds = 1 << iota
|
||||
kPlan
|
||||
kURL
|
||||
kFile
|
||||
)
|
||||
|
||||
type refSpec struct {
|
||||
key string
|
||||
kinds kinds
|
||||
}
|
||||
|
||||
var (
|
||||
schemeRe = regexp.MustCompile(`^([A-Za-z][A-Za-z0-9+.-]*):`)
|
||||
durableRe = regexp.MustCompile(`^([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)?#\d+$`)
|
||||
idPattern = `\d{8}T\d{9}Z-[a-z0-9]+(?:-[a-z0-9]+)*`
|
||||
adrPathRe = regexp.MustCompile(`^/docs/adr/` + idPattern + `\.md$`)
|
||||
planPathRe = regexp.MustCompile(`^/docs/plans/` + idPattern + `\.md$`)
|
||||
)
|
||||
|
||||
type ref struct {
|
||||
path string // decoded repo-root path; empty for URLs and anchors
|
||||
remote bool
|
||||
}
|
||||
|
||||
// parseRef checks the syntax of a reference. body allows #anchors, mailto: and #fragments.
|
||||
func parseRef(v string, body bool) (ref, string) {
|
||||
if v == "" {
|
||||
return ref{}, "empty reference"
|
||||
}
|
||||
if strings.Contains(v, `\`) {
|
||||
return ref{}, "backslashes are not allowed"
|
||||
}
|
||||
if body && v[0] == '#' {
|
||||
return ref{}, ""
|
||||
}
|
||||
if m := schemeRe.FindStringSubmatch(v); m != nil {
|
||||
switch strings.ToLower(m[1]) {
|
||||
case "http", "https":
|
||||
u, err := url.Parse(v)
|
||||
switch {
|
||||
case err != nil || u.Hostname() == "":
|
||||
return ref{}, "URL must have a host"
|
||||
case u.User != nil:
|
||||
return ref{}, "URL must not contain credentials"
|
||||
}
|
||||
return ref{remote: true}, ""
|
||||
case "mailto":
|
||||
if body {
|
||||
return ref{}, ""
|
||||
}
|
||||
}
|
||||
return ref{}, fmt.Sprintf("scheme %q is not allowed", m[1])
|
||||
}
|
||||
if v[0] != '/' {
|
||||
return ref{}, "relative paths are not allowed; use a repo-root path starting with /"
|
||||
}
|
||||
if strings.HasPrefix(v, "//") {
|
||||
return ref{}, "protocol-relative references are not allowed"
|
||||
}
|
||||
p, frag, hasFrag := strings.Cut(v, "#")
|
||||
if hasFrag && !body {
|
||||
return ref{}, "fragments are not allowed here (" + frag + ")"
|
||||
}
|
||||
if strings.Contains(p, "?") {
|
||||
return ref{}, "queries are not allowed"
|
||||
}
|
||||
dec, err := url.PathUnescape(p)
|
||||
if err != nil {
|
||||
return ref{}, "invalid percent-encoding"
|
||||
}
|
||||
if slices.Contains(strings.Split(dec, "/"), "..") || strings.Contains(dec, `\`) {
|
||||
return ref{}, `".." segments and backslashes are not allowed`
|
||||
}
|
||||
return ref{path: dec}, ""
|
||||
}
|
||||
|
||||
// exists reports why a repo-root path is not a regular file inside the repository.
|
||||
func (c *checker) exists(p string) string {
|
||||
// Match each component's case exactly, so case-insensitive filesystems agree with Linux.
|
||||
dir := c.root
|
||||
for _, part := range strings.Split(p, "/") {
|
||||
if part == "" || part == "." {
|
||||
continue
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil || !slices.ContainsFunc(entries, func(e os.DirEntry) bool { return e.Name() == part }) {
|
||||
return "target " + p + " does not exist"
|
||||
}
|
||||
dir = filepath.Join(dir, part)
|
||||
}
|
||||
real, err := filepath.EvalSymlinks(filepath.Join(c.root, filepath.FromSlash(p)))
|
||||
if err != nil {
|
||||
return "target " + p + " does not exist"
|
||||
}
|
||||
if !within(c.realRoot, real) {
|
||||
return "target " + p + " resolves outside the repository"
|
||||
}
|
||||
if st, err := os.Stat(real); err != nil || !st.Mode().IsRegular() {
|
||||
return "target " + p + " is not a regular file"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// checkLink validates a body link destination.
|
||||
func (c *checker) checkLink(r *record, line int, dest string) {
|
||||
ref, msg := parseRef(dest, true)
|
||||
if msg == "" && ref.path != "" {
|
||||
msg = c.exists(ref.path)
|
||||
}
|
||||
if msg != "" {
|
||||
c.add(r.path, line, "link %q: %s", dest, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// refs validates the reference lists of a record against their specs.
|
||||
func (m *fm) refs(specs ...refSpec) {
|
||||
self := "/" + m.r.path
|
||||
for _, spec := range specs {
|
||||
items, ok := m.list(spec.key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, it := range items {
|
||||
if it.v == marker {
|
||||
continue
|
||||
}
|
||||
if msg := m.checkRef(spec, it.v, self); msg != "" {
|
||||
m.c.add(m.r.path, it.line, "%s: %s", spec.key, msg)
|
||||
} else if seen[it.v] {
|
||||
m.c.add(m.r.path, it.line, "%s: duplicate reference %q", spec.key, it.v)
|
||||
}
|
||||
seen[it.v] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *fm) checkRef(spec refSpec, v, self string) string {
|
||||
if durableRe.MatchString(v) || slices.Contains([]string{"this pr", "this pull request", "this commit"}, strings.ToLower(v)) {
|
||||
return fmt.Sprintf("%q is not durable; use a full URL", v)
|
||||
}
|
||||
r, msg := parseRef(v, false)
|
||||
if msg != "" {
|
||||
return msg
|
||||
}
|
||||
k := spec.kinds
|
||||
switch {
|
||||
case r.remote:
|
||||
if k&kURL == 0 {
|
||||
return "URLs are not accepted here; accepts " + k.String()
|
||||
}
|
||||
return ""
|
||||
case k&kFile == 0 && !(k&kAdr != 0 && adrPathRe.MatchString(v)) && !(k&kPlan != 0 && planPathRe.MatchString(v)):
|
||||
return fmt.Sprintf("%q is not accepted here; accepts %s", v, k)
|
||||
case r.path == self:
|
||||
return "a record must not reference itself"
|
||||
}
|
||||
return m.c.exists(r.path)
|
||||
}
|
||||
|
||||
func (k kinds) String() string {
|
||||
var s []string
|
||||
for _, e := range []struct {
|
||||
k kinds
|
||||
s string
|
||||
}{{kAdr, "/docs/adr/<id>.md"}, {kPlan, "/docs/plans/<id>.md"}, {kURL, "http(s) URLs"}, {kFile, "repo-root file paths"}} {
|
||||
if k&e.k != 0 {
|
||||
s = append(s, e.s)
|
||||
}
|
||||
}
|
||||
return strings.Join(s, ", ")
|
||||
}
|
||||
|
||||
// within reports whether the real path p is root or lies under it.
|
||||
func within(root, p string) bool {
|
||||
rel, err := filepath.Rel(root, p)
|
||||
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||
}
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
---
|
||||
title: @TITLE_YAML@
|
||||
date: @DATE@
|
||||
status: proposed
|
||||
implementation: not-started
|
||||
summary: "{{docs:todo}}"
|
||||
tags:
|
||||
- "{{docs:todo}}"
|
||||
supersedes: []
|
||||
superseded_by: []
|
||||
related: []
|
||||
tracking: []
|
||||
implemented_by: []
|
||||
---
|
||||
|
||||
# @TITLE@
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
{{docs:todo}}
|
||||
|
||||
## Considered Options
|
||||
|
||||
{{docs:todo}}
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
{{docs:todo}}
|
||||
|
||||
### Consequences
|
||||
|
||||
{{docs:todo}}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
---
|
||||
status: draft
|
||||
tracking: []
|
||||
adrs: []
|
||||
completion: []
|
||||
---
|
||||
|
||||
# @TITLE@
|
||||
|
||||
## Scope
|
||||
|
||||
{{docs:todo}}
|
||||
|
||||
## Steps
|
||||
|
||||
{{docs:todo}}
|
||||
|
||||
## Validation
|
||||
|
||||
{{docs:todo}}
|
||||
@@ -0,0 +1,230 @@
|
||||
// Package cli runs caller-owned urfave/cli trees inside selected lifecycle modules.
|
||||
// It owns temporary signal subscriptions and exit classification, not process exit,
|
||||
// configuration, resource handles or unsupervised goroutines.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"slices"
|
||||
"syscall"
|
||||
|
||||
urfavecli "github.com/urfave/cli/v3"
|
||||
"go.auvem.com/corestack/lifecycle"
|
||||
)
|
||||
|
||||
// Run executes cliRoot with args including argv[0], returning 0 for success/help,
|
||||
// 2 for usage, and 1 for runtime failures. Lifecycle failures always take priority.
|
||||
// It prints a final diagnostic to cliRoot.ErrWriter and never exits the process.
|
||||
// Run reserves ExitErrHandler, OnUsageError and CommandNotFound throughout the
|
||||
// declared tree. Construct fresh trees for independent runs; do not mutate them
|
||||
// concurrently. Native generated-help parse and environment validation failures
|
||||
// may return 1 and print native diagnostics in addition to the final diagnostic.
|
||||
func Run(ctx context.Context, cliRoot *urfavecli.Command, args []string) (int, error) {
|
||||
var err error
|
||||
if ctx == nil || cliRoot == nil {
|
||||
err = errors.New("cli: context and root command are required")
|
||||
} else {
|
||||
err = validateTree(cliRoot)
|
||||
}
|
||||
if err == nil {
|
||||
var missing error
|
||||
installHooks(cliRoot, &missing)
|
||||
err = cliRoot.Run(ctx, args)
|
||||
if missing != nil {
|
||||
if err == nil {
|
||||
err = missing
|
||||
} else {
|
||||
err = errors.Join(err, missing)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
return 0, nil
|
||||
}
|
||||
code := classify(err, 0)
|
||||
err = expose(err)
|
||||
if cliRoot != nil && cliRoot.ErrWriter != nil {
|
||||
fmt.Fprintln(cliRoot.ErrWriter, err)
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
}
|
||||
return code, err
|
||||
}
|
||||
|
||||
func validateTree(cmd *urfavecli.Command) error {
|
||||
if cmd == nil {
|
||||
return errors.New("cli: nil command")
|
||||
}
|
||||
if cmd.ExitErrHandler != nil || cmd.OnUsageError != nil || cmd.CommandNotFound != nil {
|
||||
return fmt.Errorf("cli: command %q sets a reserved error hook", cmd.Name)
|
||||
}
|
||||
for _, child := range cmd.Commands {
|
||||
if err := validateTree(child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installHooks(cmd *urfavecli.Command, missing *error) {
|
||||
cmd.ExitErrHandler = func(context.Context, *urfavecli.Command, error) {}
|
||||
cmd.OnUsageError = func(_ context.Context, selected *urfavecli.Command, err error, _ bool) error {
|
||||
_ = urfavecli.ShowSubcommandHelp(selected)
|
||||
return &failure{err, 2}
|
||||
}
|
||||
cmd.CommandNotFound = func(_ context.Context, selected *urfavecli.Command, name string) {
|
||||
*missing = errors.Join(*missing, &failure{fmt.Errorf("unknown command or help topic %q", name), 2})
|
||||
_ = urfavecli.ShowSubcommandHelp(selected)
|
||||
}
|
||||
for _, child := range cmd.Commands {
|
||||
installHooks(child, missing)
|
||||
}
|
||||
}
|
||||
|
||||
// Serve returns a native action running exactly modules, including their services,
|
||||
// in registration order with reverse cleanup. Bindings capture declarations without
|
||||
// starting work; resources and declarations must not be mutated concurrently.
|
||||
// Nil config.Signals subscribes to SIGINT/SIGTERM for the invocation, buffered for
|
||||
// two signals; an injected channel and its subscription remain caller-owned.
|
||||
// The private invocation App is unavailable for managed-worker admission.
|
||||
func Serve(config lifecycle.Config, modules []lifecycle.Module) urfavecli.ActionFunc {
|
||||
return bind(config, modules, nil, false)
|
||||
}
|
||||
|
||||
// Action returns a native one-off action using exactly modules with their Run
|
||||
// callbacks suppressed. All setups precede fn; completion triggers reverse cleanup.
|
||||
// fn receives lifecycle's signal-aware context and the selected native command.
|
||||
// Nil fn is a runtime configuration error. Signal and ownership rules match Serve;
|
||||
// hooks outside this action are outside the lifecycle shutdown guarantee.
|
||||
func Action(config lifecycle.Config, modules []lifecycle.Module, fn urfavecli.ActionFunc) urfavecli.ActionFunc {
|
||||
return bind(config, modules, fn, true)
|
||||
}
|
||||
|
||||
func bind(config lifecycle.Config, modules []lifecycle.Module, fn urfavecli.ActionFunc, oneOff bool) urfavecli.ActionFunc {
|
||||
modules = slices.Clone(modules)
|
||||
return func(ctx context.Context, cmd *urfavecli.Command) error {
|
||||
err := execute(ctx, cmd, config, modules, fn, oneOff)
|
||||
if err != nil {
|
||||
return &failure{err, 1}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func execute(ctx context.Context, cmd *urfavecli.Command, config lifecycle.Config, modules []lifecycle.Module, fn urfavecli.ActionFunc, oneOff bool) error {
|
||||
if oneOff && fn == nil {
|
||||
return errors.New("cli: one-off function is required")
|
||||
}
|
||||
var signals chan os.Signal
|
||||
if config.Signals == nil {
|
||||
signals = make(chan os.Signal, 2)
|
||||
config.Signals = signals
|
||||
}
|
||||
app, err := lifecycle.New(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names := make(map[string]bool, len(modules))
|
||||
for _, module := range modules {
|
||||
names[module.Name] = true
|
||||
if oneOff {
|
||||
module.Run = nil
|
||||
}
|
||||
if err := app.Register(module); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if oneOff {
|
||||
name := "cli-action"
|
||||
for names[name] {
|
||||
name += "-"
|
||||
}
|
||||
noop := func(context.Context) error { return nil }
|
||||
if err := app.Register(lifecycle.Module{Name: name, Setup: noop, Teardown: noop,
|
||||
Run: func(ctx context.Context) error { return fn(ctx, cmd) }}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if signals != nil {
|
||||
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer signal.Stop(signals)
|
||||
}
|
||||
return app.Run(ctx)
|
||||
}
|
||||
|
||||
type failure struct {
|
||||
error
|
||||
code int
|
||||
}
|
||||
|
||||
func (e *failure) Unwrap() error { return e.error }
|
||||
|
||||
func children(err error) []error {
|
||||
switch e := err.(type) {
|
||||
case interface{ Unwrap() []error }:
|
||||
return e.Unwrap()
|
||||
case interface{ Unwrap() error }:
|
||||
if child := e.Unwrap(); child != nil {
|
||||
return []error{child}
|
||||
}
|
||||
case urfavecli.MultiError:
|
||||
return e.Errors()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// invariant: runtime branches dominate usage; aggregate containers add no failure.
|
||||
func classify(err error, inherited int) int {
|
||||
if e, ok := err.(*failure); ok {
|
||||
if e.code == 1 {
|
||||
return 1
|
||||
}
|
||||
inherited = e.code
|
||||
} else if e, ok := err.(urfavecli.ExitCoder); ok && inherited == 0 {
|
||||
inherited = 1
|
||||
if e.ExitCode() == 2 {
|
||||
inherited = 2
|
||||
}
|
||||
}
|
||||
code := 0
|
||||
for _, child := range children(err) {
|
||||
if child == nil {
|
||||
continue
|
||||
}
|
||||
next := classify(child, inherited)
|
||||
if next == 1 {
|
||||
return 1
|
||||
}
|
||||
code = next
|
||||
}
|
||||
if code != 0 {
|
||||
return code
|
||||
}
|
||||
if inherited != 0 {
|
||||
return inherited
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// exposed retains native identity and formatting while making opaque MultiError
|
||||
// descendants reachable through standard errors.Is and errors.As traversal.
|
||||
type exposed struct {
|
||||
error
|
||||
causes []error
|
||||
}
|
||||
|
||||
func (e *exposed) Unwrap() []error { return e.causes }
|
||||
|
||||
func expose(err error) error {
|
||||
causes := []error{err}
|
||||
for _, child := range children(err) {
|
||||
if child != nil {
|
||||
causes = append(causes, expose(child))
|
||||
}
|
||||
}
|
||||
return &exposed{err, causes}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
urfavecli "github.com/urfave/cli/v3"
|
||||
"go.auvem.com/corestack/lifecycle"
|
||||
)
|
||||
|
||||
func config() lifecycle.Config {
|
||||
return lifecycle.Config{Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Signals: make(chan os.Signal, 2), ShutdownTimeout: time.Second}
|
||||
}
|
||||
|
||||
func command(action urfavecli.ActionFunc) *urfavecli.Command {
|
||||
return &urfavecli.Command{Name: "app", Writer: io.Discard, ErrWriter: io.Discard, Commands: []*urfavecli.Command{{Name: "inspect", Action: action, Flags: []urfavecli.Flag{&urfavecli.BoolFlag{Name: "verbose"}}}}}
|
||||
}
|
||||
|
||||
func TestNativeLifecycleFlow(t *testing.T) {
|
||||
for _, fail := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint(fail), func(t *testing.T) {
|
||||
var order []string
|
||||
boom := &os.PathError{Op: "inspect", Path: "db", Err: io.ErrUnexpectedEOF}
|
||||
var selected []lifecycle.Module
|
||||
for _, name := range []string{"cli-action", "cli-action-", "database"} {
|
||||
selected = append(selected, lifecycle.Module{Name: name,
|
||||
Setup: func(context.Context) error { order = append(order, "setup "+name); return nil },
|
||||
Run: func(context.Context) error { t.Error("one-off service started"); return nil },
|
||||
Teardown: func(context.Context) error { order = append(order, "close "+name); return nil }})
|
||||
}
|
||||
cliRoot := command(Action(config(), selected, func(_ context.Context, cmd *urfavecli.Command) error {
|
||||
if !cmd.Bool("verbose") {
|
||||
t.Error("native flag missing")
|
||||
}
|
||||
order = append(order, "inspect")
|
||||
if fail {
|
||||
return boom
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
cliRoot.Commands = append(cliRoot.Commands, &urfavecli.Command{Name: "unused", Action: Serve(config(), []lifecycle.Module{{Name: "unused", Setup: func(context.Context) error { t.Error("unselected setup"); return nil }, Teardown: func(context.Context) error { return nil }}})})
|
||||
code, err := Run(context.Background(), cliRoot, []string{"app", "inspect", "--verbose"})
|
||||
if fail && (code != 1 || !errors.Is(err, boom)) || !fail && (code != 0 || err != nil) {
|
||||
t.Fatalf("Run = %d, %v", code, err)
|
||||
}
|
||||
want := []string{"setup cli-action", "setup cli-action-", "setup database", "inspect", "close database", "close cli-action-", "close cli-action"}
|
||||
if !reflect.DeepEqual(order, want) {
|
||||
t.Fatalf("order %v", order)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeUsageAndHelp(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
args string
|
||||
code int
|
||||
}{
|
||||
{"", 0}, {"--help", 0}, {"inspect --help", 0}, {"help inspect", 0}, {"group", 0}, {"group --help", 0},
|
||||
{"missing", 2}, {"help missing", 2}, {"group missing", 2}, {"group help missing", 2},
|
||||
{"--bad", 2}, {"inspect --bad", 2}, {"inspect --count", 2}, {"inspect --count nope", 2}, {"inspect", 2},
|
||||
{"help --bad", 1}, {"h --bad", 1}, {"group help --bad", 1}, {"group h --bad", 1},
|
||||
} {
|
||||
t.Run(tc.args, func(t *testing.T) {
|
||||
cliRoot := command(Action(config(), []lifecycle.Module{{Name: "db", Setup: func(context.Context) error { t.Error("setup during usage/help"); return nil }, Teardown: func(context.Context) error { return nil }}}, func(context.Context, *urfavecli.Command) error { t.Error("action during usage/help"); return nil }))
|
||||
cliRoot.Commands[0].Flags = append(cliRoot.Commands[0].Flags, &urfavecli.IntFlag{Name: "count", Required: true})
|
||||
cliRoot.Commands = append(cliRoot.Commands, &urfavecli.Command{Name: "group", Commands: []*urfavecli.Command{{Name: "child"}}})
|
||||
var output, diagnostic bytes.Buffer
|
||||
cliRoot.Writer = &output
|
||||
cliRoot.ErrWriter = &diagnostic
|
||||
code, err := Run(context.Background(), cliRoot, append([]string{"app"}, strings.Fields(tc.args)...))
|
||||
if code != tc.code || (err != nil) != (tc.code != 0) {
|
||||
t.Fatalf("Run=%d,%v; want %d", code, err, tc.code)
|
||||
}
|
||||
if output.Len() == 0 && tc.code != 1 {
|
||||
t.Error("missing native help")
|
||||
}
|
||||
if (diagnostic.Len() > 0) != (tc.code != 0) {
|
||||
t.Fatalf("diagnostic %q", diagnostic.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeValidation(t *testing.T) {
|
||||
for _, kind := range []string{"environment", "validator", "validator-exit", "flag-action", "required-argument", "argument-parse", "exclusive"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
cliRoot := command(func(context.Context, *urfavecli.Command) error {
|
||||
t.Error("invalid invocation reached action")
|
||||
return nil
|
||||
})
|
||||
cmd := cliRoot.Commands[0]
|
||||
args := []string{"app", "inspect"}
|
||||
want := 1
|
||||
switch kind {
|
||||
case "environment":
|
||||
t.Setenv("CORESTACK_CLI_COUNT", "oops")
|
||||
cmd.Flags = []urfavecli.Flag{&urfavecli.IntFlag{Name: "count", Sources: urfavecli.EnvVars("CORESTACK_CLI_COUNT")}}
|
||||
case "validator", "validator-exit":
|
||||
cmd.ArgValidator = func(context.Context, *urfavecli.Command) error {
|
||||
if kind == "validator-exit" {
|
||||
return urfavecli.Exit("invalid", 2)
|
||||
}
|
||||
return errors.New("invalid")
|
||||
}
|
||||
if kind == "validator-exit" {
|
||||
want = 2
|
||||
}
|
||||
case "flag-action":
|
||||
cmd.Flags = []urfavecli.Flag{&urfavecli.BoolFlag{Name: "bad", Action: func(context.Context, *urfavecli.Command, bool) error { return errors.New("invalid") }}}
|
||||
args = append(args, "--bad")
|
||||
case "required-argument":
|
||||
cmd.Arguments = []urfavecli.Argument{&urfavecli.StringArg{Name: "target", Required: true}}
|
||||
want = 2
|
||||
case "argument-parse":
|
||||
cmd.Arguments = []urfavecli.Argument{&urfavecli.IntArg{Name: "count"}}
|
||||
args = append(args, "oops")
|
||||
want = 2
|
||||
case "exclusive":
|
||||
cmd.MutuallyExclusiveFlags = []urfavecli.MutuallyExclusiveFlags{{Flags: [][]urfavecli.Flag{{&urfavecli.BoolFlag{Name: "a"}}, {&urfavecli.BoolFlag{Name: "b"}}}}}
|
||||
args = append(args, "--a", "--b")
|
||||
want = 2
|
||||
}
|
||||
code, err := Run(context.Background(), cliRoot, args)
|
||||
if code != want || err == nil {
|
||||
t.Fatalf("Run=%d,%v; want %d", code, err, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureCategoriesAndCauses(t *testing.T) {
|
||||
for _, kind := range []string{"native-exit2", "native-after", "native-exit0", "native-exit9", "action-exit2", "teardown", "setup", "action-after", "usage-after", "nested-after", "unknown-after"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
cause := &os.PathError{Op: "read", Path: "db", Err: io.ErrUnexpectedEOF}
|
||||
after := errors.New("after")
|
||||
parent := errors.New("parent after")
|
||||
original := urfavecli.Exit(cause, 2)
|
||||
cfg := config()
|
||||
modules := []lifecycle.Module{{Name: "db", Setup: func(context.Context) error { return nil }, Teardown: func(context.Context) error { return nil }}}
|
||||
action := func(context.Context, *urfavecli.Command) error { return original }
|
||||
want := 1
|
||||
args := []string{"app", "inspect"}
|
||||
switch kind {
|
||||
case "native-exit2":
|
||||
want = 2
|
||||
case "native-exit0":
|
||||
original = urfavecli.Exit(cause, 0)
|
||||
case "native-exit9":
|
||||
original = urfavecli.Exit(cause, 9)
|
||||
case "setup":
|
||||
modules[0].Setup = func(context.Context) error { return cause }
|
||||
case "teardown":
|
||||
modules[0].Teardown = func(context.Context) error { return after }
|
||||
}
|
||||
cliRoot := command(action)
|
||||
if !strings.HasPrefix(kind, "native-") {
|
||||
cliRoot.Commands[0].Action = Action(cfg, modules, action)
|
||||
}
|
||||
if kind == "native-after" || kind == "action-after" || kind == "nested-after" || kind == "usage-after" {
|
||||
cliRoot.Commands[0].After = func(context.Context, *urfavecli.Command) error { return after }
|
||||
}
|
||||
if kind == "nested-after" {
|
||||
cliRoot.After = func(context.Context, *urfavecli.Command) error { return parent }
|
||||
}
|
||||
if kind == "usage-after" {
|
||||
cliRoot.Commands[0].Flags = append(cliRoot.Commands[0].Flags, &urfavecli.StringFlag{Name: "needed", Required: true})
|
||||
}
|
||||
if kind == "unknown-after" {
|
||||
args = []string{"app", "help", "missing"}
|
||||
cliRoot.After = func(context.Context, *urfavecli.Command) error { return after }
|
||||
}
|
||||
code, err := Run(context.Background(), cliRoot, args)
|
||||
if code != want || err == nil {
|
||||
t.Fatalf("Run=%d,%v", code, err)
|
||||
}
|
||||
if kind != "usage-after" && kind != "unknown-after" {
|
||||
var path *os.PathError
|
||||
if !errors.Is(err, cause) || !errors.As(err, &path) || path != cause || !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("cause lost: %v", err)
|
||||
}
|
||||
if kind != "setup" && !errors.Is(err, original) {
|
||||
t.Fatalf("original identity lost: %v", err)
|
||||
}
|
||||
}
|
||||
if kind == "teardown" || strings.HasSuffix(kind, "-after") {
|
||||
if !errors.Is(err, after) {
|
||||
t.Fatalf("After/cleanup lost: %v", err)
|
||||
}
|
||||
}
|
||||
if kind == "nested-after" && !errors.Is(err, parent) {
|
||||
t.Fatal("parent cause lost")
|
||||
}
|
||||
if kind == "native-after" || kind == "action-after" || kind == "nested-after" || kind == "usage-after" {
|
||||
var multi urfavecli.MultiError
|
||||
if !errors.As(err, &multi) || !errors.Is(err, multi) {
|
||||
t.Fatalf("native aggregate lost: %v", err)
|
||||
}
|
||||
if err.Error() != multi.Error() {
|
||||
t.Fatalf("duplicated formatting: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationBeforeSetup(t *testing.T) {
|
||||
for _, kind := range []string{"nil-context", "nil-root", "nil-function", "nil-logger", "negative-timeout", "invalid-module", "duplicate-module", "nil-child", "exit-hook", "usage-hook", "not-found-hook"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
cfg := config()
|
||||
noop := func(context.Context) error { return nil }
|
||||
modules := []lifecycle.Module{{Name: "db", Setup: func(context.Context) error { t.Error("setup before validation"); return nil }, Teardown: noop}}
|
||||
var ctx context.Context = context.Background()
|
||||
fn := urfavecli.ActionFunc(func(context.Context, *urfavecli.Command) error { t.Error("action before validation"); return nil })
|
||||
switch kind {
|
||||
case "nil-context":
|
||||
ctx = nil
|
||||
case "nil-function":
|
||||
fn = nil
|
||||
case "nil-logger":
|
||||
cfg.Logger = nil
|
||||
case "negative-timeout":
|
||||
cfg.ShutdownTimeout = -1
|
||||
case "invalid-module":
|
||||
modules = append(modules, lifecycle.Module{Name: "invalid"})
|
||||
case "duplicate-module":
|
||||
modules = append(modules, modules[0])
|
||||
}
|
||||
cliRoot := command(Action(cfg, modules, fn))
|
||||
switch kind {
|
||||
case "nil-root":
|
||||
cliRoot = nil
|
||||
case "nil-child":
|
||||
cliRoot.Commands = append(cliRoot.Commands, nil)
|
||||
case "exit-hook":
|
||||
cliRoot.Commands[0].ExitErrHandler = func(context.Context, *urfavecli.Command, error) {}
|
||||
case "usage-hook":
|
||||
cliRoot.Commands[0].OnUsageError = func(context.Context, *urfavecli.Command, error, bool) error { return nil }
|
||||
case "not-found-hook":
|
||||
cliRoot.Commands[0].CommandNotFound = func(context.Context, *urfavecli.Command, string) {}
|
||||
}
|
||||
code, err := Run(ctx, cliRoot, []string{"app", "inspect"})
|
||||
if code != 1 || err == nil {
|
||||
t.Fatalf("Run=%d,%v", code, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func await[T any](t *testing.T, ch <-chan T) T {
|
||||
t.Helper()
|
||||
select {
|
||||
case value := <-ch:
|
||||
return value
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("callback blocked")
|
||||
var zero T
|
||||
return zero
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalsAndParent(t *testing.T) {
|
||||
for _, mode := range []string{"first", "second", "parent", "serve"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
signals := make(chan os.Signal, 2)
|
||||
cfg := config()
|
||||
cfg.Signals = signals
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ready := make(chan struct{})
|
||||
canceled := make(chan struct{})
|
||||
exited := make(chan struct{})
|
||||
cleanup := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
defer func() { close(release); await(t, exited) }()
|
||||
callback := func(ctx context.Context) error {
|
||||
close(ready)
|
||||
defer close(exited)
|
||||
<-ctx.Done()
|
||||
close(canceled)
|
||||
if mode == "second" {
|
||||
<-release
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
modules := []lifecycle.Module{{Name: "db", Setup: func(context.Context) error { return nil }, Teardown: func(context.Context) error { close(cleanup); return nil }, Run: callback}}
|
||||
action := Action(cfg, modules, func(ctx context.Context, _ *urfavecli.Command) error { return callback(ctx) })
|
||||
if mode == "serve" {
|
||||
action = Serve(cfg, modules)
|
||||
}
|
||||
done := make(chan struct {
|
||||
code int
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
code, err := Run(ctx, command(action), []string{"app", "inspect"})
|
||||
done <- struct {
|
||||
code int
|
||||
err error
|
||||
}{code, err}
|
||||
}()
|
||||
await(t, ready)
|
||||
if mode == "parent" {
|
||||
cancel()
|
||||
} else {
|
||||
signals <- syscall.SIGINT
|
||||
}
|
||||
await(t, canceled)
|
||||
await(t, cleanup)
|
||||
if mode == "second" {
|
||||
signals <- syscall.SIGTERM
|
||||
}
|
||||
result := await(t, done)
|
||||
want := 0
|
||||
if mode == "second" || mode == "parent" {
|
||||
want = 1
|
||||
}
|
||||
if result.code != want || (result.err != nil) != (want != 0) {
|
||||
t.Fatalf("Run=%d,%v", result.code, result.err)
|
||||
}
|
||||
if want == 1 && !errors.Is(result.err, context.Canceled) {
|
||||
t.Fatalf("cancellation lost: %v", result.err)
|
||||
}
|
||||
// The adapter must leave the injected channel open and reusable.
|
||||
signals <- syscall.SIGTERM
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedShutdownDeadline(t *testing.T) {
|
||||
cfg := config()
|
||||
cfg.ShutdownTimeout = 100 * time.Millisecond
|
||||
ready := make(chan struct{})
|
||||
shutdown := make(chan time.Time, 1)
|
||||
exited := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
defer func() { close(release); await(t, exited) }()
|
||||
var first time.Time
|
||||
noop := func(context.Context) error { return nil }
|
||||
modules := []lifecycle.Module{
|
||||
{Name: "blocked", Setup: noop, Teardown: func(ctx context.Context) error {
|
||||
defer close(exited)
|
||||
deadline, _ := ctx.Deadline()
|
||||
shutdown <- deadline
|
||||
<-release
|
||||
return nil
|
||||
}},
|
||||
{Name: "first", Setup: noop, Teardown: func(ctx context.Context) error {
|
||||
first, _ = ctx.Deadline()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
return nil
|
||||
}},
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := Run(context.Background(), command(Action(cfg, modules, func(context.Context, *urfavecli.Command) error { close(ready); return nil })), []string{"app", "inspect"})
|
||||
done <- err
|
||||
}()
|
||||
await(t, ready)
|
||||
deadline := await(t, shutdown)
|
||||
if !first.Equal(deadline) {
|
||||
t.Fatal("shutdown deadline reset between hooks")
|
||||
}
|
||||
if err := await(t, done); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("deadline lost: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptySelectionsAndOwnedSignals(t *testing.T) {
|
||||
for _, oneOff := range []bool{false, true} {
|
||||
cfg := config()
|
||||
cfg.Signals = nil
|
||||
action := Serve(cfg, nil)
|
||||
if oneOff {
|
||||
action = Action(cfg, nil, func(context.Context, *urfavecli.Command) error { return nil })
|
||||
}
|
||||
if code, err := Run(context.Background(), command(action), []string{"app", "inspect"}); code != 0 || err != nil {
|
||||
t.Fatalf("Run=%d,%v", code, err)
|
||||
}
|
||||
}
|
||||
cfg := config()
|
||||
signals := make(chan os.Signal)
|
||||
close(signals)
|
||||
cfg.Signals = signals
|
||||
if code, err := Run(context.Background(), command(Serve(cfg, nil)), []string{"app", "inspect"}); code != 0 || err != nil {
|
||||
t.Fatalf("closed signals: %d,%v", code, err)
|
||||
}
|
||||
}
|
||||
func TestPartialSetupAndMixedCleanup(t *testing.T) {
|
||||
for _, setupFailure := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint(setupFailure), func(t *testing.T) {
|
||||
initiating := urfavecli.Exit(io.ErrUnexpectedEOF, 2)
|
||||
cleanup := errors.New("cleanup")
|
||||
var order []string
|
||||
modules := []lifecycle.Module{
|
||||
{Name: "first", Setup: func(context.Context) error { order = append(order, "first setup"); return nil }, Teardown: func(context.Context) error { order = append(order, "first cleanup"); return cleanup }},
|
||||
{Name: "second", Setup: func(context.Context) error {
|
||||
order = append(order, "second setup")
|
||||
if setupFailure {
|
||||
return initiating
|
||||
}
|
||||
return nil
|
||||
}, Teardown: func(context.Context) error { order = append(order, "second cleanup"); return nil }},
|
||||
}
|
||||
cliRoot := command(Action(config(), modules, func(context.Context, *urfavecli.Command) error { order = append(order, "action"); return initiating }))
|
||||
code, err := Run(context.Background(), cliRoot, []string{"app", "inspect"})
|
||||
if code != 1 || !errors.Is(err, initiating) || !errors.Is(err, cleanup) || !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("Run=%d,%v", code, err)
|
||||
}
|
||||
want := []string{"first setup", "second setup", "first cleanup"}
|
||||
if !setupFailure {
|
||||
want = []string{"first setup", "second setup", "action", "second cleanup", "first cleanup"}
|
||||
}
|
||||
if !reflect.DeepEqual(order, want) {
|
||||
t.Fatalf("order %v", order)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
|
||||
urfavecli "github.com/urfave/cli/v3"
|
||||
"go.auvem.com/corestack/lifecycle"
|
||||
"go.auvem.com/corestack/lifecycle/cli"
|
||||
)
|
||||
|
||||
func Example() {
|
||||
cfg := lifecycle.Config{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
for _, args := range [][]string{{"app", "serve"}, {"app", "inspect", "--verbose"}} {
|
||||
var database string
|
||||
db := lifecycle.Module{Name: "database",
|
||||
Setup: func(context.Context) error { database = "connected"; fmt.Println("open database"); return nil },
|
||||
Teardown: func(context.Context) error { fmt.Println("close database"); database = ""; return nil },
|
||||
}
|
||||
server := lifecycle.Module{Name: "server",
|
||||
Setup: func(context.Context) error { fmt.Println("open server"); return nil },
|
||||
// A real server blocks until cancellation; this finite service keeps the example executable.
|
||||
Run: func(context.Context) error { fmt.Println("serve:", database); return nil },
|
||||
Teardown: func(context.Context) error { fmt.Println("close server"); return nil },
|
||||
}
|
||||
cliRoot := &urfavecli.Command{Name: "app", Commands: []*urfavecli.Command{
|
||||
{Name: "serve", Action: cli.Serve(cfg, []lifecycle.Module{db, server})},
|
||||
{Name: "inspect", Flags: []urfavecli.Flag{&urfavecli.BoolFlag{Name: "verbose"}},
|
||||
Action: cli.Action(cfg, []lifecycle.Module{db}, func(_ context.Context, cmd *urfavecli.Command) error {
|
||||
fmt.Println("inspect:", database, "verbose:", cmd.Bool("verbose"))
|
||||
return nil
|
||||
})},
|
||||
}}
|
||||
code, err := cli.Run(context.Background(), cliRoot, args)
|
||||
fmt.Println("exit:", code, err)
|
||||
// main may call os.Exit(code) here, after adapter cleanup.
|
||||
}
|
||||
// Output:
|
||||
// open database
|
||||
// open server
|
||||
// serve: connected
|
||||
// close server
|
||||
// close database
|
||||
// exit: 0 <nil>
|
||||
// open database
|
||||
// inspect: connected verbose: true
|
||||
// close database
|
||||
// exit: 0 <nil>
|
||||
}
|
||||
@@ -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: <nil>
|
||||
}
|
||||
@@ -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)...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
# migrate
|
||||
|
||||
`go.auvem.com/corestack/migrate` sets the house configuration for
|
||||
[Goose's Provider API](https://pressly.github.io/goose/documentation/provider/).
|
||||
It returns `*goose.Provider` directly: Goose owns execution, version tracking,
|
||||
status, and errors. There is no lifecycle dependency or background work.
|
||||
|
||||
Use the standalone `corestack-goose` development tool for SQL and Go creation
|
||||
and timestamp-to-sequential reconciliation. Its generated Go migrations work
|
||||
unchanged with this package's provider-local registration:
|
||||
|
||||
```sh
|
||||
go install go.auvem.com/corestack/cmd/corestack-goose@<release>
|
||||
mkdir -p migrations
|
||||
cd migrations
|
||||
corestack-goose create create_events sql
|
||||
corestack-goose fix
|
||||
corestack-goose create populate_events go
|
||||
corestack-goose fix
|
||||
# Or use the version selected by your consumer module:
|
||||
go run go.auvem.com/corestack/cmd/corestack-goose create add_index sql
|
||||
```
|
||||
|
||||
Both commands default to the current working directory; use `--dir <dir>` to
|
||||
select another existing directory. Paths must have no symlink components (on
|
||||
macOS use `/private/tmp`, rather than `/tmp`), and migration files must be regular.
|
||||
Names are lowercase ASCII words separated by underscores. Templates are stock
|
||||
Goose SQL or transactional Go. The Go suffix `<version>_<name>_migration.go`
|
||||
keeps names such as `test`, `create_test` and `windows` buildable on every platform.
|
||||
Edit up/down bodies; leave factories and generated `register.go` intact.
|
||||
Its `GoMigrations()` returns fresh registrations, even for SQL-only sets.
|
||||
|
||||
Versions must be unique across SQL and Go. Goose timestamps have second
|
||||
resolution: fix each new migration before creating another in the same second,
|
||||
or wait for a distinct timestamp. `fix` preserves sequential IDs and migration
|
||||
bodies, allocates timestamps after the highest sequential ID with at least
|
||||
five-digit padding, and updates filenames and registration literals together.
|
||||
Preflight rejects unsafe paths, collisions, unsupported Go layouts, edited
|
||||
aggregates, duplicate versions/factories, overflow and lexical/numeric order
|
||||
disagreement. Stock Goose Go output uses an incompatible global `init` registry.
|
||||
For concepts, see Goose's [hybrid versioning](https://github.com/pressly/goose#hybrid-versioning)
|
||||
and [SQL annotations](https://pressly.github.io/goose/documentation/annotations/).
|
||||
|
||||
Before `fix`, commit or separately back up the **complete source set**, including
|
||||
untracked migrations and tracked edits. Use one authoring process without concurrent
|
||||
editor changes. New destinations are exclusive; staged destinations are fully
|
||||
published before old sources are removed. This is **not an atomic multi-file
|
||||
operation**: interruption can leave duplicates, aggregate drift or staging files,
|
||||
with no automatic rollback or journal. On failure inspect the reported completed,
|
||||
partial/current and outstanding paths, then restore the complete source set and
|
||||
aggregate from backup before retrying. Ordinary Git restore cannot recover
|
||||
untracked files or unsaved edits.
|
||||
|
||||
Source reconciliation never rewrites database history. Use it before those IDs
|
||||
reach a shared database; preserve deployed IDs and add migrations afterward. The
|
||||
tool cannot determine deployment history.
|
||||
|
||||
The standalone tool needs no database or build tags. Production imports `migrate`
|
||||
and optionally `migrate/lifecycle`; neither imports authoring code. Generated
|
||||
migrations import only Goose and the standard library. Artifact tests verify the
|
||||
runtime dependency and executable boundary for the tested build.
|
||||
|
||||
Embed migrations in the application's binary and run them before serving traffic:
|
||||
|
||||
```go
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"log/slog"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
"go.auvem.com/corestack/migrate"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrations embed.FS
|
||||
|
||||
func migrateDatabase(ctx context.Context, db *sql.DB, logger *slog.Logger) error {
|
||||
provider, err := migrate.New(db, goose.DialectPostgres, migrations, "migrations", logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = provider.Up(ctx)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
The caller opens and closes `db`, selects a matching database driver and dialect,
|
||||
supplies a nonnil logger, and controls the operation context/deadline. Construction
|
||||
is offline; SQL is parsed lazily by Goose. `Provider.Close` also closes `db`, so do
|
||||
not defer it when sharing the pool with the application. Use `"."` for SQL files
|
||||
at the embedded root. Files in the selected directory are discovered directly,
|
||||
without recursively searching subdirectories.
|
||||
|
||||
Preserve the tool's creator/fix filenames and ship the complete migration history.
|
||||
The pinned Goose SQL-only provider relies on lexical filename order matching
|
||||
numeric version order. Timestamp versions and gaps are supported; never derive
|
||||
versions from file counts. For operations and errors, use the
|
||||
[Provider API](https://pkg.go.dev/github.com/pressly/goose/v3#Provider).
|
||||
|
||||
For generated Go functions, import the consumer's migration package and pass
|
||||
`migrations.GoMigrations()...` as the trailing arguments to `migrate.New`.
|
||||
Embed the complete directory with `//go:embed migrations` (and use a different
|
||||
variable name, such as `migrationFiles`, from the imported package). The directory
|
||||
must exist even for Go-only sets. Global registration is disabled; never share
|
||||
mutable registration objects across providers. See Goose's
|
||||
[provider-local registration](https://pressly.github.io/goose/documentation/provider/#withgomigrations).
|
||||
|
||||
## Lifecycle startup and native commands
|
||||
|
||||
`go.auvem.com/corestack/migrate/lifecycle` is a thin adapter; `migrate` itself has
|
||||
no lifecycle dependency. Alias it `migratelifecycle` to distinguish the core
|
||||
lifecycle package. Its resolver runs after the caller's database setup and should
|
||||
construct a fresh provider with fresh registrations:
|
||||
|
||||
```go
|
||||
resolve := func(ctx context.Context) (*goose.Provider, error) {
|
||||
return migrate.New(db, goose.DialectPostgres, migrationFiles,
|
||||
"migrations", logger, migrations.GoMigrations()...)
|
||||
}
|
||||
startup, err := migratelifecycle.Module("migrations", migratelifecycle.Require, resolve)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root := &urfavecli.Command{Name: "app", Commands: []*urfavecli.Command{
|
||||
{Name: "serve", Action: cli.Serve(cfg, []lifecycle.Module{database, startup, server})},
|
||||
migratelifecycle.Commands(cfg, []lifecycle.Module{database}, resolve),
|
||||
}}
|
||||
code, err := cli.Run(ctx, root, args) // args includes argv[0]; caller owns process exit.
|
||||
```
|
||||
|
||||
Here `database.Setup` opens `db` and cleans up its partial acquisition on failure;
|
||||
`database.Teardown` closes it. Construct the tree and captured resource bindings
|
||||
fresh for independent invocations. Declaration does no work. Select `Apply` to run
|
||||
Up during setup, or `Require` to fail before later setup/services when migrations
|
||||
are pending. Require is a point-in-time check without a deployment lock. It and
|
||||
status may initialize Goose's version table; neither promises read-only DB access.
|
||||
The migration module never closes the provider or shared database.
|
||||
|
||||
The native group supplies `migrate up`, `up-to <version>` (inclusive), `down`
|
||||
(one migration), `down-to <version>` and `status` (version/state/source table).
|
||||
Pass exactly the database modules, excluding the startup migration module and
|
||||
unrelated modules. Services are suppressed; the existing `lifecycle/cli.Action`
|
||||
owns signals, cancellation, errors and reverse cleanup. Help and invalid arguments
|
||||
never run setup. Target versions must be nonnegative decimal int64 values, with
|
||||
no extra arguments; malformed usage returns exit 2 through `cli.Run`. Zero follows
|
||||
Goose: `down-to 0` rolls everything back; `up-to 0` succeeds if nothing is pending,
|
||||
but returns a runtime error when pending work reaches Goose's version validation.
|
||||
Resolver/Goose/cleanup and status Writer failures return runtime errors (exit 1).
|
||||
Supply the native command Writer and configured Goose logger for output.
|
||||
|
||||
Postgres providers automatically use Goose's default session advisory locker.
|
||||
Concurrent startup calls from separate providers/processes serialize migration
|
||||
execution and recheck applied versions under the lock. The default lock ID
|
||||
serializes Goose migrations throughout the same database, including different
|
||||
schemas. Use a direct connection or a session-pooling proxy: transaction pooling
|
||||
cannot preserve session locks.
|
||||
`RunDB` Go migrations need spare pool connections while the locked session is
|
||||
held; avoid a one-connection pool for them.
|
||||
|
||||
Other dialects have no cross-provider/process lock configured by this package.
|
||||
Serialize migration runs externally, for example with a single deployment job.
|
||||
SQLite tests validate the SQL convention, not concurrent daemon startup safety.
|
||||
|
||||
Run the integration suite against an existing Postgres database with schema
|
||||
creation privileges:
|
||||
|
||||
```sh
|
||||
MIGRATE_POSTGRES_DSN='postgres://user:password@localhost/testdb?sslmode=disable' go test -race ./migrate
|
||||
```
|
||||
|
||||
Tests create and clean only random schemas they own. Without a DSN, Postgres
|
||||
tests skip locally; in CI a missing DSN fails, and the CI job supplies a Postgres
|
||||
service. SQLite tests always run in process using temporary databases.
|
||||
@@ -0,0 +1,146 @@
|
||||
// Package lifecycle adapts Goose providers to startup policy and native commands.
|
||||
// Callers own database modules and supply fresh, late-bound providers.
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
urfavecli "github.com/urfave/cli/v3"
|
||||
"go.auvem.com/corestack/lifecycle"
|
||||
"go.auvem.com/corestack/lifecycle/cli"
|
||||
)
|
||||
|
||||
// Policy selects migration behavior during module setup.
|
||||
type Policy string
|
||||
|
||||
const (
|
||||
// Apply runs pending migrations before later modules start.
|
||||
Apply Policy = "apply"
|
||||
// Require fails setup when migrations are pending. This is a point-in-time
|
||||
// check, not a deployment lock; Goose may initialize its version table.
|
||||
Require Policy = "require"
|
||||
)
|
||||
|
||||
// Module declares startup migration work without resolving provider yet.
|
||||
// Register it after the database module and before dependent modules. The
|
||||
// resolver must return fresh registrations and clean up any partial acquisition.
|
||||
// Teardown never closes the provider: the database module owns its database.
|
||||
func Module(name string, policy Policy, provider func(context.Context) (*goose.Provider, error)) (lifecycle.Module, error) {
|
||||
if name == "" || (policy != Apply && policy != Require) || provider == nil {
|
||||
return lifecycle.Module{}, errors.New("migrate: name, apply/require policy and provider resolver are required")
|
||||
}
|
||||
return lifecycle.Module{Name: name, Teardown: func(context.Context) error { return nil }, Setup: func(ctx context.Context) error {
|
||||
p, err := resolve(ctx, provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if policy == Apply {
|
||||
_, err = p.Up(ctx)
|
||||
} else {
|
||||
var pending bool
|
||||
pending, err = p.HasPending(ctx)
|
||||
if err == nil && pending {
|
||||
return errors.New("migrate: pending migrations; run migrate up before startup")
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate: %s: %w", policy, err)
|
||||
}
|
||||
return nil
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func resolve(ctx context.Context, provider func(context.Context) (*goose.Provider, error)) (*goose.Provider, error) {
|
||||
if provider == nil {
|
||||
return nil, errors.New("migrate: provider resolver is required")
|
||||
}
|
||||
p, err := provider(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("migrate: resolve provider: %w", err)
|
||||
}
|
||||
if p == nil {
|
||||
return nil, errors.New("migrate: resolver returned nil provider")
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Commands returns a fresh native migrate group using exactly modules, with
|
||||
// services suppressed. Supply database modules only, excluding startup policy.
|
||||
// Resolvers run after setup. Help/usage starts nothing; Action owns signals and
|
||||
// reverse cleanup. Use fresh trees and bindings for independent invocations.
|
||||
// Status may initialize the version table; it does not run migration bodies.
|
||||
func Commands(config lifecycle.Config, modules []lifecycle.Module, provider func(context.Context) (*goose.Provider, error)) *urfavecli.Command {
|
||||
group := &urfavecli.Command{Name: "migrate", Usage: "Manage database migrations"}
|
||||
for _, name := range []string{"up", "up-to", "down", "down-to", "status"} {
|
||||
targeted := strings.HasSuffix(name, "-to")
|
||||
leaf := &urfavecli.Command{Name: name, ArgValidator: func(_ context.Context, cmd *urfavecli.Command) error {
|
||||
if targeted {
|
||||
if cmd.NArg() == 1 {
|
||||
if _, err := version(cmd.Args().First()); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return urfavecli.Exit("expected one nonnegative decimal int64 version", 2)
|
||||
}
|
||||
if cmd.NArg() != 0 {
|
||||
return urfavecli.Exit("unexpected positional arguments", 2)
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
if targeted {
|
||||
leaf.ArgsUsage = "<version>"
|
||||
}
|
||||
leaf.Action = cli.Action(config, modules, func(ctx context.Context, cmd *urfavecli.Command) error {
|
||||
p, err := resolve(ctx, provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, _ := version(cmd.Args().First()) // ArgValidator checked targeted leaves before setup.
|
||||
switch name {
|
||||
case "up":
|
||||
_, err = p.Up(ctx)
|
||||
case "up-to":
|
||||
_, err = p.UpTo(ctx, target)
|
||||
case "down":
|
||||
_, err = p.Down(ctx)
|
||||
case "down-to":
|
||||
_, err = p.DownTo(ctx, target)
|
||||
case "status":
|
||||
var statuses []*goose.MigrationStatus
|
||||
statuses, err = p.Status(ctx)
|
||||
if err == nil {
|
||||
writer := tabwriter.NewWriter(cmd.Writer, 0, 4, 2, ' ', 0)
|
||||
if _, err = fmt.Fprintln(writer, "VERSION\tSTATE\tSOURCE"); err == nil {
|
||||
for _, status := range statuses {
|
||||
if _, err = fmt.Fprintf(writer, "%d\t%s\t%s\n", status.Source.Version, status.State, status.Source.Path); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = writer.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
group.Commands = append(group.Commands, leaf)
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
func version(raw string) (int64, error) {
|
||||
if raw == "" || strings.IndexFunc(raw, func(r rune) bool { return r < '0' || r > '9' }) >= 0 {
|
||||
return 0, errors.New("invalid version")
|
||||
}
|
||||
return strconv.ParseInt(raw, 10, 64)
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package lifecycle_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
urfavecli "github.com/urfave/cli/v3"
|
||||
"go.auvem.com/corestack/lifecycle"
|
||||
"go.auvem.com/corestack/lifecycle/cli"
|
||||
"go.auvem.com/corestack/migrate"
|
||||
adapter "go.auvem.com/corestack/migrate/lifecycle"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed testdata
|
||||
var files embed.FS
|
||||
|
||||
func config() lifecycle.Config {
|
||||
return lifecycle.Config{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
}
|
||||
|
||||
func database(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
return db
|
||||
}
|
||||
func provider(t *testing.T, db *sql.DB) *goose.Provider {
|
||||
t.Helper()
|
||||
p, err := migrate.New(db, goose.DialectSQLite3, files, "testdata/migrations", config().Logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
func TestStartupPolicies(t *testing.T) {
|
||||
for _, policy := range []adapter.Policy{adapter.Apply, adapter.Require} {
|
||||
for _, applied := range []bool{false, true} {
|
||||
t.Run(string(policy)+"/applied="+stringBool(applied), func(t *testing.T) {
|
||||
db := database(t)
|
||||
p := provider(t, db)
|
||||
if applied {
|
||||
if _, err := p.Up(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var calls []string
|
||||
var setupCtx context.Context
|
||||
dbModule := lifecycle.Module{Name: "db", Setup: func(ctx context.Context) error { setupCtx = ctx; calls = append(calls, "db"); return nil }, Teardown: func(ctx context.Context) error {
|
||||
if ctx.Err() != nil {
|
||||
t.Error(ctx.Err())
|
||||
}
|
||||
calls = append(calls, "close")
|
||||
return nil
|
||||
}}
|
||||
module, err := adapter.Module("migrations", policy, func(ctx context.Context) (*goose.Provider, error) {
|
||||
if ctx != setupCtx {
|
||||
t.Error("setup context changed")
|
||||
}
|
||||
calls = append(calls, "resolve")
|
||||
return provider(t, db), nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(calls) != 0 || module.Run != nil {
|
||||
t.Fatal("declaration started work")
|
||||
}
|
||||
app, err := lifecycle.New(config())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
later := lifecycle.Module{Name: "later", Setup: func(context.Context) error { calls = append(calls, "later"); return nil }, Run: func(context.Context) error { calls = append(calls, "service"); return nil }, Teardown: func(context.Context) error { calls = append(calls, "later-close"); return nil }}
|
||||
for _, m := range []lifecycle.Module{dbModule, module, later} {
|
||||
if err := app.Register(m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
err = app.Run(t.Context())
|
||||
if policy == adapter.Require && !applied {
|
||||
if err == nil || !strings.Contains(err.Error(), "pending") {
|
||||
t.Fatalf("pending Require: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(calls, []string{"db", "resolve", "close"}) {
|
||||
t.Fatal(calls)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(calls, []string{"db", "resolve", "later", "service", "later-close", "close"}) {
|
||||
t.Fatal(calls)
|
||||
}
|
||||
pending, err := p.HasPending(t.Context())
|
||||
if err != nil || pending {
|
||||
t.Fatalf("pending=%v err=%v", pending, err)
|
||||
}
|
||||
}
|
||||
if err := db.PingContext(t.Context()); err != nil {
|
||||
t.Fatalf("adapter closed caller DB: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
func stringBool(v bool) string {
|
||||
if v {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
func TestModuleValidationAndErrors(t *testing.T) {
|
||||
sentinel := errors.New("resolve failure")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
policy adapter.Policy
|
||||
resolver func(context.Context) (*goose.Provider, error)
|
||||
}{{"", adapter.Apply, func(context.Context) (*goose.Provider, error) { return nil, nil }}, {"m", "", func(context.Context) (*goose.Provider, error) { return nil, nil }}, {"m", "invalid", func(context.Context) (*goose.Provider, error) { return nil, nil }}, {"m", adapter.Apply, nil}} {
|
||||
if _, err := adapter.Module(tc.name, tc.policy, tc.resolver); err == nil {
|
||||
t.Fatal("accepted invalid module")
|
||||
}
|
||||
}
|
||||
for _, policy := range []adapter.Policy{adapter.Apply, adapter.Require} {
|
||||
for _, resolver := range []func(context.Context) (*goose.Provider, error){func(context.Context) (*goose.Provider, error) { return nil, nil }, func(context.Context) (*goose.Provider, error) { return nil, sentinel }, func(context.Context) (*goose.Provider, error) {
|
||||
db := database(t)
|
||||
p := provider(t, db)
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p, nil
|
||||
}} {
|
||||
m, err := adapter.Module("m", policy, resolver)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Setup(t.Context()); err == nil {
|
||||
t.Fatal("accepted resolver/DB failure")
|
||||
}
|
||||
}
|
||||
}
|
||||
m, err := adapter.Module("m", adapter.Apply, func(context.Context) (*goose.Provider, error) { return nil, sentinel })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Setup(t.Context()); !errors.Is(err, sentinel) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func run(t *testing.T, args []string, modules []lifecycle.Module, resolver func(context.Context) (*goose.Provider, error), writer io.Writer) (int, error) {
|
||||
t.Helper()
|
||||
root := &urfavecli.Command{Name: "app", Writer: writer, ErrWriter: io.Discard, Commands: []*urfavecli.Command{adapter.Commands(config(), modules, resolver)}}
|
||||
return cli.Run(t.Context(), root, append([]string{"app", "migrate"}, args...))
|
||||
}
|
||||
func TestCommandsSQLite(t *testing.T) {
|
||||
db := database(t)
|
||||
var out bytes.Buffer
|
||||
var calls []string
|
||||
module := lifecycle.Module{Name: "db", Setup: func(context.Context) error { calls = append(calls, "setup"); return nil }, Run: func(context.Context) error { t.Error("one-off ran service"); return nil }, Teardown: func(context.Context) error { calls = append(calls, "cleanup"); return nil }}
|
||||
resolver := func(context.Context) (*goose.Provider, error) {
|
||||
calls = append(calls, "resolve")
|
||||
return provider(t, db), nil
|
||||
}
|
||||
for _, step := range []struct {
|
||||
args []string
|
||||
version int64
|
||||
}{{[]string{"up-to", "1"}, 1}, {[]string{"up"}, 2}, {[]string{"status"}, 2}, {[]string{"down"}, 1}, {[]string{"up-to", "2"}, 2}, {[]string{"down-to", "0"}, 0}} {
|
||||
calls = nil
|
||||
code, err := run(t, step.args, []lifecycle.Module{module}, resolver, &out)
|
||||
if code != 0 || err != nil {
|
||||
t.Fatalf("%v code=%d err=%v", step.args, code, err)
|
||||
}
|
||||
version, err := provider(t, db).GetDBVersion(t.Context())
|
||||
if err != nil || version != step.version {
|
||||
t.Fatalf("%v version=%d err=%v", step.args, version, err)
|
||||
}
|
||||
if !reflect.DeepEqual(calls, []string{"setup", "resolve", "cleanup"}) {
|
||||
t.Fatal(calls)
|
||||
}
|
||||
}
|
||||
// Zero is valid syntax; Goose UpTo rejects it after setup when migrations are pending.
|
||||
code, err := run(t, []string{"up-to", "0"}, []lifecycle.Module{module}, resolver, &out)
|
||||
if code != 1 || err == nil || !strings.Contains(err.Error(), "greater than 0") {
|
||||
t.Fatalf("zero UpTo: code=%d err=%v", code, err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "VERSION") || !strings.Contains(out.String(), "applied") || !strings.Contains(out.String(), "00002_second.sql") {
|
||||
t.Fatal(out.String())
|
||||
}
|
||||
}
|
||||
func TestHelpAndUsageWithoutSetup(t *testing.T) {
|
||||
for _, leaf := range []string{"up", "up-to", "down", "down-to", "status"} {
|
||||
code, err := run(t, []string{leaf, "--help"}, nil, nil, io.Discard)
|
||||
if code != 0 || err != nil {
|
||||
t.Fatalf("%s help: %d %v", leaf, code, err)
|
||||
}
|
||||
}
|
||||
|
||||
module := lifecycle.Module{Name: "db", Setup: func(context.Context) error { t.Error("usage opened DB"); return nil }, Teardown: func(context.Context) error { return nil }}
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
code int
|
||||
}{{nil, 0}, {[]string{"--help"}, 0}, {[]string{"help", "up-to"}, 0}, {[]string{"missing"}, 2}, {[]string{"up", "extra"}, 2}, {[]string{"down", "extra"}, 2}, {[]string{"status", "extra"}, 2}, {[]string{"up-to"}, 2}, {[]string{"down-to"}, 2}, {[]string{"up-to", "1", "2"}, 2}, {[]string{"down-to", "--", "-1"}, 2}, {[]string{"up-to", "+1"}, 2}, {[]string{"up-to", "9223372036854775808"}, 2}, {[]string{"down-to", "1x"}, 2}, {[]string{"up-to", "1"}, 2}} {
|
||||
code, err := run(t, tc.args, []lifecycle.Module{module}, nil, io.Discard)
|
||||
if code != tc.code || (code == 0 && err != nil) || (code != 0 && err == nil) {
|
||||
t.Fatalf("args=%v code=%d err=%v", tc.args, code, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type brokenWriter struct{ err error }
|
||||
|
||||
func (w brokenWriter) Write([]byte) (int, error) { return 0, w.err }
|
||||
func TestCommandFailureCleanup(t *testing.T) {
|
||||
db := database(t)
|
||||
writeErr := errors.New("write failed")
|
||||
resolveErr := errors.New("resolve failed")
|
||||
cleanupErr := errors.New("cleanup failed")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
resolver func(context.Context) (*goose.Provider, error)
|
||||
writer io.Writer
|
||||
want error
|
||||
}{{"writer", func(context.Context) (*goose.Provider, error) { return provider(t, db), nil }, brokenWriter{writeErr}, writeErr}, {"resolver", func(context.Context) (*goose.Provider, error) { return nil, resolveErr }, io.Discard, resolveErr}, {"nil", nil, io.Discard, nil}, {"nil-provider", func(context.Context) (*goose.Provider, error) { return nil, nil }, io.Discard, nil}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cleaned := false
|
||||
module := lifecycle.Module{Name: "db", Setup: func(context.Context) error { return nil }, Teardown: func(context.Context) error { cleaned = true; return cleanupErr }}
|
||||
code, err := run(t, []string{"status"}, []lifecycle.Module{module}, tc.resolver, tc.writer)
|
||||
if code != 1 || err == nil || !cleaned || !errors.Is(err, cleanupErr) || (tc.want != nil && !errors.Is(err, tc.want)) {
|
||||
t.Fatalf("code=%d err=%v cleaned=%v", code, err, cleaned)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestCommandSignalContext(t *testing.T) {
|
||||
signals := make(chan os.Signal, 2)
|
||||
cfg := config()
|
||||
cfg.Signals = signals
|
||||
entered := make(chan struct{})
|
||||
done := make(chan error, 1)
|
||||
cleaned := false
|
||||
module := lifecycle.Module{Name: "db", Setup: func(context.Context) error { return nil }, Teardown: func(ctx context.Context) error {
|
||||
if ctx.Err() != nil {
|
||||
t.Error(ctx.Err())
|
||||
}
|
||||
cleaned = true
|
||||
return nil
|
||||
}}
|
||||
root := &urfavecli.Command{Name: "app", Writer: io.Discard, ErrWriter: io.Discard, Commands: []*urfavecli.Command{adapter.Commands(cfg, []lifecycle.Module{module}, func(ctx context.Context) (*goose.Provider, error) {
|
||||
close(entered)
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
})}}
|
||||
go func() { _, err := cli.Run(t.Context(), root, []string{"app", "migrate", "up"}); done <- err }()
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("resolver did not start")
|
||||
}
|
||||
signals <- syscall.SIGTERM
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil || !cleaned {
|
||||
t.Fatalf("err=%v cleaned=%v", err, cleaned)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("signal did not cancel resolver")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE events (id INTEGER PRIMARY KEY);
|
||||
-- +goose Down
|
||||
DROP TABLE events;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- +goose Up
|
||||
INSERT INTO events VALUES (2);
|
||||
-- +goose Down
|
||||
DELETE FROM events WHERE id = 2;
|
||||
@@ -0,0 +1,68 @@
|
||||
// Package migrate configures Goose providers for embedded application migrations.
|
||||
// Applications own their database, migration files, and startup ordering.
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
"github.com/pressly/goose/v3/lock"
|
||||
)
|
||||
|
||||
// New returns a provider for SQL files in dir and optional provider-local Go
|
||||
// migrations. Pass "." for files at the embedded root and a nonnil logger.
|
||||
// The selected directory must exist, including when using only Go migrations.
|
||||
// Construction does not connect to db; the caller owns and closes db.
|
||||
//
|
||||
// PostgreSQL uses Goose's session advisory locker with its default lock ID and
|
||||
// retry limits. Other dialects require the caller to serialize migration runs
|
||||
// across providers/processes. PostgreSQL connections must preserve session
|
||||
// affinity (transaction-pooling proxies cannot safely hold session locks).
|
||||
//
|
||||
// Use the returned Provider's Up, UpTo, Down, DownTo, Status, GetDBVersion, GetVersions,
|
||||
// and HasPending methods directly. Create and reconcile sources with the standalone
|
||||
// corestack-goose tool (go run go.auvem.com/corestack/cmd/corestack-goose).
|
||||
// Pass migrations.GoMigrations()... from its generated package; stock Goose Go
|
||||
// output uses an incompatible global init registry. Lexical filename order must
|
||||
// match numeric version order. Each provider needs fresh Go registrations;
|
||||
// global Go registration is ignored.
|
||||
func New(db *sql.DB, dialect goose.Dialect, files embed.FS, dir string, logger *slog.Logger, migrations ...*goose.Migration) (*goose.Provider, error) {
|
||||
for i, migration := range migrations {
|
||||
if migration == nil {
|
||||
return nil, fmt.Errorf("migrate: Go migration %d must not be nil", i)
|
||||
}
|
||||
}
|
||||
root, err := fs.Sub(files, dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("migrate: directory %q: %w", dir, err)
|
||||
}
|
||||
info, err := fs.Stat(root, ".")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("migrate: directory %q: %w", dir, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("migrate: directory %q: not a directory", dir)
|
||||
}
|
||||
opts := []goose.ProviderOption{
|
||||
goose.WithDisableGlobalRegistry(true),
|
||||
goose.WithGoMigrations(migrations...),
|
||||
goose.WithSlog(logger),
|
||||
goose.WithVerbose(true),
|
||||
}
|
||||
if dialect == goose.DialectPostgres {
|
||||
locker, err := lock.NewPostgresSessionLocker()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("migrate: postgres locker: %w", err)
|
||||
}
|
||||
opts = append(opts, goose.WithSessionLocker(locker))
|
||||
}
|
||||
provider, err := goose.NewProvider(dialect, db, root, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("migrate: new provider: %w", err)
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
package migrate_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/pressly/goose/v3"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"go.auvem.com/corestack/migrate"
|
||||
)
|
||||
|
||||
// invariant: embedded fixtures are read-only; no test mutates shared migration state.
|
||||
//
|
||||
//go:embed testdata
|
||||
var fixtures embed.FS
|
||||
|
||||
func TestSQLite(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
run func(*testing.T, *sql.DB, goose.Dialect)
|
||||
}{
|
||||
{"flow", testFlow}, {"sql-only", testSQLOnly}, {"failure", testFailure},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "migrate.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
test.run(t, db, goose.DialectSQLite3)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgres(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
run func(*testing.T, *sql.DB, goose.Dialect)
|
||||
}{
|
||||
{"flow", testFlow}, {"sql-only", testSQLOnly}, {"failure", testFailure},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) { test.run(t, postgresDatabase(t)(), goose.DialectPostgres) })
|
||||
}
|
||||
}
|
||||
|
||||
func testFlow(t *testing.T, db *sql.DB, dialect goose.Dialect) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||
defer cancel()
|
||||
var logs bytes.Buffer
|
||||
goMigration := goose.NewGoMigration(20261027120000,
|
||||
&goose.GoFunc{RunTx: func(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, "INSERT INTO migration_events (version) VALUES (20261027120000)")
|
||||
return err
|
||||
}},
|
||||
&goose.GoFunc{RunTx: func(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, "DELETE FROM migration_events WHERE version = 20261027120000")
|
||||
return err
|
||||
}})
|
||||
p, err := migrate.New(db, dialect, fixtures, "testdata/migrations", slog.New(slog.NewJSONHandler(&logs, nil)), goMigration)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versions := []int64{7, 20261026093000, 20261027114500, 20261027120000}
|
||||
// Registering on one provider must not leak into another provider in the same process.
|
||||
other := newProvider(t, db, dialect, "testdata/migrations")
|
||||
if len(other.ListSources()) != 3 {
|
||||
t.Fatal("Go migration leaked to another provider")
|
||||
}
|
||||
assertState(t, ctx, p, 0, versions, 0)
|
||||
results, err := p.UpTo(ctx, versions[1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertResults(t, results, versions[:2])
|
||||
assertState(t, ctx, p, versions[1], versions, 2)
|
||||
results, err = p.Up(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertResults(t, results, versions[2:])
|
||||
assertState(t, ctx, p, versions[3], versions, 4)
|
||||
assertEvents(t, ctx, db, versions)
|
||||
results, err = p.Up(ctx)
|
||||
if err != nil || len(results) != 0 {
|
||||
t.Fatalf("idempotent Up = %v, %v", results, err)
|
||||
}
|
||||
results, err = p.DownTo(ctx, versions[1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertResults(t, results, []int64{versions[3], versions[2]})
|
||||
assertState(t, ctx, p, versions[1], versions, 2)
|
||||
assertEvents(t, ctx, db, versions[:2])
|
||||
results, err = p.DownTo(ctx, versions[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertResults(t, results, []int64{versions[1]})
|
||||
assertState(t, ctx, p, versions[0], versions, 1)
|
||||
assertEvents(t, ctx, db, versions[:1])
|
||||
if _, err := p.DownTo(ctx, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertState(t, ctx, p, 0, versions, 0)
|
||||
results, err = p.Up(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertResults(t, results, versions)
|
||||
assertEvents(t, ctx, db, versions)
|
||||
if !strings.Contains(logs.String(), `"logger":"goose"`) || !strings.Contains(logs.String(), `"version":20261027114500`) {
|
||||
t.Fatalf("missing structured migration logs: %s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func testSQLOnly(t *testing.T, db *sql.DB, dialect goose.Dialect) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||
defer cancel()
|
||||
for _, test := range []struct {
|
||||
dir string
|
||||
versions []int64
|
||||
}{
|
||||
{"testdata/numeric", []int64{7, 10}},
|
||||
{"testdata/migrations", []int64{7, 20261026093000, 20261027114500}},
|
||||
} {
|
||||
p := newProvider(t, db, dialect, test.dir)
|
||||
assertState(t, ctx, p, 0, test.versions, 0)
|
||||
results, err := p.UpTo(ctx, test.versions[1]-1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertResults(t, results, test.versions[:1])
|
||||
assertState(t, ctx, p, test.versions[0], test.versions, 1)
|
||||
results, err = p.Up(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertResults(t, results, test.versions[1:])
|
||||
assertState(t, ctx, p, test.versions[len(test.versions)-1], test.versions, len(test.versions))
|
||||
assertEvents(t, ctx, db, test.versions)
|
||||
results, err = p.DownTo(ctx, test.versions[1]-1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantDown := slices.Clone(test.versions[1:])
|
||||
slices.Reverse(wantDown)
|
||||
assertResults(t, results, wantDown)
|
||||
assertState(t, ctx, p, test.versions[0], test.versions, 1)
|
||||
if _, err := p.DownTo(ctx, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertState(t, ctx, p, 0, test.versions, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func testFailure(t *testing.T, db *sql.DB, dialect goose.Dialect) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||
defer cancel()
|
||||
p := newProvider(t, db, dialect, "testdata/failure")
|
||||
_, err := p.Up(ctx)
|
||||
var partial *goose.PartialError
|
||||
if !errors.As(err, &partial) || partial.Failed.Source.Version != 20261027114500 ||
|
||||
!strings.Contains(err.Error(), "missing_migration_table") || !strings.Contains(err.Error(), "20261027114500") {
|
||||
t.Fatalf("expected clear failed timestamp migration error, got %v", err)
|
||||
}
|
||||
assertResults(t, partial.Applied, []int64{7, 20261026093000})
|
||||
assertState(t, ctx, p, 20261026093000, []int64{7, 20261026093000, 20261027114500}, 2)
|
||||
assertEvents(t, ctx, db, []int64{7, 20261026093000})
|
||||
// A second attempt must fail at the same version without repeating committed work.
|
||||
if _, err := p.Up(ctx); err == nil {
|
||||
t.Fatal("retry unexpectedly succeeded")
|
||||
}
|
||||
assertEvents(t, ctx, db, []int64{7, 20261026093000})
|
||||
}
|
||||
|
||||
func newProvider(t *testing.T, db *sql.DB, dialect goose.Dialect, dir string, migrations ...*goose.Migration) *goose.Provider {
|
||||
t.Helper()
|
||||
p, err := migrate.New(db, dialect, fixtures, dir, slog.New(slog.NewTextHandler(io.Discard, nil)), migrations...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func assertState(t *testing.T, ctx context.Context, p *goose.Provider, current int64, versions []int64, applied int) {
|
||||
t.Helper()
|
||||
var sources []int64
|
||||
for _, source := range p.ListSources() {
|
||||
sources = append(sources, source.Version)
|
||||
}
|
||||
if !slices.Equal(sources, versions) {
|
||||
t.Fatalf("ListSources = %v; want %v", sources, versions)
|
||||
}
|
||||
got, err := p.GetDBVersion(ctx)
|
||||
if err != nil || got != current {
|
||||
t.Fatalf("GetDBVersion = %d, %v; want %d", got, err, current)
|
||||
}
|
||||
got, latest, err := p.GetVersions(ctx)
|
||||
if err != nil || got != current || latest != versions[len(versions)-1] {
|
||||
t.Fatalf("GetVersions = %d, %d, %v", got, latest, err)
|
||||
}
|
||||
pending, err := p.HasPending(ctx)
|
||||
if err != nil || pending != (applied < len(versions)) {
|
||||
t.Fatalf("HasPending = %v, %v", pending, err)
|
||||
}
|
||||
status, err := p.Status(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(status) != len(versions) {
|
||||
t.Fatalf("Status length = %d; want %d", len(status), len(versions))
|
||||
}
|
||||
for i, s := range status {
|
||||
want := goose.StatePending
|
||||
if i < applied {
|
||||
want = goose.StateApplied
|
||||
}
|
||||
if s.Source.Version != versions[i] || s.State != want {
|
||||
t.Fatalf("Status[%d] = %+v, version %d; want %s, %d", i, s, s.Source.Version, want, versions[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertResults(t *testing.T, results []*goose.MigrationResult, want []int64) {
|
||||
t.Helper()
|
||||
got := make([]int64, 0, len(results))
|
||||
for _, r := range results {
|
||||
got = append(got, r.Source.Version)
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("applied versions = %v; want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEvents(t *testing.T, ctx context.Context, db *sql.DB, want []int64) {
|
||||
t.Helper()
|
||||
rows, err := db.QueryContext(ctx, "SELECT version FROM migration_events ORDER BY version")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var got []int64
|
||||
for rows.Next() {
|
||||
var v int64
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = append(got, v)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("events = %v; want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// postgresDatabase owns only its random schema, never the supplied database.
|
||||
func postgresDatabase(t *testing.T) func() *sql.DB {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("MIGRATE_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
if os.Getenv("CI") != "" {
|
||||
t.Fatal("MIGRATE_POSTGRES_DSN is required in CI")
|
||||
}
|
||||
t.Skip("set MIGRATE_POSTGRES_DSN to run Postgres integration tests")
|
||||
}
|
||||
config, err := pgx.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := stdlib.OpenDB(*config)
|
||||
t.Cleanup(func() {
|
||||
if err := admin.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
schema := "migrate_" + strings.ToLower(rand.Text())
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||
defer cancel()
|
||||
if _, err := admin.ExecContext(ctx, "CREATE SCHEMA "+schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
if _, err := admin.ExecContext(ctx, "DROP SCHEMA "+schema+" CASCADE"); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
config.RuntimeParams["search_path"] = schema
|
||||
return func() *sql.DB {
|
||||
db := stdlib.OpenDB(*config)
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
return db
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresConcurrentUp(t *testing.T) {
|
||||
open := postgresDatabase(t)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||
defer cancel()
|
||||
const workers = 4
|
||||
providers := make([]*goose.Provider, workers)
|
||||
for i := range providers {
|
||||
// Each provider owns a distinct pool and Go migration instance, like separate daemons.
|
||||
pause := goose.NewGoMigration(3, &goose.GoFunc{RunTx: func(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, "SELECT pg_sleep(0.2)")
|
||||
return err
|
||||
}}, nil)
|
||||
providers[i] = newProvider(t, open(), goose.DialectPostgres, "testdata/migrations", pause)
|
||||
}
|
||||
type outcome struct {
|
||||
results []*goose.MigrationResult
|
||||
err error
|
||||
}
|
||||
start := make(chan struct{})
|
||||
done := make(chan outcome, workers)
|
||||
for _, p := range providers {
|
||||
go func() {
|
||||
select {
|
||||
case <-start:
|
||||
case <-ctx.Done():
|
||||
done <- outcome{err: ctx.Err()}
|
||||
return
|
||||
}
|
||||
results, err := p.Up(ctx)
|
||||
done <- outcome{results, err}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
var applied []int64
|
||||
for range workers {
|
||||
result := <-done
|
||||
if result.err != nil {
|
||||
t.Error(result.err)
|
||||
}
|
||||
for _, r := range result.results {
|
||||
applied = append(applied, r.Source.Version)
|
||||
}
|
||||
}
|
||||
slices.Sort(applied)
|
||||
if want := []int64{3, 7, 20261026093000, 20261027114500}; !slices.Equal(applied, want) {
|
||||
t.Fatalf("concurrent Up applied = %v; want exactly once each: %v", applied, want)
|
||||
}
|
||||
assertEvents(t, ctx, open(), []int64{7, 20261026093000, 20261027114500})
|
||||
for _, p := range providers {
|
||||
assertState(t, ctx, p, 20261027114500, applied, len(applied))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstructor(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
db *sql.DB
|
||||
dialect goose.Dialect
|
||||
dir string
|
||||
logger *slog.Logger
|
||||
migrations []*goose.Migration
|
||||
contains string
|
||||
}{
|
||||
{name: "nil database", dialect: goose.DialectSQLite3, dir: "testdata/migrations", logger: logger, contains: "db must not be nil"},
|
||||
{name: "nil logger", db: db, dialect: goose.DialectSQLite3, dir: "testdata/migrations", contains: "slog logger must not be nil"},
|
||||
{name: "invalid directory", db: db, dialect: goose.DialectSQLite3, dir: "../migrations", logger: logger, contains: "directory"},
|
||||
{name: "missing directory", db: db, dialect: goose.DialectSQLite3, dir: "missing", logger: logger, contains: "migrate:"},
|
||||
{name: "invalid dialect", db: db, dialect: "unknown", dir: "testdata/migrations", logger: logger, contains: "dialect"},
|
||||
{name: "nil migration", db: db, dialect: goose.DialectSQLite3, dir: "testdata/migrations", logger: logger, migrations: []*goose.Migration{nil}, contains: "must not be nil"},
|
||||
{name: "duplicate version", db: db, dialect: goose.DialectSQLite3, dir: "testdata/migrations", logger: logger, migrations: []*goose.Migration{goose.NewGoMigration(7, nil, nil)}, contains: "7"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
p, err := migrate.New(test.db, test.dialect, fixtures, test.dir, test.logger, test.migrations...)
|
||||
if err == nil || p != nil || !strings.Contains(err.Error(), test.contains) {
|
||||
t.Fatalf("New = %v, %v; want error containing %q", p, err, test.contains)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Construction and validation are offline, even when the database is closed.
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := migrate.New(db, goose.DialectSQLite3, fixtures, "testdata/migrations", logger); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstructorDirectory(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Directory validation and Go-only construction must remain offline.
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
for _, test := range []struct {
|
||||
dir string
|
||||
wantErr string
|
||||
}{
|
||||
{dir: "missing", wantErr: "directory"},
|
||||
{dir: "testdata/migrations/00007_events.sql", wantErr: "not a directory"},
|
||||
{dir: "."},
|
||||
{dir: "testdata"},
|
||||
} {
|
||||
t.Run(test.dir, func(t *testing.T) {
|
||||
p, err := migrate.New(db, goose.DialectSQLite3, fixtures, test.dir, logger, goose.NewGoMigration(10, nil, nil))
|
||||
if test.wantErr != "" {
|
||||
if err == nil || p != nil || !strings.Contains(err.Error(), test.wantErr) || !strings.Contains(err.Error(), test.dir) {
|
||||
t.Fatalf("New = %v, %v; want directory error containing %q and %q", p, err, test.wantErr, test.dir)
|
||||
}
|
||||
if test.dir == "missing" && !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("New error = %v; want fs.ErrNotExist", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sources := p.ListSources(); len(sources) != 1 || sources[0].Version != 10 {
|
||||
t.Fatalf("ListSources = %v; want Go migration 10 only", sources)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE migration_events (version BIGINT PRIMARY KEY);
|
||||
INSERT INTO migration_events (version) VALUES (7);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE migration_events;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
INSERT INTO migration_events (version) VALUES (20261026093000);
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM migration_events WHERE version = 20261026093000;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- +goose Up
|
||||
INSERT INTO migration_events (version) VALUES (20261027114500);
|
||||
INSERT INTO missing_migration_table (version) VALUES (20261027114500);
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM migration_events WHERE version = 20261027114500;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE migration_events (version BIGINT PRIMARY KEY);
|
||||
INSERT INTO migration_events (version) VALUES (7);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE migration_events;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
INSERT INTO migration_events (version) VALUES (20261026093000);
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM migration_events WHERE version = 20261026093000;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
INSERT INTO migration_events (version) VALUES (20261027114500);
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM migration_events WHERE version = 20261027114500;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE migration_events (version BIGINT PRIMARY KEY);
|
||||
INSERT INTO migration_events (version) VALUES (7);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE migration_events;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
INSERT INTO migration_events (version) VALUES (10);
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM migration_events WHERE version = 10;
|
||||
+216
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+184
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
Reference in new issue
Block a user