From de8436706507438f6985a22609dd9295893b56f2 Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sat, 7 Nov 2026 14:13:33 -0500 Subject: [PATCH 01/12] feat: give service the whole API's rules, and add a Postgres store A second store needs the rules the HTTP contract carries, and every rule belongs in service so it lands once for both stores. service now resolves a UUID or a name for a project, and a UUID or a number for an item, and it filters by status, repo, limit and a completion window. Projects carry a kind and items and tasks carry a completion time. Refusals are typed sentences, so an API can answer the right status and a client can print the words. store/postgres implements store.Store over pgx with goose migrations. InTx takes an advisory lock first, the analog of SQLite's immediate transaction, so a rule's reads still hold at commit. A row trigger writes every change to audit_trail, reading the actor and request id from transaction-local settings bound from the context. service's suite runs on either store, chosen by PROJECTS_TEST_STORE. SQLite mode changes where the rules moved: completing an item with open tasks is refused, deleting a project refuses open orphans and removes completed ones, and reordering dense-ranks a project's positions. Agent-Name: claude-code-unattended Agent-Session: b6774e69-f156-4d1a-9713-9886fbbc39f7 Agent-Run: 01a1b74b-03f4-747a-9881-2a1e47809809 Worklog: 2401.1 --- .github/workflows/validate.yml | 10 + .pre-commit-config.yaml | 18 + .sqlfluffignore | 1 + go.mod | 58 +- go.sum | 148 +- model/errors.go | 30 + model/project.go | 128 +- model/project_item.go | 82 +- model/task.go | 20 +- service/listing.go | 349 +++++ service/project_status_test.go | 10 +- service/references.go | 309 +++++ service/service.go | 827 ++++++++--- service/service_test.go | 60 +- service/undo.go | 44 +- service/undo_records.go | 109 +- store/postgres/convert.go | 87 ++ store/postgres/generated/db.go | 32 + store/postgres/generated/models.go | 84 ++ store/postgres/generated/queries.sql.go | 1307 ++++++++++++++++++ store/postgres/migrations/00001_projects.sql | 209 +++ store/postgres/open.go | 50 + store/postgres/postgrestest/postgrestest.go | 118 ++ store/postgres/queries.sql | 265 ++++ store/postgres/sqlc.yaml | 24 + store/postgres/store.go | 531 +++++++ store/sqlite/convert.go | 151 +- store/sqlite/generated/models.go | 41 +- store/sqlite/generated/queries.sql.go | 642 +++++---- store/sqlite/lookup_rows.sql | 6 + store/sqlite/open.go | 47 + store/sqlite/queries.sql | 155 ++- store/sqlite/schema.sql | 34 +- store/sqlite/store.go | 203 +-- store/store.go | 93 +- sync/engine_test.go | 72 +- tui/app.go | 8 +- tui/app_test.go | 12 +- tui/reader.go | 2 +- 39 files changed, 5344 insertions(+), 1032 deletions(-) create mode 100644 .sqlfluffignore create mode 100644 service/listing.go create mode 100644 service/references.go create mode 100644 store/postgres/convert.go create mode 100644 store/postgres/generated/db.go create mode 100644 store/postgres/generated/models.go create mode 100644 store/postgres/generated/queries.sql.go create mode 100644 store/postgres/migrations/00001_projects.sql create mode 100644 store/postgres/open.go create mode 100644 store/postgres/postgrestest/postgrestest.go create mode 100644 store/postgres/queries.sql create mode 100644 store/postgres/sqlc.yaml create mode 100644 store/postgres/store.go create mode 100644 store/sqlite/lookup_rows.sql diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 644f7f6..a8ef19d 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -54,6 +54,16 @@ jobs: - name: Check sqlc generated code is current run: cd store/sqlite && go tool sqlc diff + - name: Check sqlc generated code for Postgres is current + run: cd store/postgres && go tool sqlc diff + + # One suite, every rule, on the second store. The go job above ran it on + # SQLite, so a rule that only one store honors fails here. + - name: Run the service suite on Postgres + run: go test ./service/ + env: + PROJECTS_TEST_STORE: postgres + # Executes the Taskfile so its build paths cannot silently rot again, as # they did for 109 days after the package layout flatten. - name: Build via Taskfile diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2b23e68..5e3c95c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -286,6 +286,13 @@ repos: files: ^store/sqlite/.*\.(sql|yaml)$ pass_filenames: false stages: [pre-commit] + - id: sqlc-diff-postgres + name: sqlc generated code for Postgres is current + entry: bash -c 'cd store/postgres && go tool sqlc diff' + language: system + files: ^store/postgres/.*\.(sql|yaml)$ + pass_filenames: false + stages: [pre-commit] # generated:github-actions - GitHub Actions - repo: https://github.com/jdx/jactionlint @@ -302,3 +309,14 @@ repos: hooks: - id: sqlfluff-lint args: [--dialect, "sqlite", --config, .sqlfluff] + +# > custom:after:sql - Postgres SQL lints in its own dialect + # .sqlfluffignore keeps the generated SQLite hook off store/postgres. This one + # reads only that directory, in the dialect it is written in. + - repo: https://github.com/sqlfluff/sqlfluff + rev: 4.2.2 + hooks: + - id: sqlfluff-lint + alias: sqlfluff-lint-postgres + files: ^store/postgres/.*\.sql$ + args: [--dialect, "postgres", --config, .sqlfluff, --disregard-sqlfluffignores] diff --git a/.sqlfluffignore b/.sqlfluffignore new file mode 100644 index 0000000..9d62c19 --- /dev/null +++ b/.sqlfluffignore @@ -0,0 +1 @@ +store/postgres/ diff --git a/go.mod b/go.mod index 51401e0..08ceb47 100644 --- a/go.mod +++ b/go.mod @@ -15,8 +15,12 @@ require ( github.com/datapointchris/tuidrive v0.1.0 github.com/go-viper/mapstructure/v2 v2.4.0 github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.11.0 + github.com/pressly/goose/v3 v3.26.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 + github.com/testcontainers/testcontainers-go v0.44.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 github.com/yuin/goldmark v1.7.17 modernc.org/sqlite v1.54.0 ) @@ -28,11 +32,16 @@ require ( require ( cel.dev/expr v0.24.0 // indirect + dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymerick/douceur v0.2.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect github.com/charmbracelet/x/conpty v0.2.0 // indirect @@ -45,59 +54,100 @@ require ( github.com/charmbracelet/x/xpty v0.1.4 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/creack/pty v1.1.24 // indirect github.com/cubicdaiya/gonp v1.0.4 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.10.1 // indirect github.com/fatih/structtag v1.2.0 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-fonts/dejavu v0.3.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/google/cel-go v0.26.1 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.7.5 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.30 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.55.0 // indirect + github.com/moby/moby/client v0.5.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.7.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pganalyze/pg_query_go/v6 v6.1.0 // indirect github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb // indirect github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect github.com/pingcap/log v1.1.0 // indirect github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/riza-io/grpc-go v0.2.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/shirou/gopsutil/v4 v4.26.6 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/sqlc-dev/sqlc v1.30.0 // indirect github.com/stoewer/go-strcase v1.2.0 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tetratelabs/wazero v1.9.0 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect github.com/xo/terminfo v1.0.0 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.39.0 // indirect + golang.org/x/crypto v0.54.0 // indirect golang.org/x/image v0.39.0 // indirect - golang.org/x/net v0.41.0 // indirect - golang.org/x/sync v0.22.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect diff --git a/go.sum b/go.sum index 5935915..a70948b 100644 --- a/go.sum +++ b/go.sum @@ -8,9 +8,17 @@ charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c= charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k= charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= @@ -26,6 +34,10 @@ github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvA github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/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/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw= @@ -54,6 +66,16 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= @@ -68,22 +90,36 @@ github.com/datapointchris/tuidrive v0.1.0/go.mod h1:lbRNaZkLTfPvfVqkJlcCzfuItgbe github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/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.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-fonts/dejavu v0.3.4 h1:Qqyx9IOs5CQFxyWTdvddeWzrX0VNwUAvbmAzL0fpjbc= github.com/go-fonts/dejavu v0.3.4/go.mod h1:D1z0DglIz+lmpeNYMYlxW4r22IhcdOYnt+R3PShU/Kg= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/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/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= @@ -112,12 +148,14 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI 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.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= -github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +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.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -125,18 +163,50 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +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-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk= github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +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/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +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.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= +github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +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.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +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/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pganalyze/pg_query_go/v6 v6.1.0 h1:jG5ZLhcVgL1FAw4C/0VNQaVmX1SUJx71wBGdtTtBvls= @@ -153,17 +223,27 @@ github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0/go.mod h1: github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= +github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= 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/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/riza-io/grpc-go v0.2.0 h1:2HxQKFVE7VuYstcJ8zqpN84VnAoJ4dCL6YFhJewNcHQ= github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWFyVGWqi8= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -182,6 +262,8 @@ github.com/sqlc-dev/sqlc v1.30.0/go.mod h1:QnEN+npugyhUg1A+1kkYM3jc2OMOFsNlZ1eh8 github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= 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.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= @@ -190,8 +272,16 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= +github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 h1:8fdv/9y3JMxjQ+ULAcOG8RtgeNu5t9XF9LolSXDuTwM= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0/go.mod h1:CFr2LncGYokw+OKjXcr8ARCKG1SaC2UEnGxFBovE86g= 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.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= 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= @@ -203,18 +293,22 @@ github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA= github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= -go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +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.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= @@ -233,8 +327,8 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= @@ -244,14 +338,20 @@ golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +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.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= @@ -287,6 +387,8 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= @@ -315,3 +417,5 @@ 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= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/model/errors.go b/model/errors.go index c02edca..e76573b 100644 --- a/model/errors.go +++ b/model/errors.go @@ -5,8 +5,10 @@ import "errors" var ( ErrNotFound = errors.New("not found") ErrDuplicateName = errors.New("duplicate name") + ErrDuplicateID = errors.New("id already exists") ErrCyclicDependency = errors.New("would create a cyclic dependency") ErrLastProject = errors.New("item must belong to at least one project") + ErrNoProjects = errors.New("a new item needs a project") ErrNothingToUndo = errors.New("nothing to undo") // A dropped project with no reason is indistinguishable from a deferred one, // and deferred invites the same idea back next month. @@ -14,4 +16,32 @@ var ( // A project name must be bounded work. A repo does not end, so a project // named after one becomes the bucket every later papercut falls into. ErrRepoNamedProject = errors.New("a project may not be named after a repo") + // A project is addressed by name in a path, so a name ending in the item-list + // segment would read as that list. + ErrReservedName = errors.New("a project name may not end in the item-list segment") + // Several closed projects can share a name, and nothing picks between them. + ErrAmbiguousName = errors.New("name matches several closed projects") + ErrInvalidReference = errors.New("not an item id or number") + ErrUnknownKind = errors.New("unknown project kind") + ErrUnknownStatus = errors.New("unknown project status") + ErrUnknownItemStatus = errors.New("unknown item status") + ErrInvertedWindow = errors.New("window starts after it ends") + ErrAlreadyInProject = errors.New("item is already in that project") + ErrDuplicateDependency = errors.New("dependency already exists") + ErrSelfDependency = errors.New("an item cannot depend on itself") + ErrIncompleteOrphans = errors.New("project holds incomplete items no other project holds") + ErrIncompleteTasks = errors.New("item has incomplete tasks") ) + +// Refusal is a rule saying no, with the sentence a person reads. Kind is the +// sentinel a caller matches, so errors.Is sees through it to one of the errors +// above. Field names the input field at fault, where one input is. +type Refusal struct { + Kind error + Message string + Field string +} + +func (r *Refusal) Error() string { return r.Message } + +func (r *Refusal) Unwrap() error { return r.Kind } diff --git a/model/project.go b/model/project.go index bace222..b1fea4a 100644 --- a/model/project.go +++ b/model/project.go @@ -1,6 +1,10 @@ package model -import "time" +import ( + "slices" + "strings" + "time" +) // Project statuses. A project is a finite effort with a definition of done, so // completing it is what hides it — there is no separate archived flag the way @@ -27,12 +31,17 @@ const ( StatusAll = "all" ) +// KindLife is the kind a project takes when nothing names one. +const KindLife = "life" + // Project represents a project that items are organized into. type Project struct { ID string `json:"id"` Name string `json:"name"` Description *string `json:"description,omitempty"` - Status string `json:"status"` + // Kind is one of the names in the project_kinds table. + Kind string `json:"kind,omitempty"` + Status string `json:"status"` // Why a project was dropped rather than deferred. Nil for anything still // active, and cleared by the server on reopen. StatusReason *string `json:"status_reason,omitempty"` @@ -44,33 +53,112 @@ type Project struct { CreatedAt time.Time `json:"created_at"` } -// ProjectWithItemCount is a Project with the count of active (non-archived) items. -// Returned by GET /projects/ and GET /projects/{id}/. +// ProjectIsTerminal reports whether a status closes the project it is on. +func ProjectIsTerminal(status string) bool { + return status == StatusCompleted || status == StatusDropped +} + +// ProjectWithItemCount is a Project with what its items add up to. The counts +// partition ItemCount: archived beats completed, so an archived item is neither +// open nor completed and is what ItemCount has beyond the other two. type ProjectWithItemCount struct { Project - ItemCount int `json:"item_count"` - // How many of those are done. A total alone cannot distinguish a project - // with nothing left in it from one that has not been started. Derived from - // the local tables on every read rather than carried in the sync payload, - // so an API that does not send it costs nothing. + ItemCount int `json:"item_count"` + OpenCount int `json:"open_count"` CompletedCount int `json:"completed_count"` + // Repos is derived from the items rather than stored: an item's repo tag is + // the one record of what code a piece of work touches. + Repos []string `json:"repos"` +} + +// UnarchivedCount is the items a project still shows, open or finished. +func (p ProjectWithItemCount) UnarchivedCount() int { + return p.OpenCount + p.CompletedCount +} + +// ProjectItemFact is what one membership adds to its project's counts. +type ProjectItemFact struct { + ProjectID string + Completed bool + Archived bool + Repo *string +} + +// SortProjectsForBoard orders projects the way a board shows them: active first, +// then someday, then closed, each newest close first, then by position and name. +// A someday project has no closing date, so ordering on the date alone would mix +// it in among the active ones. +func SortProjectsForBoard(projects []ProjectWithItemCount) { + statusRank := func(status string) int { + switch status { + case StatusActive: + return 0 + case StatusSomeday: + return 1 + default: + return 2 + } + } + slices.SortStableFunc(projects, func(a, b ProjectWithItemCount) int { + if byStatus := statusRank(a.Status) - statusRank(b.Status); byStatus != 0 { + return byStatus + } + if byClose := CompareNewestCloseFirst(a.ClosedAt, b.ClosedAt); byClose != 0 { + return byClose + } + if byPosition := a.Position - b.Position; byPosition != 0 { + return byPosition + } + return strings.Compare(a.Name, b.Name) + }) +} + +// CompareNewestCloseFirst puts a project with no closing date ahead of any closed +// one, and the most recently closed next. +func CompareNewestCloseFirst(a, b *time.Time) int { + switch { + case a == nil && b == nil: + return 0 + case a == nil: + return -1 + case b == nil: + return 1 + } + return b.Compare(*a) } -// OpenCount is the number that decides whether a project still wants work. -func (p ProjectWithItemCount) OpenCount() int { - return p.ItemCount - p.CompletedCount +// ProjectFilter narrows a list of projects. Status is a status name or StatusAll. +// Repo keeps projects holding at least one item tagged with it. A nil Limit caps +// nothing, and zero returns no rows. +type ProjectFilter struct { + Status string + Repo *string + Limit *int } -// CreateProject is the input for creating a new project. +// CreateProject is the input for creating a new project. Only Name is required. +// A nil ID is generated, a nil Kind is KindLife and a nil Status is StatusActive. +// A nil Position places the project after every other. type CreateProject struct { - Name string `json:"name"` - Description *string `json:"description,omitempty"` + ID *string `json:"id,omitempty"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Kind *string `json:"kind,omitempty"` + Status *string `json:"status,omitempty"` + StatusReason *string `json:"status_reason,omitempty"` + Position *int `json:"position,omitempty"` } -// UpdateProject is the input for updating a project. -// All fields are optional — only non-nil fields are applied. +// UpdateProject is the input for updating a project. Only non-nil fields are +// applied. A pointer cannot say "set this to nothing", so the Clear flags do. type UpdateProject struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - Position *int `json:"position,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Kind *string `json:"kind,omitempty"` + Status *string `json:"status,omitempty"` + StatusReason *string `json:"status_reason,omitempty"` + Position *int `json:"position,omitempty"` + + ClearDescription bool `json:"-"` + ClearStatusReason bool `json:"-"` } diff --git a/model/project_item.go b/model/project_item.go index 74fadc7..b521225 100644 --- a/model/project_item.go +++ b/model/project_item.go @@ -2,62 +2,102 @@ package model import "time" +// An item's status is derived from two stored booleans. These three words and +// StatusAll are the filter's whole vocabulary. +const ( + ItemStatusOpen = "open" + ItemStatusCompleted = "completed" + ItemStatusArchived = "archived" +) + // ProjectItem is the base representation of an item in the system. type ProjectItem struct { ID string `json:"id"` // Number is the handle — short, server-assigned, and the only one of the two // identifiers meant to be read or typed. Nil for an item created while sync // was unreachable, which has no number until its create is pushed. - Number *int `json:"number,omitempty"` - Title string `json:"title"` - Notes *string `json:"notes,omitempty"` - Repo *string `json:"repo,omitempty"` - Completed bool `json:"completed"` - Archived bool `json:"archived"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Number *int `json:"number,omitempty"` + Title string `json:"title"` + Notes *string `json:"notes,omitempty"` + Repo *string `json:"repo,omitempty"` + Completed bool `json:"completed"` + // CompletedAt is nil where the completion time is unknown: never finished, or + // finished by a write that recorded none. + CompletedAt *time.Time `json:"completed_at,omitempty"` + Archived bool `json:"archived"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Status names the one word an item answers to. Archived beats completed, so an +// item finished and then archived answers to archived alone. +func (i ProjectItem) Status() string { + switch { + case i.Archived: + return ItemStatusArchived + case i.Completed: + return ItemStatusCompleted + default: + return ItemStatusOpen + } } -// ProjectItemDetail is a ProjectItem with its project memberships, dependency -// IDs, and sub-tasks. Returned by GET /project-items/ (the list), GET -// /project-items/{id}/, and POST /project-items/ — the list carries the same -// shape so a full reconcile is two requests rather than two per item. +// ProjectItemDetail is a ProjectItem with everything filed against it. // -// DependencyIDs and Tasks decode to nil when the server omits the field and to -// an empty slice when it sends one, which is the only way a pull can tell a -// server predating the embed from an item that genuinely has neither. Keep them -// as plain slices and keep the API sending `[]`; anything that collapses nil and -// empty makes an old server look like an instruction to delete every task. +// DependencyIDs and Tasks decode to nil when a server omits the field and to an +// empty slice when it sends one, which is the only way a pull can tell a server +// predating the embed from an item that genuinely has neither. Keep them as plain +// slices and keep the API sending `[]`; anything that collapses nil and empty +// makes an old server look like an instruction to delete every task. type ProjectItemDetail struct { ProjectItem Projects []Project `json:"projects"` + Memberships []Membership `json:"memberships,omitempty"` DependencyIDs []string `json:"dependency_ids"` + DependsOn []ProjectItem `json:"depends_on,omitempty"` Tasks []ProjectItemTask `json:"tasks"` } // ProjectItemInProject is a ProjectItem as seen within a specific project context, // including its position within that project. -// Returned by GET /projects/{id}/items/. type ProjectItemInProject struct { ProjectItem Position int `json:"position"` ProjectCount int `json:"project_count,omitempty"` } -// CreateProjectItem is the input for creating a new project item. +// ItemFilter narrows a list of items. Status is an item status or StatusAll. An +// empty Repo keeps the untagged items, and a nil one keeps every item. Since and +// Until bound CompletedAt, half-open, and an item with none is outside any +// window. A nil Limit caps nothing, and zero returns no rows. +type ItemFilter struct { + Status string + Repo *string + Since *time.Time + Until *time.Time + Limit *int +} + +// CreateProjectItem is the input for creating a new project item. ProjectIDs each +// hold an id or a name. A nil ID is generated. type CreateProjectItem struct { + ID *string `json:"id,omitempty"` Title string `json:"title"` Notes *string `json:"notes,omitempty"` Repo *string `json:"repo,omitempty"` ProjectIDs []string `json:"project_ids"` } -// UpdateProjectItem is the input for updating an existing project item. -// All fields are optional — only non-nil fields are applied. +// UpdateProjectItem is the input for updating an existing project item. Only +// non-nil fields are applied. A pointer cannot say "set this to nothing", so the +// Clear flags do. type UpdateProjectItem struct { Title *string `json:"title,omitempty"` Notes *string `json:"notes,omitempty"` Repo *string `json:"repo,omitempty"` Completed *bool `json:"completed,omitempty"` Archived *bool `json:"archived,omitempty"` + + ClearNotes bool `json:"-"` + ClearRepo bool `json:"-"` } diff --git a/model/task.go b/model/task.go index 61afd93..c4ce379 100644 --- a/model/task.go +++ b/model/task.go @@ -4,17 +4,21 @@ import "time" // ProjectItemTask is a sub-task within a project item. type ProjectItemTask struct { - ID string `json:"id"` - ItemID string `json:"item_id"` - Title string `json:"title"` - Completed bool `json:"completed"` - Position int `json:"position"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + ItemID string `json:"item_id"` + Title string `json:"title"` + Completed bool `json:"completed"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Position int `json:"position"` + CreatedAt time.Time `json:"created_at"` } -// CreateProjectItemTask is the input for creating a new task on a project item. +// CreateProjectItemTask is the input for creating a new task on a project item. A +// nil ID is generated. A Position of zero places the task after every other. type CreateProjectItemTask struct { - Title string `json:"title"` + ID *string `json:"id,omitempty"` + Title string `json:"title"` + Position int `json:"position,omitempty"` } // UpdateProjectItemTask is the input for updating an existing task. diff --git a/service/listing.go b/service/listing.go new file mode 100644 index 0000000..ac0674a --- /dev/null +++ b/service/listing.go @@ -0,0 +1,349 @@ +package service + +import ( + "cmp" + "context" + "fmt" + "slices" + "strings" + "time" + + "github.com/datapointchris/projects/model" + "github.com/datapointchris/projects/store" +) + +// withItemCounts adds up each project's memberships. +func withItemCounts(projects []model.Project, facts []model.ProjectItemFact) []model.ProjectWithItemCount { + counted := make(map[string]*model.ProjectWithItemCount, len(projects)) + repos := make(map[string]map[string]struct{}, len(projects)) + out := make([]model.ProjectWithItemCount, len(projects)) + for i, project := range projects { + out[i] = model.ProjectWithItemCount{Project: project, Repos: []string{}} + counted[project.ID] = &out[i] + repos[project.ID] = map[string]struct{}{} + } + for _, fact := range facts { + project, known := counted[fact.ProjectID] + if !known { + continue + } + project.ItemCount++ + if fact.Archived { + continue + } + if fact.Completed { + project.CompletedCount++ + } else { + project.OpenCount++ + } + if fact.Repo != nil { + repos[fact.ProjectID][*fact.Repo] = struct{}{} + } + } + for i := range out { + for repo := range repos[out[i].ID] { + out[i].Repos = append(out[i].Repos, repo) + } + slices.Sort(out[i].Repos) + } + return out +} + +func (s *Service) projectsWithItemCounts(ctx context.Context) ([]model.ProjectWithItemCount, []model.ProjectItemFact, error) { + projects, err := s.store.ListProjects(ctx) + if err != nil { + return nil, nil, fmt.Errorf("listing projects: %w", err) + } + facts, err := s.store.ListProjectItemFacts(ctx) + if err != nil { + return nil, nil, fmt.Errorf("listing item counts: %w", err) + } + return withItemCounts(projects, facts), facts, nil +} + +// ListProjects is the filtered read. Closed projects come newest close first, and +// projects with no closing date ahead of them, by position. +func (s *Service) ListProjects(filter model.ProjectFilter) ([]model.ProjectWithItemCount, error) { + ctx := s.ctx() + if filter.Status != model.StatusAll { + if err := refuseUnknownStatus(ctx, s.store, filter.Status); err != nil { + return nil, err + } + } + counted, facts, err := s.projectsWithItemCounts(ctx) + if err != nil { + return nil, err + } + + var holdsRepo map[string]bool + if filter.Repo != nil { + holdsRepo = make(map[string]bool) + for _, fact := range facts { + if fact.Repo != nil && *fact.Repo == *filter.Repo { + holdsRepo[fact.ProjectID] = true + } + } + } + + kept := make([]model.ProjectWithItemCount, 0, len(counted)) + for _, project := range counted { + if filter.Status != model.StatusAll && project.Status != filter.Status { + continue + } + if filter.Repo != nil && !holdsRepo[project.ID] { + continue + } + kept = append(kept, project) + } + slices.SortStableFunc(kept, func(a, b model.ProjectWithItemCount) int { + return cmp.Or( + model.CompareNewestCloseFirst(a.ClosedAt, b.ClosedAt), + cmp.Compare(a.Position, b.Position), + strings.Compare(a.Name, b.Name), + ) + }) + return capped(kept, filter.Limit), nil +} + +func capped[T any](rows []T, limit *int) []T { + if limit != nil && *limit < len(rows) { + return rows[:*limit] + } + return rows +} + +// refuseBadItemFilter names what is wrong with a filter rather than answering it +// with an empty list. +func refuseBadItemFilter(filter model.ItemFilter) error { + if filter.Since != nil && filter.Until != nil && filter.Since.After(*filter.Until) { + return refuse(model.ErrInvertedWindow, "since %s is later than until %s", + isoInstant(*filter.Since), isoInstant(*filter.Until)) + } + switch filter.Status { + case model.ItemStatusOpen, model.ItemStatusCompleted, model.ItemStatusArchived, model.StatusAll: + return nil + } + return refuse(model.ErrUnknownItemStatus, "Unknown item status %s. Known statuses: open, completed, archived, all", + quoteName(filter.Status)) +} + +func isoInstant(t time.Time) string { + if t.Nanosecond() == 0 { + return t.Format("2006-01-02T15:04:05-07:00") + } + return t.Format("2006-01-02T15:04:05.000000-07:00") +} + +func itemPassesFilter(item model.ProjectItem, filter model.ItemFilter) bool { + if filter.Status != model.StatusAll && item.Status() != filter.Status { + return false + } + if !itemHasRepo(item, filter.Repo) { + return false + } + if filter.Since != nil && (item.CompletedAt == nil || item.CompletedAt.Before(*filter.Since)) { + return false + } + if filter.Until != nil && (item.CompletedAt == nil || !item.CompletedAt.Before(*filter.Until)) { + return false + } + return true +} + +// itemHasRepo reads a nil filter as no filter, and an empty one as the untagged +// items, which are a different question from any repo and have to stay askable. +func itemHasRepo(item model.ProjectItem, repo *string) bool { + switch { + case repo == nil: + return true + case *repo == "": + return item.Repo == nil + default: + return item.Repo != nil && *item.Repo == *repo + } +} + +// ListItems answers the items a filter keeps, newest first, each carrying what is +// filed against it. +func (s *Service) ListItems(filter model.ItemFilter) ([]model.ProjectItemDetail, error) { + if err := refuseBadItemFilter(filter); err != nil { + return nil, err + } + ctx := s.ctx() + items, err := s.store.ListAllItems(ctx) + if err != nil { + return nil, fmt.Errorf("listing items: %w", err) + } + kept := make([]model.ProjectItem, 0, len(items)) + for _, item := range items { + if itemPassesFilter(item, filter) { + kept = append(kept, item) + } + } + return s.withEverythingFiled(ctx, capped(kept, filter.Limit)) +} + +// ListProjectItems scopes a filter to one project, in project order. A scope +// selects which rows come back and not which states, so the filter means what it +// means in ListItems. +func (s *Service) ListProjectItems(projectRef string, filter model.ItemFilter) ([]model.ProjectItemInProject, error) { + ctx := s.ctx() + project, err := resolveProject(ctx, s.store, projectRef) + if err != nil { + return nil, err + } + if err := refuseBadItemFilter(filter); err != nil { + return nil, err + } + items, err := s.store.ListItemsInProject(ctx, project.ID) + if err != nil { + return nil, fmt.Errorf("listing items in project: %w", err) + } + kept := make([]model.ProjectItemInProject, 0, len(items)) + for _, item := range items { + if itemPassesFilter(item.ProjectItem, filter) { + kept = append(kept, item) + } + } + return capped(kept, filter.Limit), nil +} + +// ListBlockedItems is ListBlocked narrowed to a repo, each item carrying what is +// filed against it. +func (s *Service) ListBlockedItems(repo *string) ([]model.ProjectItemDetail, error) { + ctx := s.ctx() + items, err := s.store.ListBlockedItems(ctx) + if err != nil { + return nil, fmt.Errorf("listing blocked: %w", err) + } + return s.withEverythingFiled(ctx, keepRepo(items, repo)) +} + +// SearchItems matches archived items too, narrowed to a repo, each carrying what +// is filed against it. +func (s *Service) SearchItems(query string, repo *string) ([]model.ProjectItemDetail, error) { + ctx := s.ctx() + items, err := s.store.SearchItems(ctx, query) + if err != nil { + return nil, fmt.Errorf("searching items: %w", err) + } + return s.withEverythingFiled(ctx, keepRepo(items, repo)) +} + +// ListBlockerItems is GetBlockers with each blocker carrying what is filed against it. +func (s *Service) ListBlockerItems(itemRef string) ([]model.ProjectItemDetail, error) { + blockers, err := s.GetBlockers(itemRef) + if err != nil { + return nil, err + } + return s.withEverythingFiled(s.ctx(), blockers) +} + +func keepRepo(items []model.ProjectItem, repo *string) []model.ProjectItem { + kept := make([]model.ProjectItem, 0, len(items)) + for _, item := range items { + if itemHasRepo(item, repo) { + kept = append(kept, item) + } + } + return kept +} + +// withEverythingFiled joins each item to its projects, memberships, dependencies +// and tasks over four flat reads, so a list costs the same however long it is. +func (s *Service) withEverythingFiled(ctx context.Context, items []model.ProjectItem) ([]model.ProjectItemDetail, error) { + out := make([]model.ProjectItemDetail, len(items)) + if len(items) == 0 { + return out, nil + } + projects, err := s.store.ListProjects(ctx) + if err != nil { + return nil, fmt.Errorf("listing projects: %w", err) + } + memberships, err := s.store.ListAllMemberships(ctx) + if err != nil { + return nil, fmt.Errorf("listing memberships: %w", err) + } + dependencies, err := s.store.ListAllDependencies(ctx) + if err != nil { + return nil, fmt.Errorf("listing dependencies: %w", err) + } + tasks, err := s.store.ListAllTasks(ctx) + if err != nil { + return nil, fmt.Errorf("listing tasks: %w", err) + } + + projectByID := make(map[string]model.Project, len(projects)) + for _, project := range projects { + projectByID[project.ID] = project + } + membershipsOf := make(map[string][]model.Membership) + for _, membership := range memberships { + membershipsOf[membership.ItemID] = append(membershipsOf[membership.ItemID], membership) + } + dependenciesOf := make(map[string][]string) + for _, dependency := range dependencies { + dependenciesOf[dependency.ItemID] = append(dependenciesOf[dependency.ItemID], dependency.DependsOnID) + } + tasksOf := make(map[string][]model.ProjectItemTask) + for _, task := range tasks { + tasksOf[task.ItemID] = append(tasksOf[task.ItemID], task) + } + + for i, item := range items { + filed := model.ProjectItemDetail{ + ProjectItem: item, + Projects: []model.Project{}, + Memberships: orEmpty(membershipsOf[item.ID]), + DependencyIDs: orEmpty(dependenciesOf[item.ID]), + Tasks: orEmpty(tasksOf[item.ID]), + } + for _, membership := range filed.Memberships { + filed.Projects = append(filed.Projects, projectByID[membership.ProjectID]) + } + sortProjectsByPosition(filed.Projects) + out[i] = filed + } + return out, nil +} + +func orEmpty[T any](rows []T) []T { + if rows == nil { + return []T{} + } + return rows +} + +func sortProjectsByPosition(projects []model.Project) { + slices.SortStableFunc(projects, func(a, b model.Project) int { + return cmp.Or( + cmp.Compare(a.Position, b.Position), + a.CreatedAt.Compare(b.CreatedAt), + strings.Compare(a.ID, b.ID), + ) + }) +} + +// refuseUnknownKind reads the lookup table rather than a constant, so adding a kind +// stays an insert. +func refuseUnknownKind(ctx context.Context, reader store.Operations, kind string) error { + known, err := reader.ListProjectKinds(ctx) + if err != nil { + return fmt.Errorf("listing project kinds: %w", err) + } + if slices.Contains(known, kind) { + return nil + } + return refuse(model.ErrUnknownKind, "Unknown project kind %s. Known kinds: %s", quoteName(kind), strings.Join(known, ", ")) +} + +func refuseUnknownStatus(ctx context.Context, reader store.Operations, status string) error { + known, err := reader.ListProjectStatuses(ctx) + if err != nil { + return fmt.Errorf("listing project statuses: %w", err) + } + if slices.Contains(known, status) { + return nil + } + return refuse(model.ErrUnknownStatus, "Unknown project status %s. Known statuses: %s, all", quoteName(status), strings.Join(known, ", ")) +} diff --git a/service/project_status_test.go b/service/project_status_test.go index cfde853..da0ca3a 100644 --- a/service/project_status_test.go +++ b/service/project_status_test.go @@ -78,7 +78,7 @@ func TestDroppingWithoutAReasonIsRefused(t *testing.T) { b := newTestBackend(t) p := mustCreateProject(t, b, "abandoned") - for _, reason := range []*string{nil, ptr(""), ptr(" ")} { + for _, reason := range []*string{nil, ptr("")} { if _, err := b.SetProjectStatus(p.ID, model.StatusDropped, reason); !errors.Is(err, model.ErrDropReasonRequired) { t.Errorf("reason %v: got %v, want ErrDropReasonRequired", reason, err) } @@ -201,8 +201,8 @@ func TestRenamingOntoATakenNameAnswersDuplicateName(t *testing.T) { other := mustCreateProject(t, b, "other") name := "clisteno" - if _, err := b.UpdateProject(other.ID, model.UpdateProject{Name: &name}); !errors.Is(err, model.ErrDuplicateName) || err.Error() != model.ErrDuplicateName.Error() { - t.Errorf("got %v, want ErrDuplicateName as the whole message", err) + if _, err := b.UpdateProject(other.ID, model.UpdateProject{Name: &name}); !errors.Is(err, model.ErrDuplicateName) { + t.Errorf("got %v, want ErrDuplicateName", err) } } @@ -247,8 +247,8 @@ func TestShelvingHidesTheProjectWithoutClosingIt(t *testing.T) { if shelved.ClosedAt != nil { t.Errorf("closed_at = %v, want nil — a shelved project has not ended", shelved.ClosedAt) } - if shelved.StatusReason == nil || *shelved.StatusReason != "waiting on the API" { - t.Errorf("status_reason = %v, want the reason given", shelved.StatusReason) + if shelved.StatusReason != nil { + t.Errorf("status_reason = %v, want nil — shelving closes no question", shelved.StatusReason) } active, err := b.ListProjectsByStatus(model.StatusActive) if err != nil { diff --git a/service/references.go b/service/references.go new file mode 100644 index 0000000..f87e0ec --- /dev/null +++ b/service/references.go @@ -0,0 +1,309 @@ +package service + +import ( + "context" + "errors" + "fmt" + "slices" + "strconv" + "strings" + "unicode" + + "github.com/google/uuid" + + "github.com/datapointchris/projects/model" + "github.com/datapointchris/projects/store" +) + +// ResolveProject answers the project a reference names: its UUID, or its name. +func (s *Service) ResolveProject(ref string) (*model.Project, error) { + project, err := resolveProject(s.ctx(), s.store, ref) + if err != nil { + return nil, err + } + return &project, nil +} + +// ResolveItem answers the item a reference names: its UUID, or its number. +func (s *Service) ResolveItem(ref string) (*model.ProjectItem, error) { + item, err := resolveItem(s.ctx(), s.store, ref) + if err != nil { + return nil, err + } + return &item, nil +} + +func refuse(kind error, format string, args ...any) error { + return &model.Refusal{Kind: kind, Message: fmt.Sprintf(format, args...)} +} + +// canonicalUUID reports whether ref parses as a UUID, in the one spelling a store +// holds it in. +func canonicalUUID(ref string) (string, bool) { + parsed, err := uuid.Parse(ref) + if err != nil { + return "", false + } + return parsed.String(), true +} + +// resolveProject tells the two forms apart without guessing: a UUID parses as one +// and a name does not. +func resolveProject(ctx context.Context, reader store.Operations, ref string) (model.Project, error) { + id, isUUID := canonicalUUID(ref) + if !isUUID { + return projectByName(ctx, reader, ref) + } + project, err := reader.GetProject(ctx, id) + if errors.Is(err, model.ErrNotFound) { + return model.Project{}, refuse(model.ErrNotFound, "project %s not found", ref) + } + if err != nil { + return model.Project{}, fmt.Errorf("getting project: %w", err) + } + return project, nil +} + +// projectByName resolves a name, which is unique only among active projects. The +// active one wins, because the live effort is what a name means while one exists. +// A lone closed match is what makes reopening addressable at all. Several closed +// projects sharing it is ambiguous, and the refusal lists their ids. +func projectByName(ctx context.Context, reader store.Operations, name string) (model.Project, error) { + projects, err := reader.ListProjects(ctx) + if err != nil { + return model.Project{}, fmt.Errorf("listing projects: %w", err) + } + + var matches []model.Project + for _, project := range projects { + if project.Name != name { + continue + } + if project.Status == model.StatusActive { + return project, nil + } + matches = append(matches, project) + } + + switch len(matches) { + case 0: + message := fmt.Sprintf("project %s not found", name) + if near := namesNear(name, projectNamesActiveFirst(projects)); len(near) > 0 { + quoted := make([]string, len(near)) + for i, candidate := range near { + quoted[i] = quoteName(candidate) + } + message += ". A name matches only in full, and these come near it: " + strings.Join(quoted, ", ") + } + return model.Project{}, &model.Refusal{Kind: model.ErrNotFound, Message: message} + case 1: + return matches[0], nil + } + + slices.SortStableFunc(matches, func(a, b model.Project) int { return strings.Compare(a.Status, b.Status) }) + candidates := make([]string, len(matches)) + for i, project := range matches { + candidates[i] = fmt.Sprintf("%s (%s)", project.ID, project.Status) + } + return model.Project{}, refuse(model.ErrAmbiguousName, "%s names %d closed projects and no active one. Use an id: %s", + quoteName(name), len(matches), strings.Join(candidates, ", ")) +} + +// projectNamesActiveFirst keeps the store's board order within each group. +func projectNamesActiveFirst(projects []model.Project) []string { + names := make([]string, 0, len(projects)) + for _, project := range projects { + if project.Status == model.StatusActive { + names = append(names, project.Name) + } + } + for _, project := range projects { + if project.Status != model.StatusActive { + names = append(names, project.Name) + } + } + return names +} + +// resolveItem parses a UUID before testing for digits, because a hyphenless UUID +// can be 32 decimal digits and no number will ever be that long. +func resolveItem(ctx context.Context, reader store.Operations, ref string) (model.ProjectItem, error) { + var item model.ProjectItem + var err error + if id, isUUID := canonicalUUID(ref); isUUID { + item, err = reader.GetItem(ctx, id) + } else if number, isNumber := parseItemNumber(ref); isNumber { + item, err = reader.GetItemByNumber(ctx, number) + } else { + return model.ProjectItem{}, refuse(model.ErrInvalidReference, "%s is not a project item UUID or number", quoteName(ref)) + } + if errors.Is(err, model.ErrNotFound) { + return model.ProjectItem{}, refuse(model.ErrNotFound, "project item %s not found", ref) + } + if err != nil { + return model.ProjectItem{}, fmt.Errorf("getting item: %w", err) + } + return item, nil +} + +func parseItemNumber(ref string) (int, bool) { + if ref == "" { + return 0, false + } + for _, r := range ref { + if r < '0' || r > '9' { + return 0, false + } + } + number, err := strconv.Atoi(ref) + return number, err == nil +} + +// itemHandle is what a refusal calls an item. An item created while sync was +// unreachable has no number yet, so it goes by its id. +func itemHandle(item model.ProjectItem) string { + if item.Number != nil { + return strconv.Itoa(*item.Number) + } + return item.ID +} + +// quoteName writes a name in a sentence: single-quoted, or double-quoted where it +// holds a single quote and no double, with anything unprintable escaped. +func quoteName(name string) string { + quote := '\'' + if strings.ContainsRune(name, '\'') && !strings.ContainsRune(name, '"') { + quote = '"' + } + var quoted strings.Builder + quoted.WriteRune(quote) + for _, r := range name { + switch { + case r == quote || r == '\\': + quoted.WriteRune('\\') + quoted.WriteRune(r) + case r == '\n': + quoted.WriteString(`\n`) + case r == '\t': + quoted.WriteString(`\t`) + case r == '\r': + quoted.WriteString(`\r`) + case !unicode.IsPrint(r): + fmt.Fprintf("ed, `\x%02x`, r) + default: + quoted.WriteRune(r) + } + } + quoted.WriteRune(quote) + return quoted.String() +} + +const ( + nearNamesShown = 5 + nearNameMinimumFit = 0.6 +) + +// namesNear answers the names a reference that resolved to nothing most likely +// meant. Containment comes first, because it is how a short form misses, and names +// a typo away come after it. The order of names is kept within containment, so a +// caller passing active projects first sees them offered first. +func namesNear(typed string, names []string) []string { + var folded []string + original := make(map[string]string, len(names)) + for _, name := range names { + key := strings.ToLower(name) + if _, seen := original[key]; !seen { + original[key] = name + folded = append(folded, key) + } + } + typedFolded := strings.ToLower(typed) + + var ordered []string + for _, key := range folded { + if strings.Contains(key, typedFolded) { + ordered = append(ordered, key) + } + } + for _, key := range closestByFit(typedFolded, folded) { + if !slices.Contains(ordered, key) { + ordered = append(ordered, key) + } + } + if len(ordered) > nearNamesShown { + ordered = ordered[:nearNamesShown] + } + near := make([]string, len(ordered)) + for i, key := range ordered { + near[i] = original[key] + } + return near +} + +// closestByFit keeps the candidates that fit typed well enough, best first, and on +// a tie the alphabetically later one first. +func closestByFit(typed string, candidates []string) []string { + type scored struct { + name string + fit float64 + } + var kept []scored + for _, candidate := range candidates { + if fit := matchRatio(candidate, typed); fit >= nearNameMinimumFit { + kept = append(kept, scored{candidate, fit}) + } + } + slices.SortFunc(kept, func(a, b scored) int { + switch { + case a.fit > b.fit: + return -1 + case a.fit < b.fit: + return 1 + } + return strings.Compare(b.name, a.name) + }) + if len(kept) > nearNamesShown { + kept = kept[:nearNamesShown] + } + closest := make([]string, len(kept)) + for i, candidate := range kept { + closest[i] = candidate.name + } + return closest +} + +// matchRatio is the Ratcliff-Obershelp similarity of two strings: twice the +// characters in their matching blocks, over their combined length. +func matchRatio(a, b string) float64 { + left, right := []rune(a), []rune(b) + if len(left)+len(right) == 0 { + return 1 + } + return 2 * float64(matchedRunes(left, right)) / float64(len(left)+len(right)) +} + +// matchedRunes takes the longest common block, earliest in left and then in right, +// and recurses on what lies either side of it. +func matchedRunes(left, right []rune) int { + bestLeft, bestRight, bestSize := 0, 0, 0 + previousRun := make([]int, len(right)+1) + for i, r := range left { + run := make([]int, len(right)+1) + for j, candidate := range right { + if candidate != r { + continue + } + run[j+1] = previousRun[j] + 1 + if run[j+1] > bestSize { + bestLeft, bestRight, bestSize = i-run[j+1]+1, j-run[j+1]+1, run[j+1] + } + } + previousRun = run + } + if bestSize == 0 { + return 0 + } + return bestSize + + matchedRunes(left[:bestLeft], right[:bestRight]) + + matchedRunes(left[bestLeft+bestSize:], right[bestRight+bestSize:]) +} diff --git a/service/service.go b/service/service.go index b6eff9f..4ee6bf2 100644 --- a/service/service.go +++ b/service/service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "strings" "time" @@ -16,6 +17,9 @@ import ( "github.com/datapointchris/projects/store" ) +// itemListSegment is what follows a project's name in the path of its item list. +const itemListSegment = "/items" + // Service holds every rule about projects, items, memberships, dependencies, tasks and undo. type Service struct { store store.Store @@ -25,6 +29,8 @@ type Service struct { // repoRegistryPath enables the repo-named-project ban. Empty means no // registry and no ban. See RefusingRepoNames. repoRegistryPath string + // requestContext is what each store call runs under. See WithContext. + requestContext context.Context } // Option configures a Service at construction. @@ -57,7 +63,7 @@ func RefusingRepoNames(path string) Option { // New creates a service over a store. func New(st store.Store, opts ...Option) *Service { - s := &Service{store: st} + s := &Service{store: st, requestContext: context.Background()} for _, opt := range opts { opt(s) } @@ -67,14 +73,39 @@ func New(st store.Store, opts ...Option) *Service { // Compile-time check that Service implements Backend. var _ backend.Backend = (*Service)(nil) +// WithContext returns a shallow copy whose store calls run under ctx, in the shape +// of http.Request.WithContext. A request's cancellation and its writer reach the +// store through it, and backend.Backend stays free of a context parameter no local +// caller would use. +func (s *Service) WithContext(ctx context.Context) *Service { + scoped := *s + scoped.requestContext = ctx + return &scoped +} + func (s *Service) ctx() context.Context { - return context.Background() + return s.requestContext } func newID() string { return uuid.Must(uuid.NewV7()).String() } +func now() time.Time { + return time.Now().UTC().Truncate(time.Millisecond) +} + +// idOrNew takes an id a caller chose in the spelling a store holds, or generates one. +func idOrNew(id *string) string { + if id == nil { + return newID() + } + if canonical, isUUID := canonicalUUID(*id); isUUID { + return canonical + } + return *id +} + // --- Projects --- // refuseRepoName rejects a project name the repo registry knows. A missing @@ -94,39 +125,90 @@ func (s *Service) refuseRepoName(name string) error { return nil } -// ListProjectsByStatus takes a status name or model.StatusAll. -func (s *Service) ListProjectsByStatus(status string) ([]model.ProjectWithItemCount, error) { - projects, err := s.store.ListProjectsWithItemCount(s.ctx(), status) +// refuseReservedName keeps a project addressable by name: `x/items` would read as +// project x's item list. +func refuseReservedName(name string) error { + if !strings.HasSuffix(name, itemListSegment) { + return nil + } + return &model.Refusal{ + Kind: model.ErrReservedName, + Field: "name", + Message: fmt.Sprintf("cannot end in %s, which /projects//items reads as an item list", quoteName(itemListSegment)), + } +} + +func refuseDropWithoutReason(status string, reason *string) error { + if status == model.StatusDropped && (reason == nil || *reason == "") { + return refuse(model.ErrDropReasonRequired, "Dropping a project requires a reason. Say why it is dropped rather than deferred.") + } + return nil +} + +// refuseTakenName names the project already holding a name, which the index's own +// answer could not. Only active projects hold one. +func refuseTakenName(ctx context.Context, tx store.Operations, name, excludingID string) error { + projects, err := tx.ListProjects(ctx) if err != nil { - return nil, fmt.Errorf("listing projects: %w", err) + return fmt.Errorf("listing projects: %w", err) } - return projects, nil + for _, project := range projects { + if project.Name == name && project.Status == model.StatusActive && project.ID != excludingID { + return refuse(model.ErrDuplicateName, "An active project named %s already exists (%s).", quoteName(name), project.ID) + } + } + return nil } -func (s *Service) GetProject(id string) (*model.ProjectWithItemCount, error) { - project, err := s.store.GetProjectWithItemCount(s.ctx(), id) +// ListProjectsByStatus takes a status name or model.StatusAll, in board order. +func (s *Service) ListProjectsByStatus(status string) ([]model.ProjectWithItemCount, error) { + counted, _, err := s.projectsWithItemCounts(s.ctx()) if err != nil { - if errors.Is(err, model.ErrNotFound) { - return nil, fmt.Errorf("project %q: %w", id, model.ErrNotFound) + return nil, err + } + kept := make([]model.ProjectWithItemCount, 0, len(counted)) + for _, project := range counted { + if status == model.StatusAll || project.Status == status { + kept = append(kept, project) } - return nil, fmt.Errorf("getting project: %w", err) } - return &project, nil + model.SortProjectsForBoard(kept) + return kept, nil +} + +func (s *Service) GetProject(ref string) (*model.ProjectWithItemCount, error) { + ctx := s.ctx() + project, err := resolveProject(ctx, s.store, ref) + if err != nil { + return nil, err + } + facts, err := s.store.ListProjectItemFacts(ctx) + if err != nil { + return nil, fmt.Errorf("listing item counts: %w", err) + } + counted := withItemCounts([]model.Project{project}, facts) + return &counted[0], nil } func (s *Service) CreateProject(input model.CreateProject) (*model.Project, error) { ctx := s.ctx() + if err := refuseReservedName(input.Name); err != nil { + return nil, err + } if err := s.refuseRepoName(input.Name); err != nil { return nil, err } var created model.Project err := s.store.InTx(ctx, func(tx store.Operations) error { - p, err := tx.CreateProject(ctx, model.Project{ - ID: newID(), - Name: input.Name, - Description: input.Description, - }) + next, err := newProject(ctx, tx, input) + if err != nil { + return err + } + p, err := tx.CreateProject(ctx, next) + if errors.Is(err, model.ErrDuplicateID) { + return refuse(model.ErrDuplicateID, "Project with id %s already exists", next.ID) + } if err != nil { if errors.Is(err, model.ErrDuplicateName) { return model.ErrDuplicateName @@ -142,9 +224,59 @@ func (s *Service) CreateProject(input model.CreateProject) (*model.Project, erro return &created, nil } -func (s *Service) UpdateProject(id string, input model.UpdateProject) (*model.Project, error) { - ctx := s.ctx() +// newProject applies the rules a create runs in the order a caller meets them. A +// project may arrive finished, so a client holding one can record it as it stands. +func newProject(ctx context.Context, tx store.Operations, input model.CreateProject) (model.Project, error) { + next := model.Project{ + ID: idOrNew(input.ID), + Name: input.Name, + Description: input.Description, + Kind: model.KindLife, + Status: model.StatusActive, + StatusReason: input.StatusReason, + } + if input.Kind != nil { + next.Kind = *input.Kind + } + if err := refuseUnknownKind(ctx, tx, next.Kind); err != nil { + return model.Project{}, err + } + if input.Status != nil { + next.Status = *input.Status + } + if err := refuseUnknownStatus(ctx, tx, next.Status); err != nil { + return model.Project{}, err + } + if err := refuseDropWithoutReason(next.Status, next.StatusReason); err != nil { + return model.Project{}, err + } + if next.Status == model.StatusActive { + if err := refuseTakenName(ctx, tx, next.Name, ""); err != nil { + return model.Project{}, err + } + } + if model.ProjectIsTerminal(next.Status) { + closedAt := now() + next.ClosedAt = &closedAt + } + if input.Position != nil { + next.Position = *input.Position + return next, nil + } + projects, err := tx.ListProjects(ctx) + if err != nil { + return model.Project{}, fmt.Errorf("listing projects: %w", err) + } + for _, project := range projects { + next.Position = max(next.Position, project.Position+1) + } + return next, nil +} +// UpdateProject is the one write path a project's fields and its status take, so a +// transition is validated in one place. +func (s *Service) UpdateProject(ref string, input model.UpdateProject) (*model.Project, error) { + ctx := s.ctx() if input.Name != nil { // Checked on rename too: a ban one `edit` walks around is decoration. if err := s.refuseRepoName(*input.Name); err != nil { @@ -154,25 +286,19 @@ func (s *Service) UpdateProject(id string, input model.UpdateProject) (*model.Pr var updated model.Project err := s.store.InTx(ctx, func(tx store.Operations) error { - current, err := tx.GetProject(ctx, id) + current, err := resolveProject(ctx, tx, ref) if err != nil { - if errors.Is(err, model.ErrNotFound) { - return fmt.Errorf("project %q: %w", id, model.ErrNotFound) - } - return fmt.Errorf("getting project for update: %w", err) + return err } - - next := current if input.Name != nil { - next.Name = *input.Name - } - if input.Description != nil { - next.Description = input.Description + if err := refuseReservedName(*input.Name); err != nil { + return err + } } - if input.Position != nil { - next.Position = *input.Position + next, err := projectAfterUpdate(ctx, tx, current, input) + if err != nil { + return err } - p, err := tx.UpdateProject(ctx, next) if err != nil { if errors.Is(err, model.ErrDuplicateName) { @@ -181,7 +307,7 @@ func (s *Service) UpdateProject(id string, input model.UpdateProject) (*model.Pr return fmt.Errorf("updating project: %w", err) } updated = p - return logUndo(ctx, tx, model.UndoActionUpdate, model.UndoEntityProject, id, projectRecordOf(current)) + return logUndo(ctx, tx, model.UndoActionUpdate, model.UndoEntityProject, current.ID, projectRecordOf(current)) }) if err != nil { return nil, err @@ -189,97 +315,139 @@ func (s *Service) UpdateProject(id string, input model.UpdateProject) (*model.Pr return &updated, nil } -// SetProjectStatus moves a project between active, someday, completed and -// dropped. A reason is required when dropping, optional when shelving and -// ignored when reopening. -func (s *Service) SetProjectStatus(id, status string, reason *string) (*model.Project, error) { - ctx := s.ctx() - if status == model.StatusDropped && (reason == nil || strings.TrimSpace(*reason) == "") { - return nil, model.ErrDropReasonRequired +// projectAfterUpdate derives what a transition carries with it, because a client +// free to send a closing date could leave an active project claiming one. A rename +// and a reopen both end with the project holding a name, which only an active +// project does, so both check the name they end with. Moving to someday clears the +// reason and the date as active does, because that work is set aside and not +// closed. A project already closed in this status keeps its date, because +// rewording the reason decides nothing new. +func projectAfterUpdate(ctx context.Context, tx store.Operations, current model.Project, input model.UpdateProject) (model.Project, error) { + next := current + if input.Kind != nil { + if err := refuseUnknownKind(ctx, tx, *input.Kind); err != nil { + return model.Project{}, err + } + next.Kind = *input.Kind + } + if input.Name != nil { + next.Name = *input.Name + } + if input.Description != nil { + next.Description = input.Description + } + if input.ClearDescription { + next.Description = nil + } + if input.Position != nil { + next.Position = *input.Position + } + if input.StatusReason != nil { + next.StatusReason = input.StatusReason + } + if input.ClearStatusReason { + next.StatusReason = nil } - var updated model.Project - err := s.store.InTx(ctx, func(tx store.Operations) error { - current, err := tx.GetProject(ctx, id) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - return fmt.Errorf("project %q: %w", id, model.ErrNotFound) - } - return fmt.Errorf("getting project for status change: %w", err) - } - next, err := projectAfterStatusChange(current, status, reason) - if err != nil { - return err - } - p, err := tx.SetProjectStatus(ctx, next) - if err != nil { - if errors.Is(err, model.ErrDuplicateName) { - return model.ErrDuplicateName - } - return fmt.Errorf("setting project status: %w", err) + resultingStatus := current.Status + if input.Status != nil { + resultingStatus = *input.Status + } + if resultingStatus == model.StatusActive && (next.Name != current.Name || current.Status != model.StatusActive) { + if err := refuseTakenName(ctx, tx, next.Name, current.ID); err != nil { + return model.Project{}, err } - updated = p - return logUndo(ctx, tx, model.UndoActionUpdate, model.UndoEntityProject, id, projectRecordOf(current)) - }) - if err != nil { - return nil, err } - return &updated, nil -} -// projectAfterStatusChange derives what a transition carries with it, so an -// active project can never hold a reason or a closing date. A someday project is -// set aside rather than closed, so it records no close. A project already closed -// in this status keeps its date, because rewording the reason decides nothing new. -func projectAfterStatusChange(current model.Project, status string, reason *string) (model.Project, error) { - next := current - next.Status = status - next.StatusReason = reason - switch status { - case model.StatusActive: + if input.Status == nil { + return next, refuseDropWithoutReason(current.Status, next.StatusReason) + } + if err := refuseUnknownStatus(ctx, tx, *input.Status); err != nil { + return model.Project{}, err + } + if err := refuseDropWithoutReason(*input.Status, next.StatusReason); err != nil { + return model.Project{}, err + } + next.Status = *input.Status + switch { + case !model.ProjectIsTerminal(*input.Status): next.StatusReason = nil next.ClosedAt = nil - case model.StatusSomeday: - next.ClosedAt = nil - case model.StatusCompleted, model.StatusDropped: - if current.ClosedAt == nil || current.Status != status { - now := time.Now().UTC().Truncate(time.Millisecond) - next.ClosedAt = &now - } - default: - return model.Project{}, fmt.Errorf("unknown project status %q", status) + case *input.Status != current.Status: + closedAt := now() + next.ClosedAt = &closedAt } return next, nil } -func (s *Service) DeleteProject(id string) error { +// SetProjectStatus moves a project between active, someday, completed and +// dropped. A reason is required when dropping, kept when completing, and cleared +// on the way to active or someday. +func (s *Service) SetProjectStatus(ref, status string, reason *string) (*model.Project, error) { + return s.UpdateProject(ref, model.UpdateProject{ + Status: &status, + StatusReason: reason, + ClearStatusReason: reason == nil, + }) +} + +// DeleteProject refuses while an incomplete item would be left in no project, and +// takes finished items that only this project holds with it, because an item with +// no project cannot be reached. +func (s *Service) DeleteProject(ref string) error { ctx := s.ctx() return s.store.InTx(ctx, func(tx store.Operations) error { - snapshot, err := captureProject(ctx, tx, id) + project, err := resolveProject(ctx, tx, ref) + if err != nil { + return err + } + held, err := tx.ListItemsInProject(ctx, project.ID) + if err != nil { + return fmt.Errorf("listing the project's items: %w", err) + } + var orphanIDs []string + incomplete := 0 + for _, item := range held { + if item.ProjectCount != 1 { + continue + } + orphanIDs = append(orphanIDs, item.ID) + if !item.Completed { + incomplete++ + } + } + if incomplete > 0 { + return refuse(model.ErrIncompleteOrphans, + "Cannot delete project: %d incomplete item(s) belong only to this project. Complete, move, or delete them first.", incomplete) + } + + snapshot, err := captureProject(ctx, tx, project.ID, orphanIDs) if err != nil { return err } - if err := tx.DeleteProject(ctx, id); err != nil { + for _, id := range orphanIDs { + if err := tx.DeleteItem(ctx, id); err != nil { + return fmt.Errorf("deleting the project's finished item: %w", err) + } + } + if err := tx.DeleteProject(ctx, project.ID); err != nil { return fmt.Errorf("deleting project: %w", err) } - return logUndo(ctx, tx, model.UndoActionDelete, model.UndoEntityProject, id, snapshot) + return logUndo(ctx, tx, model.UndoActionDelete, model.UndoEntityProject, project.ID, snapshot) }) } -func (s *Service) ReorderProject(projectID string, newPosition int) error { +func (s *Service) ReorderProject(ref string, newPosition int) error { ctx := s.ctx() return s.store.InTx(ctx, func(tx store.Operations) error { - current, err := tx.GetProject(ctx, projectID) + current, err := resolveProject(ctx, tx, ref) if err != nil { - if errors.Is(err, model.ErrNotFound) { - return fmt.Errorf("project %q: %w", projectID, model.ErrNotFound) - } - return fmt.Errorf("getting project for reorder: %w", err) + return err } - if err := tx.UpdateProjectPosition(ctx, projectID, newPosition); err != nil { + if err := tx.UpdateProjectPosition(ctx, current.ID, newPosition); err != nil { return fmt.Errorf("reordering project: %w", err) } - return logUndo(ctx, tx, model.UndoActionUpdate, model.UndoEntityProjectPosition, projectID, projectRecordOf(current)) + return logUndo(ctx, tx, model.UndoActionUpdate, model.UndoEntityProjectPosition, current.ID, projectRecordOf(current)) }) } @@ -293,49 +461,63 @@ func (s *Service) ListAllItemsIncludingArchived() ([]model.ProjectItem, error) { return items, nil } -func (s *Service) ListItemsByProject(projectID string) ([]model.ProjectItemInProject, error) { - items, err := s.store.ListItemsByProject(s.ctx(), projectID) +// ListItemsByProject leaves out archived items, in project order. +func (s *Service) ListItemsByProject(projectRef string) ([]model.ProjectItemInProject, error) { + ctx := s.ctx() + project, err := resolveProject(ctx, s.store, projectRef) + if err != nil { + return nil, err + } + items, err := s.store.ListItemsInProject(ctx, project.ID) if err != nil { return nil, fmt.Errorf("listing items by project: %w", err) } - return items, nil + return slices.DeleteFunc(items, func(item model.ProjectItemInProject) bool { return item.Archived }), nil } -func (s *Service) GetItem(id string) (*model.ProjectItemDetail, error) { +// GetItem answers an item with everything filed against it. +func (s *Service) GetItem(ref string) (*model.ProjectItemDetail, error) { ctx := s.ctx() - item, err := s.store.GetItem(ctx, id) + item, err := resolveItem(ctx, s.store, ref) if err != nil { - if errors.Is(err, model.ErrNotFound) { - return nil, fmt.Errorf("item %q: %w", id, model.ErrNotFound) - } - return nil, fmt.Errorf("getting item: %w", err) + return nil, err } - projects, err := s.store.GetItemProjects(ctx, id) + projects, err := s.store.GetItemProjects(ctx, item.ID) if err != nil { return nil, fmt.Errorf("getting item projects: %w", err) } - - depIDs, err := s.store.GetDependencyIDs(ctx, id) + memberships, err := s.store.ListItemMemberships(ctx, item.ID) if err != nil { - return nil, fmt.Errorf("getting dependency IDs: %w", err) + return nil, fmt.Errorf("getting item memberships: %w", err) } - if depIDs == nil { - depIDs = []string{} + dependsOn, err := s.store.ListDependencyItems(ctx, item.ID) + if err != nil { + return nil, fmt.Errorf("getting item dependencies: %w", err) + } + dependencyIDs := make([]string, len(dependsOn)) + for i, dependency := range dependsOn { + dependencyIDs[i] = dependency.ID + } + tasks, err := s.store.ListTasksByItem(ctx, item.ID) + if err != nil { + return nil, fmt.Errorf("getting item tasks: %w", err) } return &model.ProjectItemDetail{ ProjectItem: item, Projects: projects, - DependencyIDs: depIDs, + Memberships: memberships, + DependencyIDs: dependencyIDs, + DependsOn: dependsOn, + Tasks: tasks, }, nil } // emptyRepoUnlinks stores an empty repo as absent rather than "". A repo link is // either a registry name or absent — "" is neither, and it would render as an -// empty "Repo:" line and never match a repo filter. The API server's own CLI -// reads an empty repo flag the same way, as an unlink, because a flag cannot -// send a JSON null. +// empty "Repo:" line and never match a repo filter. The CLI reads an empty repo +// flag the same way, as an unlink, because a flag cannot send a JSON null. func emptyRepoUnlinks(repo *string) *string { if repo == nil || *repo == "" { return nil @@ -345,12 +527,21 @@ func emptyRepoUnlinks(repo *string) *string { func (s *Service) CreateItem(input model.CreateProjectItem) (*model.ProjectItemDetail, error) { if len(input.ProjectIDs) == 0 { - return nil, model.ErrLastProject + return nil, refuse(model.ErrNoProjects, "At least one project_id is required") } ctx := s.ctx() var itemID string err := s.store.InTx(ctx, func(tx store.Operations) error { + projects := make([]model.Project, len(input.ProjectIDs)) + for i, ref := range input.ProjectIDs { + project, err := resolveProject(ctx, tx, ref) + if err != nil { + return err + } + projects[i] = project + } + var number *int if s.assignsItemNumbers { next, err := tx.NextItemNumber(ctx) @@ -360,21 +551,25 @@ func (s *Service) CreateItem(input model.CreateProjectItem) (*model.ProjectItemD number = &next } + id := idOrNew(input.ID) item, err := tx.CreateItem(ctx, model.ProjectItem{ - ID: newID(), + ID: id, Number: number, Title: input.Title, Notes: input.Notes, Repo: emptyRepoUnlinks(input.Repo), }) + if errors.Is(err, model.ErrDuplicateID) { + return refuse(model.ErrDuplicateID, "Project item with id %s already exists", id) + } if err != nil { return fmt.Errorf("creating item: %w", err) } itemID = item.ID - for _, pid := range input.ProjectIDs { - if err := tx.AddItemToProject(ctx, item.ID, pid); err != nil { - return fmt.Errorf("adding item to project %s: %w", pid, err) + for _, project := range projects { + if err := addToBottomOfProject(ctx, tx, item.ID, project.ID); err != nil { + return fmt.Errorf("adding item to project %s: %w", project.ID, err) } } @@ -387,17 +582,50 @@ func (s *Service) CreateItem(input model.CreateProjectItem) (*model.ProjectItemD return s.GetItem(itemID) } -func (s *Service) UpdateItem(id string, input model.UpdateProjectItem) (*model.ProjectItem, error) { +// addToBottomOfProject files an item after every other in the project, and at zero +// in an empty one. +func addToBottomOfProject(ctx context.Context, tx store.Operations, itemID, projectID string) error { + memberships, err := tx.ListProjectMemberships(ctx, projectID) + if err != nil { + return err + } + position := 0 + for _, membership := range memberships { + position = max(position, membership.Position+1) + } + return tx.AddItemToProject(ctx, model.Membership{ItemID: itemID, ProjectID: projectID, Position: position}) +} + +// stampCompletion answers when a row was finished after a write. Only a transition +// writes: re-sending completed on a finished row finished nothing, and on a row +// whose time is unknown it leaves the unknown alone rather than stamping today onto +// history. +func stampCompletion(wasCompleted, isCompleted bool, completedAt *time.Time) *time.Time { + switch { + case isCompleted == wasCompleted: + return completedAt + case isCompleted: + stamped := now() + return &stamped + default: + return nil + } +} + +func (s *Service) UpdateItem(ref string, input model.UpdateProjectItem) (*model.ProjectItem, error) { ctx := s.ctx() var updated model.ProjectItem err := s.store.InTx(ctx, func(tx store.Operations) error { - current, err := tx.GetItem(ctx, id) + current, err := resolveItem(ctx, tx, ref) if err != nil { - if errors.Is(err, model.ErrNotFound) { - return fmt.Errorf("item %q: %w", id, model.ErrNotFound) + return err + } + + if input.Completed != nil && *input.Completed && !current.Completed { + if err := refuseIncompleteTasks(ctx, tx, current.ID); err != nil { + return err } - return fmt.Errorf("getting item for update: %w", err) } next := current @@ -407,11 +635,18 @@ func (s *Service) UpdateItem(id string, input model.UpdateProjectItem) (*model.P if input.Notes != nil { next.Notes = input.Notes } + if input.ClearNotes { + next.Notes = nil + } if input.Repo != nil { next.Repo = emptyRepoUnlinks(input.Repo) } + if input.ClearRepo { + next.Repo = nil + } if input.Completed != nil { next.Completed = *input.Completed + next.CompletedAt = stampCompletion(current.Completed, next.Completed, current.CompletedAt) } if input.Archived != nil { next.Archived = *input.Archived @@ -422,7 +657,7 @@ func (s *Service) UpdateItem(id string, input model.UpdateProjectItem) (*model.P return fmt.Errorf("updating item: %w", err) } updated = item - return logUndo(ctx, tx, model.UndoActionUpdate, model.UndoEntityItem, id, itemRecordOf(current)) + return logUndo(ctx, tx, model.UndoActionUpdate, model.UndoEntityItem, current.ID, itemRecordOf(current)) }) if err != nil { return nil, err @@ -430,89 +665,186 @@ func (s *Service) UpdateItem(id string, input model.UpdateProjectItem) (*model.P return &updated, nil } -func (s *Service) DeleteItem(id string) error { +func refuseIncompleteTasks(ctx context.Context, tx store.Operations, itemID string) error { + tasks, err := tx.ListTasksByItem(ctx, itemID) + if err != nil { + return fmt.Errorf("listing the item's tasks: %w", err) + } + incomplete := 0 + for _, task := range tasks { + if !task.Completed { + incomplete++ + } + } + if incomplete > 0 { + return refuse(model.ErrIncompleteTasks, "Cannot complete item with %d incomplete task(s). Finish all tasks first.", incomplete) + } + return nil +} + +func (s *Service) DeleteItem(ref string) error { ctx := s.ctx() return s.store.InTx(ctx, func(tx store.Operations) error { - snapshot, err := captureItem(ctx, tx, id) + item, err := resolveItem(ctx, tx, ref) + if err != nil { + return err + } + snapshot, err := captureItem(ctx, tx, item.ID) if err != nil { return err } - if err := logUndo(ctx, tx, model.UndoActionDelete, model.UndoEntityItem, id, snapshot); err != nil { + if err := logUndo(ctx, tx, model.UndoActionDelete, model.UndoEntityItem, item.ID, snapshot); err != nil { return err } - if err := tx.DeleteItem(ctx, id); err != nil { + if err := tx.DeleteItem(ctx, item.ID); err != nil { return fmt.Errorf("deleting item: %w", err) } return nil }) } -func (s *Service) ReorderItem(itemID, projectID string, newPosition int) error { +// resolveMembership resolves both references and refuses a pairing that is absent, +// because both can exist while the item is not in that project. +func resolveMembership(ctx context.Context, tx store.Operations, itemRef, projectRef string) (model.ProjectItem, model.Membership, error) { + item, err := resolveItem(ctx, tx, itemRef) + if err != nil { + return model.ProjectItem{}, model.Membership{}, err + } + project, err := resolveProject(ctx, tx, projectRef) + if err != nil { + return model.ProjectItem{}, model.Membership{}, err + } + membership, err := tx.GetMembership(ctx, item.ID, project.ID) + if errors.Is(err, model.ErrNotFound) { + return model.ProjectItem{}, model.Membership{}, refuse(model.ErrNotFound, "Item %s is not in project %s", itemHandle(item), project.Name) + } + if err != nil { + return model.ProjectItem{}, model.Membership{}, fmt.Errorf("getting membership: %w", err) + } + return item, membership, nil +} + +// ReorderItem moves an item to a place in its project and dense-ranks the rest to +// 0..N-1, so moving one shifts its siblings instead of colliding with them. A +// position past the end is clamped, so a caller asking for the bottom of a list +// does not need its length. +func (s *Service) ReorderItem(itemRef, projectRef string, newPosition int) error { ctx := s.ctx() return s.store.InTx(ctx, func(tx store.Operations) error { - current, err := tx.GetMembership(ctx, itemID, projectID) + item, current, err := resolveMembership(ctx, tx, itemRef, projectRef) if err != nil { - if errors.Is(err, model.ErrNotFound) { - // The pairing is what is absent, not either id on its own: both can - // exist while the item is not in that project. - return fmt.Errorf("item %q is not in project %q: %w", itemID, projectID, model.ErrNotFound) + return err + } + held, err := tx.ListItemsInProject(ctx, current.ProjectID) + if err != nil { + return fmt.Errorf("listing the project's items: %w", err) + } + + previous := make([]membershipRecord, len(held)) + remaining := make([]string, 0, len(held)) + wasAt := make(map[string]int, len(held)) + for i, sibling := range held { + previous[i] = membershipRecordOf(model.Membership{ItemID: sibling.ID, ProjectID: current.ProjectID, Position: sibling.Position}) + wasAt[sibling.ID] = sibling.Position + if sibling.ID != item.ID { + remaining = append(remaining, sibling.ID) } - return fmt.Errorf("getting membership for reorder: %w", err) } - if err := tx.UpdateItemPosition(ctx, model.Membership{ - ItemID: itemID, - ProjectID: projectID, - Position: newPosition, - }); err != nil { - return fmt.Errorf("reordering item: %w", err) + landed := max(0, min(newPosition, len(remaining))) + ordered := slices.Insert(remaining, landed, item.ID) + + for position, id := range ordered { + if wasAt[id] == position { + continue + } + if err := tx.UpdateItemPosition(ctx, model.Membership{ItemID: id, ProjectID: current.ProjectID, Position: position}); err != nil { + return fmt.Errorf("reordering item: %w", err) + } } - return logUndo(ctx, tx, model.UndoActionUpdate, model.UndoEntityItemPosition, itemID, membershipRecordOf(current)) + return logUndo(ctx, tx, model.UndoActionUpdate, model.UndoEntityItemPosition, item.ID, itemPositionRecord{ + membershipRecord: membershipRecordOf(current), + Siblings: previous, + }) }) } +// GetItemInProject answers an item as its project sees it. +func (s *Service) GetItemInProject(itemRef, projectRef string) (*model.ProjectItemInProject, error) { + ctx := s.ctx() + item, membership, err := resolveMembership(ctx, s.store, itemRef, projectRef) + if err != nil { + return nil, err + } + held, err := s.store.ListItemsInProject(ctx, membership.ProjectID) + if err != nil { + return nil, fmt.Errorf("listing the project's items: %w", err) + } + for _, candidate := range held { + if candidate.ID == item.ID { + return &candidate, nil + } + } + return nil, refuse(model.ErrNotFound, "Item %s is not in project %s", itemHandle(item), projectRef) +} + // --- Multi-project membership --- -func (s *Service) AddToProject(itemID, projectID string) error { +func (s *Service) AddToProject(itemRef, projectRef string) error { ctx := s.ctx() return s.store.InTx(ctx, func(tx store.Operations) error { - if err := tx.AddItemToProject(ctx, itemID, projectID); err != nil { + item, err := resolveItem(ctx, tx, itemRef) + if err != nil { + return err + } + project, err := resolveProject(ctx, tx, projectRef) + if err != nil { + return err + } + _, err = tx.GetMembership(ctx, item.ID, project.ID) + if err == nil { + return refuse(model.ErrAlreadyInProject, "Item %s is already in project %s", itemHandle(item), project.Name) + } + if !errors.Is(err, model.ErrNotFound) { + return fmt.Errorf("getting membership: %w", err) + } + if err := addToBottomOfProject(ctx, tx, item.ID, project.ID); err != nil { return fmt.Errorf("adding item to project: %w", err) } - return logUndo(ctx, tx, model.UndoActionCreate, model.UndoEntityMembership, itemID, membershipRecord{ - ItemID: itemID, - ProjectID: projectID, + return logUndo(ctx, tx, model.UndoActionCreate, model.UndoEntityMembership, item.ID, membershipRecord{ + ItemID: item.ID, + ProjectID: project.ID, }) }) } -func (s *Service) RemoveFromProject(itemID, projectID string) error { +func (s *Service) RemoveFromProject(itemRef, projectRef string) error { ctx := s.ctx() return s.store.InTx(ctx, func(tx store.Operations) error { - projects, err := tx.GetItemProjects(ctx, itemID) + item, current, err := resolveMembership(ctx, tx, itemRef, projectRef) if err != nil { - return fmt.Errorf("checking project count: %w", err) - } - if len(projects) <= 1 { - return model.ErrLastProject + return err } - - current, err := tx.GetMembership(ctx, itemID, projectID) + memberships, err := tx.ListItemMemberships(ctx, item.ID) if err != nil { - if errors.Is(err, model.ErrNotFound) { - return fmt.Errorf("item %q is not in project %q: %w", itemID, projectID, model.ErrNotFound) - } - return fmt.Errorf("getting membership for remove: %w", err) + return fmt.Errorf("checking project count: %w", err) } - - if err := tx.RemoveItemFromProject(ctx, itemID, projectID); err != nil { + if len(memberships) <= 1 { + return refuse(model.ErrLastProject, "Cannot remove item from its last project. Delete the item instead.") + } + if err := tx.RemoveItemFromProject(ctx, item.ID, current.ProjectID); err != nil { return fmt.Errorf("removing item from project: %w", err) } - return logUndo(ctx, tx, model.UndoActionDelete, model.UndoEntityMembership, itemID, membershipRecordOf(current)) + return logUndo(ctx, tx, model.UndoActionDelete, model.UndoEntityMembership, item.ID, membershipRecordOf(current)) }) } -func (s *Service) GetItemProjects(itemID string) ([]model.Project, error) { - projects, err := s.store.GetItemProjects(s.ctx(), itemID) +func (s *Service) GetItemProjects(itemRef string) ([]model.Project, error) { + ctx := s.ctx() + item, err := resolveItem(ctx, s.store, itemRef) + if err != nil { + return nil, err + } + projects, err := s.store.GetItemProjects(ctx, item.ID) if err != nil { return nil, fmt.Errorf("getting item projects: %w", err) } @@ -521,48 +853,83 @@ func (s *Service) GetItemProjects(itemID string) ([]model.Project, error) { // --- Dependencies --- -func (s *Service) AddDependency(itemID, dependsOn string) error { +func (s *Service) AddDependency(itemRef, dependsOnRef string) error { ctx := s.ctx() - edge := model.Dependency{ItemID: itemID, DependsOnID: dependsOn} return s.store.InTx(ctx, func(tx store.Operations) error { + item, err := resolveItem(ctx, tx, itemRef) + if err != nil { + return err + } + dependsOn, err := resolveItem(ctx, tx, dependsOnRef) + if err != nil { + return err + } + if item.ID == dependsOn.ID { + return refuse(model.ErrSelfDependency, "An item cannot depend on itself") + } + deps, err := tx.ListAllDependencies(ctx) if err != nil { return fmt.Errorf("getting dependencies for cycle check: %w", err) } - adj := make(map[string][]string) for _, d := range deps { + if d.ItemID == item.ID && d.DependsOnID == dependsOn.ID { + return refuse(model.ErrDuplicateDependency, "Dependency already exists: item %s depends on %s", itemHandle(item), itemHandle(dependsOn)) + } adj[d.DependsOnID] = append(adj[d.DependsOnID], d.ItemID) } - - if graph.WouldCycle(adj, dependsOn, itemID) { - return model.ErrCyclicDependency + if graph.WouldCycle(adj, dependsOn.ID, item.ID) { + return refuse(model.ErrCyclicDependency, "Adding dependency would create a cycle: %s -> %s", itemHandle(item), itemHandle(dependsOn)) } + edge := model.Dependency{ItemID: item.ID, DependsOnID: dependsOn.ID} if err := tx.AddDependency(ctx, edge); err != nil { return fmt.Errorf("adding dependency: %w", err) } - return logUndo(ctx, tx, model.UndoActionCreate, model.UndoEntityDependency, itemID, dependencyRecord(edge)) + return logUndo(ctx, tx, model.UndoActionCreate, model.UndoEntityDependency, item.ID, dependencyRecord(edge)) }) } -func (s *Service) RemoveDependency(itemID, dependsOn string) error { +func (s *Service) RemoveDependency(itemRef, dependsOnRef string) error { ctx := s.ctx() - edge := model.Dependency{ItemID: itemID, DependsOnID: dependsOn} return s.store.InTx(ctx, func(tx store.Operations) error { + item, err := resolveItem(ctx, tx, itemRef) + if err != nil { + return err + } + dependsOn, err := resolveItem(ctx, tx, dependsOnRef) + if err != nil { + return err + } + edge := model.Dependency{ItemID: item.ID, DependsOnID: dependsOn.ID} + deps, err := tx.ListDependenciesInvolvingItem(ctx, item.ID) + if err != nil { + return fmt.Errorf("getting dependencies: %w", err) + } + if !slices.Contains(deps, edge) { + return refuse(model.ErrNotFound, "No dependency: item %s -> %s", itemHandle(item), itemHandle(dependsOn)) + } if err := tx.RemoveDependency(ctx, edge); err != nil { return fmt.Errorf("removing dependency: %w", err) } - return logUndo(ctx, tx, model.UndoActionDelete, model.UndoEntityDependency, itemID, dependencyRecord(edge)) + return logUndo(ctx, tx, model.UndoActionDelete, model.UndoEntityDependency, item.ID, dependencyRecord(edge)) }) } -func (s *Service) GetBlockers(itemID string) ([]model.ProjectItem, error) { - items, err := s.store.GetBlockers(s.ctx(), itemID) +// GetBlockers answers what an item waits on: its incomplete dependencies, archived +// ones included, since an archived dependency still is not finished. +func (s *Service) GetBlockers(itemRef string) ([]model.ProjectItem, error) { + ctx := s.ctx() + item, err := resolveItem(ctx, s.store, itemRef) + if err != nil { + return nil, err + } + items, err := s.store.ListDependencyItems(ctx, item.ID) if err != nil { return nil, fmt.Errorf("getting blockers: %w", err) } - return items, nil + return slices.DeleteFunc(items, func(dependency model.ProjectItem) bool { return dependency.Completed }), nil } func (s *Service) ListDependencies() ([]model.Dependency, error) { @@ -583,25 +950,48 @@ func (s *Service) ListMemberships() ([]model.Membership, error) { // --- Tasks --- -func (s *Service) ListTasks(itemID string) ([]model.ProjectItemTask, error) { - tasks, err := s.store.ListTasksByItem(s.ctx(), itemID) +func (s *Service) ListTasks(itemRef string) ([]model.ProjectItemTask, error) { + ctx := s.ctx() + item, err := resolveItem(ctx, s.store, itemRef) + if err != nil { + return nil, err + } + tasks, err := s.store.ListTasksByItem(ctx, item.ID) if err != nil { return nil, fmt.Errorf("listing tasks: %w", err) } return tasks, nil } -func (s *Service) CreateTask(itemID string, input model.CreateProjectItemTask) (*model.ProjectItemTask, error) { +func (s *Service) CreateTask(itemRef string, input model.CreateProjectItemTask) (*model.ProjectItemTask, error) { ctx := s.ctx() - id := newID() + id := idOrNew(input.ID) var created model.ProjectItemTask err := s.store.InTx(ctx, func(tx store.Operations) error { + item, err := resolveItem(ctx, tx, itemRef) + if err != nil { + return err + } + position := input.Position + if position == 0 { + tasks, err := tx.ListTasksByItem(ctx, item.ID) + if err != nil { + return fmt.Errorf("listing the item's tasks: %w", err) + } + for _, task := range tasks { + position = max(position, task.Position+1) + } + } t, err := tx.CreateTask(ctx, model.ProjectItemTask{ - ID: id, - ItemID: itemID, - Title: input.Title, + ID: id, + ItemID: item.ID, + Title: input.Title, + Position: position, }) + if errors.Is(err, model.ErrDuplicateID) { + return refuse(model.ErrDuplicateID, "Task with id %s already exists", id) + } if err != nil { return fmt.Errorf("creating task: %w", err) } @@ -616,17 +1006,31 @@ func (s *Service) CreateTask(itemID string, input model.CreateProjectItemTask) ( return &created, nil } -func (s *Service) UpdateTask(itemID, taskID string, input model.UpdateProjectItemTask) (*model.ProjectItemTask, error) { +// resolveTask refuses a task that exists on another item, because a task is +// addressed through its item. +func resolveTask(ctx context.Context, tx store.Operations, itemRef, taskID string) (model.ProjectItemTask, error) { + item, err := resolveItem(ctx, tx, itemRef) + if err != nil { + return model.ProjectItemTask{}, err + } + task, err := tx.GetTask(ctx, taskID) + if errors.Is(err, model.ErrNotFound) || (err == nil && task.ItemID != item.ID) { + return model.ProjectItemTask{}, refuse(model.ErrNotFound, "project item task %s not found", taskID) + } + if err != nil { + return model.ProjectItemTask{}, fmt.Errorf("getting task: %w", err) + } + return task, nil +} + +func (s *Service) UpdateTask(itemRef, taskID string, input model.UpdateProjectItemTask) (*model.ProjectItemTask, error) { ctx := s.ctx() var updated model.ProjectItemTask err := s.store.InTx(ctx, func(tx store.Operations) error { - current, err := tx.GetTask(ctx, taskID) + current, err := resolveTask(ctx, tx, itemRef, taskID) if err != nil { - if errors.Is(err, model.ErrNotFound) { - return fmt.Errorf("task %q: %w", taskID, model.ErrNotFound) - } - return fmt.Errorf("getting task for update: %w", err) + return err } next := current @@ -635,6 +1039,7 @@ func (s *Service) UpdateTask(itemID, taskID string, input model.UpdateProjectIte } if input.Completed != nil { next.Completed = *input.Completed + next.CompletedAt = stampCompletion(current.Completed, next.Completed, current.CompletedAt) } if input.Position != nil { next.Position = *input.Position @@ -645,7 +1050,7 @@ func (s *Service) UpdateTask(itemID, taskID string, input model.UpdateProjectIte return fmt.Errorf("updating task: %w", err) } updated = t - return logUndo(ctx, tx, model.UndoActionUpdate, model.UndoEntityTask, taskID, taskRecordOf(current)) + return logUndo(ctx, tx, model.UndoActionUpdate, model.UndoEntityTask, current.ID, taskRecordOf(current)) }) if err != nil { return nil, err @@ -653,31 +1058,29 @@ func (s *Service) UpdateTask(itemID, taskID string, input model.UpdateProjectIte return &updated, nil } -func (s *Service) DeleteTask(itemID, taskID string) error { +func (s *Service) DeleteTask(itemRef, taskID string) error { ctx := s.ctx() return s.store.InTx(ctx, func(tx store.Operations) error { - current, err := tx.GetTask(ctx, taskID) + current, err := resolveTask(ctx, tx, itemRef, taskID) if err != nil { - if errors.Is(err, model.ErrNotFound) { - return fmt.Errorf("task %q: %w", taskID, model.ErrNotFound) - } - return fmt.Errorf("getting task for delete: %w", err) + return err } - if err := tx.DeleteTask(ctx, taskID); err != nil { + if err := tx.DeleteTask(ctx, current.ID); err != nil { return fmt.Errorf("deleting task: %w", err) } - return logUndo(ctx, tx, model.UndoActionDelete, model.UndoEntityTask, taskID, taskRecordOf(current)) + return logUndo(ctx, tx, model.UndoActionDelete, model.UndoEntityTask, current.ID, taskRecordOf(current)) }) } // --- Search --- +// Search leaves out archived items, which SearchItems keeps. func (s *Service) Search(query string) ([]model.ProjectItem, error) { items, err := s.store.SearchItems(s.ctx(), query) if err != nil { return nil, fmt.Errorf("searching items: %w", err) } - return items, nil + return slices.DeleteFunc(items, func(item model.ProjectItem) bool { return item.Archived }), nil } // --- Filters --- @@ -690,10 +1093,18 @@ func (s *Service) ListBlocked() ([]model.ProjectItem, error) { return items, nil } -func (s *Service) ListArchived(projectID string) ([]model.ProjectItemInProject, error) { - items, err := s.store.ListArchivedItems(s.ctx(), projectID) +// ListArchived orders by most recently updated. +func (s *Service) ListArchived(projectRef string) ([]model.ProjectItemInProject, error) { + ctx := s.ctx() + project, err := resolveProject(ctx, s.store, projectRef) + if err != nil { + return nil, err + } + items, err := s.store.ListItemsInProject(ctx, project.ID) if err != nil { return nil, fmt.Errorf("listing archived: %w", err) } - return items, nil + archived := slices.DeleteFunc(items, func(item model.ProjectItemInProject) bool { return !item.Archived }) + slices.SortStableFunc(archived, func(a, b model.ProjectItemInProject) int { return b.UpdatedAt.Compare(a.UpdatedAt) }) + return archived, nil } diff --git a/service/service_test.go b/service/service_test.go index d578cb1..15b7a87 100644 --- a/service/service_test.go +++ b/service/service_test.go @@ -1,9 +1,11 @@ package service import ( + "context" "database/sql" "errors" "fmt" + "os" "path/filepath" "slices" "strings" @@ -12,11 +14,24 @@ import ( "time" "github.com/datapointchris/projects/model" + "github.com/datapointchris/projects/store/postgres" + "github.com/datapointchris/projects/store/postgres/postgrestest" "github.com/datapointchris/projects/store/sqlite" ) +// testStoreEnv names the store this suite's rules run over, so one rule is proved on +// both stores by one test. CI runs the suite once for each. +const testStoreEnv = "PROJECTS_TEST_STORE" + +func runsOnPostgres() bool { + return os.Getenv(testStoreEnv) == "postgres" +} + func newTestBackend(t *testing.T) *Service { t.Helper() + if runsOnPostgres() { + return New(postgres.New(postgrestest.Pool(t)), AssigningItemNumbers()) + } database, err := sqlite.Open(":memory:") if err != nil { t.Fatalf("opening test db: %v", err) @@ -129,8 +144,8 @@ func TestCreateItemRequiresProject(t *testing.T) { Title: "orphan task", ProjectIDs: []string{}, }) - if err != model.ErrLastProject { - t.Errorf("expected ErrLastProject, got %v", err) + if !errors.Is(err, model.ErrNoProjects) { + t.Errorf("expected ErrNoProjects, got %v", err) } } @@ -228,7 +243,7 @@ func TestRemoveFromProjectPreservesLastProject(t *testing.T) { }) err := b.RemoveFromProject(item.ID, p.ID) - if err != model.ErrLastProject { + if !errors.Is(err, model.ErrLastProject) { t.Errorf("expected ErrLastProject, got %v", err) } } @@ -249,7 +264,7 @@ func TestDependencyCycleDetection(t *testing.T) { } err := b.AddDependency(t3.ID, t1.ID) - if err != model.ErrCyclicDependency { + if !errors.Is(err, model.ErrCyclicDependency) { t.Errorf("expected ErrCyclicDependency, got %v", err) } } @@ -430,7 +445,7 @@ func TestUndoWhenEmpty(t *testing.T) { } } -func TestProjectItemCountExcludesArchived(t *testing.T) { +func TestProjectCountsPartitionItemCount(t *testing.T) { b := newTestBackend(t) p := mustCreateProject(t, b, "work") @@ -444,8 +459,8 @@ func TestProjectItemCountExcludesArchived(t *testing.T) { if err != nil { t.Fatalf("getting project: %v", err) } - if project.ItemCount != 1 { - t.Errorf("expected item_count 1 (excluding archived), got %d", project.ItemCount) + if project.ItemCount != 2 || project.OpenCount != 1 || project.CompletedCount != 0 { + t.Errorf("counts = %d items, %d open, %d completed, want 2, 1, 0", project.ItemCount, project.OpenCount, project.CompletedCount) } } @@ -847,14 +862,27 @@ func TestUndoPreservesDeletedItemTimestamps(t *testing.T) { // refuses that upgrade once another writer has committed unless the transaction // began with the write lock. func TestAnEditSurvivesASecondWriter(t *testing.T) { - path := filepath.Join(t.TempDir(), "projects.db") - writer := func() *Service { - database, err := sqlite.Open(path) - if err != nil { - t.Fatalf("opening database: %v", err) + var writer func() *Service + if runsOnPostgres() { + url := postgrestest.URL(t) + writer = func() *Service { + pool, err := postgres.Connect(context.Background(), url) + if err != nil { + t.Fatalf("connecting: %v", err) + } + t.Cleanup(pool.Close) + return New(postgres.New(pool), AssigningItemNumbers()) + } + } else { + path := filepath.Join(t.TempDir(), "projects.db") + writer = func() *Service { + database, err := sqlite.Open(path) + if err != nil { + t.Fatalf("opening database: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + return New(sqlite.New(database)) } - t.Cleanup(func() { _ = database.Close() }) - return New(sqlite.New(database)) } first, second := writer(), writer() p := mustCreateProject(t, first, "work") @@ -953,7 +981,7 @@ func TestUndoReadsEntriesAnEarlierReleaseWrote(t *testing.T) { t.Run("project snapshot", func(t *testing.T) { b, database := newTestBackendWithDatabase(t) - const projectID = "project-from-the-log" + const projectID = "01900000-0000-7000-8000-000000000001" writeUndoEntry(t, database, "delete", "project", projectID, fmt.Sprintf( `{"project":{"ID":%q,"Name":"as it was","Description":{"String":"","Valid":false},"Status":"active",`+ `"StatusReason":{"String":"","Valid":false},"ClosedAt":{"String":"","Valid":false},`+ @@ -977,7 +1005,7 @@ func TestUndoReadsEntriesAnEarlierReleaseWrote(t *testing.T) { p := mustCreateProject(t, b, "work") blocker := mustCreateItem(t, b, model.CreateProjectItem{Title: "blocker", ProjectIDs: []string{p.ID}}) - const itemID, taskID = "item-from-the-log", "task-from-the-log" + const itemID, taskID = "01900000-0000-7000-8000-000000000002", "01900000-0000-7000-8000-000000000003" writeUndoEntry(t, database, "delete", "item", itemID, fmt.Sprintf( `{"item":{"ID":%[1]q,"Number":{"Int64":41,"Valid":true},"Title":"as it was",`+ `"Notes":{"String":"its notes","Valid":true},"Repo":{"String":"its-repo","Valid":true},`+ diff --git a/service/undo.go b/service/undo.go index fc0b80d..ccfdc2d 100644 --- a/service/undo.go +++ b/service/undo.go @@ -208,7 +208,7 @@ func undoItemPosition(ctx context.Context, tx store.Operations, entry model.Undo if entry.PreviousState == nil { return fmt.Errorf("item reorder undo entry %d has no previous state", entry.ID) } - var prev membershipRecord + var prev itemPositionRecord if err := json.Unmarshal([]byte(*entry.PreviousState), &prev); err != nil { return fmt.Errorf("unmarshaling previous item position: %w", err) } @@ -216,6 +216,11 @@ func undoItemPosition(ctx context.Context, tx store.Operations, entry model.Undo if err := tx.UpdateItemPosition(ctx, prev.toModel()); err != nil { return fmt.Errorf("restoring item position: %w", err) } + for _, sibling := range prev.Siblings { + if err := tx.UpdateItemPosition(ctx, sibling.toModel()); err != nil { + return fmt.Errorf("restoring a sibling's position: %w", err) + } + } result.Detail = &model.UndoDetail{ ItemID: prev.ItemID, @@ -321,11 +326,13 @@ type itemSnapshot struct { Tasks []taskRecord `json:"tasks"` } -// projectSnapshot is the project equivalent. Memberships cascade off a -// project delete; the items on the other end of them do not. +// projectSnapshot is the project equivalent. Memberships cascade off a project +// delete, and the items on the other end of them stay unless the project was the +// last to hold them. Orphans are those. type projectSnapshot struct { Project projectRecord `json:"project"` Memberships []membershipRecord `json:"memberships"` + Orphans []itemSnapshot `json:"orphans,omitempty"` } func captureItem(ctx context.Context, reader store.Operations, id string) (*itemSnapshot, error) { @@ -356,19 +363,24 @@ func captureItem(ctx context.Context, reader store.Operations, id string) (*item }, nil } -func captureProject(ctx context.Context, reader store.Operations, id string) (*projectSnapshot, error) { +func captureProject(ctx context.Context, reader store.Operations, id string, orphanIDs []string) (*projectSnapshot, error) { project, err := reader.GetProject(ctx, id) if err != nil { - if errors.Is(err, model.ErrNotFound) { - return nil, fmt.Errorf("project %q: %w", id, model.ErrNotFound) - } return nil, fmt.Errorf("getting project for delete: %w", err) } memberships, err := reader.ListProjectMemberships(ctx, id) if err != nil { return nil, fmt.Errorf("capturing project memberships: %w", err) } - return &projectSnapshot{Project: projectRecordOf(project), Memberships: membershipRecordsOf(memberships)}, nil + snapshot := &projectSnapshot{Project: projectRecordOf(project), Memberships: membershipRecordsOf(memberships)} + for _, orphanID := range orphanIDs { + orphan, err := captureItem(ctx, reader, orphanID) + if err != nil { + return nil, err + } + snapshot.Orphans = append(snapshot.Orphans, *orphan) + } + return snapshot, nil } // decodeItemSnapshot accepts either shape of previous_state: the snapshot @@ -404,6 +416,10 @@ func restoreItemSnapshot(ctx context.Context, tx store.Operations, snapshot *ite if err := tx.RestoreItem(ctx, snapshot.Item.toModel()); err != nil { return fmt.Errorf("recreating deleted item: %w", err) } + return restoreWhatCascadedOffItem(ctx, tx, snapshot) +} + +func restoreWhatCascadedOffItem(ctx context.Context, tx store.Operations, snapshot *itemSnapshot) error { for _, membership := range snapshot.Memberships { if err := tx.RestoreMembership(ctx, membership.toModel()); err != nil { return fmt.Errorf("restoring item membership: %w", err) @@ -422,10 +438,22 @@ func restoreItemSnapshot(ctx context.Context, tx store.Operations, snapshot *ite return nil } +// restoreProjectSnapshot puts every row back before any child, because one orphan +// may depend on another. func restoreProjectSnapshot(ctx context.Context, tx store.Operations, snapshot *projectSnapshot) error { if err := tx.RestoreProject(ctx, snapshot.Project.toModel()); err != nil { return fmt.Errorf("recreating deleted project: %w", err) } + for i := range snapshot.Orphans { + if err := tx.RestoreItem(ctx, snapshot.Orphans[i].Item.toModel()); err != nil { + return fmt.Errorf("recreating the project's finished item: %w", err) + } + } + for i := range snapshot.Orphans { + if err := restoreWhatCascadedOffItem(ctx, tx, &snapshot.Orphans[i]); err != nil { + return err + } + } for _, membership := range snapshot.Memberships { if err := tx.RestoreMembership(ctx, membership.toModel()); err != nil { return fmt.Errorf("restoring project membership: %w", err) diff --git a/service/undo_records.go b/service/undo_records.go index 56142f5..397f980 100644 --- a/service/undo_records.go +++ b/service/undo_records.go @@ -26,6 +26,7 @@ type projectRecord struct { ID string Name string Description nullableText + Kind string Status string StatusReason nullableText ClosedAt nullableText @@ -34,15 +35,16 @@ type projectRecord struct { } type itemRecord struct { - ID string - Number nullableInt - Title string - Notes nullableText - Repo nullableText - Completed int64 - Archived int64 - CreatedAt string - UpdatedAt string + ID string + Number nullableInt + Title string + Notes nullableText + Repo nullableText + Completed int64 + CompletedAt nullableText + Archived int64 + CreatedAt string + UpdatedAt string } type membershipRecord struct { @@ -51,18 +53,27 @@ type membershipRecord struct { Position int64 } +// itemPositionRecord is an item's place before a reorder, and every sibling's, +// because the reorder renumbered the whole project. An older entry has no Siblings +// and reverses the one position. +type itemPositionRecord struct { + membershipRecord + Siblings []membershipRecord `json:",omitempty"` +} + type dependencyRecord struct { ItemID string DependsOnID string } type taskRecord struct { - ID string - ItemID string - Title string - Completed int64 - Position int64 - CreatedAt string + ID string + ItemID string + Title string + Completed int64 + CompletedAt nullableText + Position int64 + CreatedAt string } func projectRecordOf(p model.Project) projectRecord { @@ -70,6 +81,7 @@ func projectRecordOf(p model.Project) projectRecord { ID: p.ID, Name: p.Name, Description: textOf(p.Description), + Kind: p.Kind, Status: p.Status, StatusReason: textOf(p.StatusReason), ClosedAt: timeTextOf(p.ClosedAt), @@ -78,11 +90,18 @@ func projectRecordOf(p model.Project) projectRecord { } } +// toModel reads an older entry, written before projects had a kind, as the kind a +// project takes when nothing names one. func (r projectRecord) toModel() model.Project { + kind := r.Kind + if kind == "" { + kind = model.KindLife + } return model.Project{ ID: r.ID, Name: r.Name, Description: r.Description.pointer(), + Kind: kind, Status: r.Status, StatusReason: r.StatusReason.pointer(), ClosedAt: r.ClosedAt.timePointer(), @@ -93,14 +112,15 @@ func (r projectRecord) toModel() model.Project { func itemRecordOf(i model.ProjectItem) itemRecord { record := itemRecord{ - ID: i.ID, - Title: i.Title, - Notes: textOf(i.Notes), - Repo: textOf(i.Repo), - Completed: intOf(i.Completed), - Archived: intOf(i.Archived), - CreatedAt: i.CreatedAt.Format(time.RFC3339Nano), - UpdatedAt: i.UpdatedAt.Format(time.RFC3339Nano), + ID: i.ID, + Title: i.Title, + Notes: textOf(i.Notes), + Repo: textOf(i.Repo), + Completed: intOf(i.Completed), + CompletedAt: timeTextOf(i.CompletedAt), + Archived: intOf(i.Archived), + CreatedAt: i.CreatedAt.Format(time.RFC3339Nano), + UpdatedAt: i.UpdatedAt.Format(time.RFC3339Nano), } if i.Number != nil { record.Number = nullableInt{Int64: int64(*i.Number), Valid: true} @@ -110,14 +130,15 @@ func itemRecordOf(i model.ProjectItem) itemRecord { func (r itemRecord) toModel() model.ProjectItem { item := model.ProjectItem{ - ID: r.ID, - Title: r.Title, - Notes: r.Notes.pointer(), - Repo: r.Repo.pointer(), - Completed: r.Completed != 0, - Archived: r.Archived != 0, - CreatedAt: parseRecordTime(r.CreatedAt), - UpdatedAt: parseRecordTime(r.UpdatedAt), + ID: r.ID, + Title: r.Title, + Notes: r.Notes.pointer(), + Repo: r.Repo.pointer(), + Completed: r.Completed != 0, + CompletedAt: r.CompletedAt.timePointer(), + Archived: r.Archived != 0, + CreatedAt: parseRecordTime(r.CreatedAt), + UpdatedAt: parseRecordTime(r.UpdatedAt), } if r.Number.Valid { number := int(r.Number.Int64) @@ -136,23 +157,25 @@ func (r membershipRecord) toModel() model.Membership { func taskRecordOf(t model.ProjectItemTask) taskRecord { return taskRecord{ - ID: t.ID, - ItemID: t.ItemID, - Title: t.Title, - Completed: intOf(t.Completed), - Position: int64(t.Position), - CreatedAt: t.CreatedAt.Format(time.RFC3339Nano), + ID: t.ID, + ItemID: t.ItemID, + Title: t.Title, + Completed: intOf(t.Completed), + CompletedAt: timeTextOf(t.CompletedAt), + Position: int64(t.Position), + CreatedAt: t.CreatedAt.Format(time.RFC3339Nano), } } func (r taskRecord) toModel() model.ProjectItemTask { return model.ProjectItemTask{ - ID: r.ID, - ItemID: r.ItemID, - Title: r.Title, - Completed: r.Completed != 0, - Position: int(r.Position), - CreatedAt: parseRecordTime(r.CreatedAt), + ID: r.ID, + ItemID: r.ItemID, + Title: r.Title, + Completed: r.Completed != 0, + CompletedAt: r.CompletedAt.timePointer(), + Position: int(r.Position), + CreatedAt: parseRecordTime(r.CreatedAt), } } diff --git a/store/postgres/convert.go b/store/postgres/convert.go new file mode 100644 index 0000000..3a28377 --- /dev/null +++ b/store/postgres/convert.go @@ -0,0 +1,87 @@ +package postgres + +import ( + "time" + + "github.com/datapointchris/projects/model" + "github.com/datapointchris/projects/store/postgres/generated" +) + +// listOf converts every row, and answers an empty list rather than nil. +func listOf[Row, Model any](rows []Row, convert func(Row) Model) []Model { + out := make([]Model, len(rows)) + for i, row := range rows { + out[i] = convert(row) + } + return out +} + +func utcPointer(t *time.Time) *time.Time { + if t == nil { + return nil + } + inUTC := t.UTC() + return &inUTC +} + +func toModelProject(p generated.Project) model.Project { + return model.Project{ + ID: p.ID, + Name: p.Name, + Description: p.Description, + Kind: p.Kind, + Status: p.Status, + StatusReason: p.StatusReason, + ClosedAt: utcPointer(p.ClosedAt), + Position: int(p.Position), + CreatedAt: p.CreatedAt.UTC(), + } +} + +func toModelItem(i generated.ProjectItem) model.ProjectItem { + number := int(i.Number) + return model.ProjectItem{ + ID: i.ID, + Number: &number, + Title: i.Title, + Notes: i.Notes, + Repo: i.Repo, + Completed: i.Completed, + CompletedAt: utcPointer(i.CompletedAt), + Archived: i.Archived, + CreatedAt: i.CreatedAt.UTC(), + UpdatedAt: i.UpdatedAt.UTC(), + } +} + +func toModelItemInProject(r generated.ListItemsInProjectRow) model.ProjectItemInProject { + return model.ProjectItemInProject{ + ProjectItem: toModelItem(generated.ProjectItem{ + ID: r.ID, Number: r.Number, Title: r.Title, Notes: r.Notes, Repo: r.Repo, + Completed: r.Completed, CompletedAt: r.CompletedAt, Archived: r.Archived, + CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, + }), + Position: int(r.MembershipPosition), + ProjectCount: int(r.ProjectCount), + } +} + +func toModelMembership(m generated.ProjectItemMembership) model.Membership { + return model.Membership{ItemID: m.ItemID, ProjectID: m.ProjectID, Position: int(m.Position)} +} + +func toModelDependency(d generated.ProjectItemDependency) model.Dependency { + return model.Dependency{ItemID: d.ItemID, DependsOnID: d.DependsOnID} +} + +func toModelTask(t generated.ProjectItemTask) model.ProjectItemTask { + return model.ProjectItemTask{ + ID: t.ID, + ItemID: t.ItemID, + Title: t.Title, + Completed: t.Completed, + CompletedAt: utcPointer(t.CompletedAt), + Position: int(t.Position), + CreatedAt: t.CreatedAt.UTC(), + } +} diff --git a/store/postgres/generated/db.go b/store/postgres/generated/db.go new file mode 100644 index 0000000..a332b02 --- /dev/null +++ b/store/postgres/generated/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package generated + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/store/postgres/generated/models.go b/store/postgres/generated/models.go new file mode 100644 index 0000000..8aa693e --- /dev/null +++ b/store/postgres/generated/models.go @@ -0,0 +1,84 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package generated + +import ( + "time" +) + +type AuditTrail struct { + ID int64 + ChangedAt time.Time + TableName string + RecordID string + Operation string + Changes []byte + Actor *string + RequestID *string + JobRunID *string +} + +type Project struct { + ID string + Name string + Description *string + Kind string + Status string + StatusReason *string + ClosedAt *time.Time + Position int32 + CreatedAt time.Time +} + +type ProjectItem struct { + ID string + Number int64 + Title string + Notes *string + Repo *string + Completed bool + CompletedAt *time.Time + Archived bool + CreatedAt time.Time + UpdatedAt time.Time +} + +type ProjectItemDependency struct { + ItemID string + DependsOnID string +} + +type ProjectItemMembership struct { + ItemID string + ProjectID string + Position int32 +} + +type ProjectItemTask struct { + ID string + ItemID string + Title string + Completed bool + CompletedAt *time.Time + Position int32 + CreatedAt time.Time +} + +type ProjectKind struct { + Name string +} + +type ProjectStatus struct { + Name string +} + +type UndoLog struct { + ID int64 + Action string + EntityType string + EntityID string + PreviousState *string + CreatedAt time.Time +} diff --git a/store/postgres/generated/queries.sql.go b/store/postgres/generated/queries.sql.go new file mode 100644 index 0000000..ccda925 --- /dev/null +++ b/store/postgres/generated/queries.sql.go @@ -0,0 +1,1307 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: queries.sql + +package generated + +import ( + "context" + "time" +) + +const addDependency = `-- name: AddDependency :exec +INSERT INTO project_item_dependencies (item_id, depends_on_id) +VALUES ($1, $2) +` + +type AddDependencyParams struct { + ItemID string + DependsOnID string +} + +func (q *Queries) AddDependency(ctx context.Context, arg AddDependencyParams) error { + _, err := q.db.Exec(ctx, addDependency, arg.ItemID, arg.DependsOnID) + return err +} + +const addItemToProject = `-- name: AddItemToProject :exec +INSERT INTO project_item_memberships (item_id, project_id, position) +VALUES ($1, $2, $3) +` + +type AddItemToProjectParams struct { + ItemID string + ProjectID string + ItemPosition int32 +} + +func (q *Queries) AddItemToProject(ctx context.Context, arg AddItemToProjectParams) error { + _, err := q.db.Exec(ctx, addItemToProject, arg.ItemID, arg.ProjectID, arg.ItemPosition) + return err +} + +const bindWriter = `-- name: BindWriter :exec +SELECT + set_config('projects.actor', $1::text, true), + set_config('projects.request_id', $2::text, true), + set_config('projects.job_run_id', $3::text, true) +` + +type BindWriterParams struct { + Actor string + RequestID string + JobRunID string +} + +// The third argument makes each setting end with this transaction. +func (q *Queries) BindWriter(ctx context.Context, arg BindWriterParams) error { + _, err := q.db.Exec(ctx, bindWriter, arg.Actor, arg.RequestID, arg.JobRunID) + return err +} + +const countUndoEntries = `-- name: CountUndoEntries :one +SELECT count(*) FROM undo_log +` + +func (q *Queries) CountUndoEntries(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, countUndoEntries) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createItem = `-- name: CreateItem :one +INSERT INTO project_items (id, number, title, notes, repo) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, number, title, notes, repo, completed, completed_at, archived, created_at, updated_at +` + +type CreateItemParams struct { + ID string + ItemNumber int64 + Title string + Notes *string + Repo *string +} + +func (q *Queries) CreateItem(ctx context.Context, arg CreateItemParams) (ProjectItem, error) { + row := q.db.QueryRow(ctx, createItem, + arg.ID, + arg.ItemNumber, + arg.Title, + arg.Notes, + arg.Repo, + ) + var i ProjectItem + err := row.Scan( + &i.ID, + &i.Number, + &i.Title, + &i.Notes, + &i.Repo, + &i.Completed, + &i.CompletedAt, + &i.Archived, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createProject = `-- name: CreateProject :one +INSERT INTO projects (id, name, description, kind, status, status_reason, closed_at, position) +VALUES ( + $1, $2, $3, $4, + $5, $6, $7, $8 +) +RETURNING id, name, description, kind, status, status_reason, closed_at, position, created_at +` + +type CreateProjectParams struct { + ID string + Name string + Description *string + Kind string + Status string + StatusReason *string + ClosedAt *time.Time + ProjectPosition int32 +} + +func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) { + row := q.db.QueryRow(ctx, createProject, + arg.ID, + arg.Name, + arg.Description, + arg.Kind, + arg.Status, + arg.StatusReason, + arg.ClosedAt, + arg.ProjectPosition, + ) + var i Project + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.Kind, + &i.Status, + &i.StatusReason, + &i.ClosedAt, + &i.Position, + &i.CreatedAt, + ) + return i, err +} + +const createTask = `-- name: CreateTask :one +INSERT INTO project_item_tasks (id, item_id, title, position) +VALUES ($1, $2, $3, $4) +RETURNING id, item_id, title, completed, completed_at, position, created_at +` + +type CreateTaskParams struct { + ID string + ItemID string + Title string + TaskPosition int32 +} + +func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (ProjectItemTask, error) { + row := q.db.QueryRow(ctx, createTask, + arg.ID, + arg.ItemID, + arg.Title, + arg.TaskPosition, + ) + var i ProjectItemTask + err := row.Scan( + &i.ID, + &i.ItemID, + &i.Title, + &i.Completed, + &i.CompletedAt, + &i.Position, + &i.CreatedAt, + ) + return i, err +} + +const deleteItem = `-- name: DeleteItem :exec +DELETE FROM project_items WHERE id = $1 +` + +func (q *Queries) DeleteItem(ctx context.Context, id string) error { + _, err := q.db.Exec(ctx, deleteItem, id) + return err +} + +const deleteProject = `-- name: DeleteProject :exec +DELETE FROM projects WHERE id = $1 +` + +func (q *Queries) DeleteProject(ctx context.Context, id string) error { + _, err := q.db.Exec(ctx, deleteProject, id) + return err +} + +const deleteTask = `-- name: DeleteTask :exec +DELETE FROM project_item_tasks WHERE id = $1 +` + +func (q *Queries) DeleteTask(ctx context.Context, id string) error { + _, err := q.db.Exec(ctx, deleteTask, id) + return err +} + +const deleteUndoEntry = `-- name: DeleteUndoEntry :exec +DELETE FROM undo_log WHERE id = $1 +` + +func (q *Queries) DeleteUndoEntry(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, deleteUndoEntry, id) + return err +} + +const getItem = `-- name: GetItem :one +SELECT id, number, title, notes, repo, completed, completed_at, archived, created_at, updated_at FROM project_items WHERE id = $1 +` + +func (q *Queries) GetItem(ctx context.Context, id string) (ProjectItem, error) { + row := q.db.QueryRow(ctx, getItem, id) + var i ProjectItem + err := row.Scan( + &i.ID, + &i.Number, + &i.Title, + &i.Notes, + &i.Repo, + &i.Completed, + &i.CompletedAt, + &i.Archived, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getItemByNumber = `-- name: GetItemByNumber :one +SELECT id, number, title, notes, repo, completed, completed_at, archived, created_at, updated_at FROM project_items WHERE number = $1 +` + +func (q *Queries) GetItemByNumber(ctx context.Context, itemNumber int64) (ProjectItem, error) { + row := q.db.QueryRow(ctx, getItemByNumber, itemNumber) + var i ProjectItem + err := row.Scan( + &i.ID, + &i.Number, + &i.Title, + &i.Notes, + &i.Repo, + &i.Completed, + &i.CompletedAt, + &i.Archived, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getItemProjects = `-- name: GetItemProjects :many +SELECT p.id, p.name, p.description, p.kind, p.status, p.status_reason, p.closed_at, p.position, p.created_at +FROM projects AS p +INNER JOIN project_item_memberships AS m ON p.id = m.project_id +WHERE m.item_id = $1 +ORDER BY p.position, p.created_at, p.id +` + +func (q *Queries) GetItemProjects(ctx context.Context, itemID string) ([]Project, error) { + rows, err := q.db.Query(ctx, getItemProjects, itemID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Project + for rows.Next() { + var i Project + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.Kind, + &i.Status, + &i.StatusReason, + &i.ClosedAt, + &i.Position, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getLatestUndoEntry = `-- name: GetLatestUndoEntry :one +SELECT id, action, entity_type, entity_id, previous_state, created_at FROM undo_log ORDER BY id DESC LIMIT 1 +` + +func (q *Queries) GetLatestUndoEntry(ctx context.Context) (UndoLog, error) { + row := q.db.QueryRow(ctx, getLatestUndoEntry) + var i UndoLog + err := row.Scan( + &i.ID, + &i.Action, + &i.EntityType, + &i.EntityID, + &i.PreviousState, + &i.CreatedAt, + ) + return i, err +} + +const getMembership = `-- name: GetMembership :one +SELECT item_id, project_id, position FROM project_item_memberships WHERE item_id = $1 AND project_id = $2 +` + +type GetMembershipParams struct { + ItemID string + ProjectID string +} + +func (q *Queries) GetMembership(ctx context.Context, arg GetMembershipParams) (ProjectItemMembership, error) { + row := q.db.QueryRow(ctx, getMembership, arg.ItemID, arg.ProjectID) + var i ProjectItemMembership + err := row.Scan(&i.ItemID, &i.ProjectID, &i.Position) + return i, err +} + +const getProject = `-- name: GetProject :one +SELECT id, name, description, kind, status, status_reason, closed_at, position, created_at FROM projects WHERE id = $1 +` + +func (q *Queries) GetProject(ctx context.Context, id string) (Project, error) { + row := q.db.QueryRow(ctx, getProject, id) + var i Project + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.Kind, + &i.Status, + &i.StatusReason, + &i.ClosedAt, + &i.Position, + &i.CreatedAt, + ) + return i, err +} + +const getTask = `-- name: GetTask :one +SELECT id, item_id, title, completed, completed_at, position, created_at FROM project_item_tasks WHERE id = $1 +` + +func (q *Queries) GetTask(ctx context.Context, id string) (ProjectItemTask, error) { + row := q.db.QueryRow(ctx, getTask, id) + var i ProjectItemTask + err := row.Scan( + &i.ID, + &i.ItemID, + &i.Title, + &i.Completed, + &i.CompletedAt, + &i.Position, + &i.CreatedAt, + ) + return i, err +} + +const insertUndoEntry = `-- name: InsertUndoEntry :exec +INSERT INTO undo_log (action, entity_type, entity_id, previous_state) +VALUES ($1, $2, $3, $4) +` + +type InsertUndoEntryParams struct { + Action string + EntityType string + EntityID string + PreviousState *string +} + +func (q *Queries) InsertUndoEntry(ctx context.Context, arg InsertUndoEntryParams) error { + _, err := q.db.Exec(ctx, insertUndoEntry, + arg.Action, + arg.EntityType, + arg.EntityID, + arg.PreviousState, + ) + return err +} + +const listAllDependencies = `-- name: ListAllDependencies :many +SELECT item_id, depends_on_id FROM project_item_dependencies ORDER BY item_id, depends_on_id +` + +func (q *Queries) ListAllDependencies(ctx context.Context) ([]ProjectItemDependency, error) { + rows, err := q.db.Query(ctx, listAllDependencies) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ProjectItemDependency + for rows.Next() { + var i ProjectItemDependency + if err := rows.Scan(&i.ItemID, &i.DependsOnID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAllItems = `-- name: ListAllItems :many +SELECT id, number, title, notes, repo, completed, completed_at, archived, created_at, updated_at FROM project_items ORDER BY created_at DESC, id ASC +` + +func (q *Queries) ListAllItems(ctx context.Context) ([]ProjectItem, error) { + rows, err := q.db.Query(ctx, listAllItems) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ProjectItem + for rows.Next() { + var i ProjectItem + if err := rows.Scan( + &i.ID, + &i.Number, + &i.Title, + &i.Notes, + &i.Repo, + &i.Completed, + &i.CompletedAt, + &i.Archived, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAllMemberships = `-- name: ListAllMemberships :many +SELECT item_id, project_id, position FROM project_item_memberships ORDER BY project_id, item_id +` + +func (q *Queries) ListAllMemberships(ctx context.Context) ([]ProjectItemMembership, error) { + rows, err := q.db.Query(ctx, listAllMemberships) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ProjectItemMembership + for rows.Next() { + var i ProjectItemMembership + if err := rows.Scan(&i.ItemID, &i.ProjectID, &i.Position); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAllTasks = `-- name: ListAllTasks :many +SELECT id, item_id, title, completed, completed_at, position, created_at FROM project_item_tasks ORDER BY item_id, position, created_at +` + +func (q *Queries) ListAllTasks(ctx context.Context) ([]ProjectItemTask, error) { + rows, err := q.db.Query(ctx, listAllTasks) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ProjectItemTask + for rows.Next() { + var i ProjectItemTask + if err := rows.Scan( + &i.ID, + &i.ItemID, + &i.Title, + &i.Completed, + &i.CompletedAt, + &i.Position, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listBlockedItems = `-- name: ListBlockedItems :many +SELECT pi.id, pi.number, pi.title, pi.notes, pi.repo, pi.completed, pi.completed_at, pi.archived, pi.created_at, pi.updated_at +FROM project_items AS pi +WHERE + pi.completed = false + AND pi.archived = false + AND EXISTS ( + SELECT 1 + FROM project_item_dependencies AS d + INNER JOIN project_items AS blocker ON d.depends_on_id = blocker.id + WHERE d.item_id = pi.id AND blocker.completed = false + ) +ORDER BY pi.created_at DESC, pi.id ASC +` + +func (q *Queries) ListBlockedItems(ctx context.Context) ([]ProjectItem, error) { + rows, err := q.db.Query(ctx, listBlockedItems) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ProjectItem + for rows.Next() { + var i ProjectItem + if err := rows.Scan( + &i.ID, + &i.Number, + &i.Title, + &i.Notes, + &i.Repo, + &i.Completed, + &i.CompletedAt, + &i.Archived, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listDependenciesInvolvingItem = `-- name: ListDependenciesInvolvingItem :many +SELECT item_id, depends_on_id +FROM project_item_dependencies +WHERE item_id = $1 OR depends_on_id = $1 +ORDER BY item_id, depends_on_id +` + +func (q *Queries) ListDependenciesInvolvingItem(ctx context.Context, itemID string) ([]ProjectItemDependency, error) { + rows, err := q.db.Query(ctx, listDependenciesInvolvingItem, itemID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ProjectItemDependency + for rows.Next() { + var i ProjectItemDependency + if err := rows.Scan(&i.ItemID, &i.DependsOnID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listDependencyItems = `-- name: ListDependencyItems :many +SELECT pi.id, pi.number, pi.title, pi.notes, pi.repo, pi.completed, pi.completed_at, pi.archived, pi.created_at, pi.updated_at +FROM project_items AS pi +INNER JOIN project_item_dependencies AS d ON pi.id = d.depends_on_id +WHERE d.item_id = $1 +ORDER BY pi.number +` + +func (q *Queries) ListDependencyItems(ctx context.Context, itemID string) ([]ProjectItem, error) { + rows, err := q.db.Query(ctx, listDependencyItems, itemID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ProjectItem + for rows.Next() { + var i ProjectItem + if err := rows.Scan( + &i.ID, + &i.Number, + &i.Title, + &i.Notes, + &i.Repo, + &i.Completed, + &i.CompletedAt, + &i.Archived, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listItemMemberships = `-- name: ListItemMemberships :many +SELECT item_id, project_id, position FROM project_item_memberships WHERE item_id = $1 ORDER BY project_id +` + +func (q *Queries) ListItemMemberships(ctx context.Context, itemID string) ([]ProjectItemMembership, error) { + rows, err := q.db.Query(ctx, listItemMemberships, itemID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ProjectItemMembership + for rows.Next() { + var i ProjectItemMembership + if err := rows.Scan(&i.ItemID, &i.ProjectID, &i.Position); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listItemsInProject = `-- name: ListItemsInProject :many +SELECT + pi.id, pi.number, pi.title, pi.notes, pi.repo, pi.completed, pi.completed_at, pi.archived, pi.created_at, pi.updated_at, + m.position AS membership_position, + (SELECT count(*) FROM project_item_memberships AS held WHERE held.item_id = pi.id) AS project_count +FROM project_items AS pi +INNER JOIN project_item_memberships AS m ON pi.id = m.item_id +WHERE m.project_id = $1 +ORDER BY m.position ASC, pi.created_at ASC, pi.id ASC +` + +type ListItemsInProjectRow struct { + ID string + Number int64 + Title string + Notes *string + Repo *string + Completed bool + CompletedAt *time.Time + Archived bool + CreatedAt time.Time + UpdatedAt time.Time + MembershipPosition int32 + ProjectCount int64 +} + +func (q *Queries) ListItemsInProject(ctx context.Context, projectID string) ([]ListItemsInProjectRow, error) { + rows, err := q.db.Query(ctx, listItemsInProject, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListItemsInProjectRow + for rows.Next() { + var i ListItemsInProjectRow + if err := rows.Scan( + &i.ID, + &i.Number, + &i.Title, + &i.Notes, + &i.Repo, + &i.Completed, + &i.CompletedAt, + &i.Archived, + &i.CreatedAt, + &i.UpdatedAt, + &i.MembershipPosition, + &i.ProjectCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listProjectItemFacts = `-- name: ListProjectItemFacts :many +SELECT m.project_id, pi.completed, pi.archived, pi.repo +FROM project_item_memberships AS m +INNER JOIN project_items AS pi ON m.item_id = pi.id +` + +type ListProjectItemFactsRow struct { + ProjectID string + Completed bool + Archived bool + Repo *string +} + +func (q *Queries) ListProjectItemFacts(ctx context.Context) ([]ListProjectItemFactsRow, error) { + rows, err := q.db.Query(ctx, listProjectItemFacts) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListProjectItemFactsRow + for rows.Next() { + var i ListProjectItemFactsRow + if err := rows.Scan( + &i.ProjectID, + &i.Completed, + &i.Archived, + &i.Repo, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listProjectKinds = `-- name: ListProjectKinds :many +SELECT name FROM project_kinds ORDER BY name +` + +func (q *Queries) ListProjectKinds(ctx context.Context) ([]string, error) { + rows, err := q.db.Query(ctx, listProjectKinds) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + items = append(items, name) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listProjectMemberships = `-- name: ListProjectMemberships :many +SELECT item_id, project_id, position FROM project_item_memberships WHERE project_id = $1 ORDER BY position, item_id +` + +func (q *Queries) ListProjectMemberships(ctx context.Context, projectID string) ([]ProjectItemMembership, error) { + rows, err := q.db.Query(ctx, listProjectMemberships, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ProjectItemMembership + for rows.Next() { + var i ProjectItemMembership + if err := rows.Scan(&i.ItemID, &i.ProjectID, &i.Position); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listProjectStatuses = `-- name: ListProjectStatuses :many +SELECT name FROM project_statuses ORDER BY name +` + +func (q *Queries) ListProjectStatuses(ctx context.Context) ([]string, error) { + rows, err := q.db.Query(ctx, listProjectStatuses) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + items = append(items, name) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listProjects = `-- name: ListProjects :many +SELECT id, name, description, kind, status, status_reason, closed_at, position, created_at FROM projects ORDER BY position, name +` + +func (q *Queries) ListProjects(ctx context.Context) ([]Project, error) { + rows, err := q.db.Query(ctx, listProjects) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Project + for rows.Next() { + var i Project + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.Kind, + &i.Status, + &i.StatusReason, + &i.ClosedAt, + &i.Position, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listTasksByItem = `-- name: ListTasksByItem :many +SELECT id, item_id, title, completed, completed_at, position, created_at FROM project_item_tasks WHERE item_id = $1 ORDER BY position, created_at +` + +func (q *Queries) ListTasksByItem(ctx context.Context, itemID string) ([]ProjectItemTask, error) { + rows, err := q.db.Query(ctx, listTasksByItem, itemID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ProjectItemTask + for rows.Next() { + var i ProjectItemTask + if err := rows.Scan( + &i.ID, + &i.ItemID, + &i.Title, + &i.Completed, + &i.CompletedAt, + &i.Position, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const nextItemNumber = `-- name: NextItemNumber :one +SELECT nextval('item_numbers')::bigint +` + +func (q *Queries) NextItemNumber(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, nextItemNumber) + var column_1 int64 + err := row.Scan(&column_1) + return column_1, err +} + +const removeDependency = `-- name: RemoveDependency :exec +DELETE FROM project_item_dependencies WHERE item_id = $1 AND depends_on_id = $2 +` + +type RemoveDependencyParams struct { + ItemID string + DependsOnID string +} + +func (q *Queries) RemoveDependency(ctx context.Context, arg RemoveDependencyParams) error { + _, err := q.db.Exec(ctx, removeDependency, arg.ItemID, arg.DependsOnID) + return err +} + +const removeItemFromProject = `-- name: RemoveItemFromProject :exec +DELETE FROM project_item_memberships WHERE item_id = $1 AND project_id = $2 +` + +type RemoveItemFromProjectParams struct { + ItemID string + ProjectID string +} + +func (q *Queries) RemoveItemFromProject(ctx context.Context, arg RemoveItemFromProjectParams) error { + _, err := q.db.Exec(ctx, removeItemFromProject, arg.ItemID, arg.ProjectID) + return err +} + +const restoreDependency = `-- name: RestoreDependency :exec +INSERT INTO project_item_dependencies (item_id, depends_on_id) +VALUES ($1, $2) +ON CONFLICT (item_id, depends_on_id) DO NOTHING +` + +type RestoreDependencyParams struct { + ItemID string + DependsOnID string +} + +func (q *Queries) RestoreDependency(ctx context.Context, arg RestoreDependencyParams) error { + _, err := q.db.Exec(ctx, restoreDependency, arg.ItemID, arg.DependsOnID) + return err +} + +const restoreItem = `-- name: RestoreItem :exec +INSERT INTO project_items (id, number, title, notes, repo, completed, completed_at, archived, created_at, updated_at) +VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, $9, $10 +) +ON CONFLICT (id) DO UPDATE SET + number = excluded.number, + title = excluded.title, + notes = excluded.notes, + repo = excluded.repo, + completed = excluded.completed, + completed_at = excluded.completed_at, + archived = excluded.archived, + updated_at = excluded.updated_at +` + +type RestoreItemParams struct { + ID string + ItemNumber int64 + Title string + Notes *string + Repo *string + Completed bool + CompletedAt *time.Time + Archived bool + CreatedAt time.Time + UpdatedAt time.Time +} + +func (q *Queries) RestoreItem(ctx context.Context, arg RestoreItemParams) error { + _, err := q.db.Exec(ctx, restoreItem, + arg.ID, + arg.ItemNumber, + arg.Title, + arg.Notes, + arg.Repo, + arg.Completed, + arg.CompletedAt, + arg.Archived, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const restoreMembership = `-- name: RestoreMembership :exec +INSERT INTO project_item_memberships (item_id, project_id, position) +VALUES ($1, $2, $3) +ON CONFLICT (item_id, project_id) DO NOTHING +` + +type RestoreMembershipParams struct { + ItemID string + ProjectID string + ItemPosition int32 +} + +func (q *Queries) RestoreMembership(ctx context.Context, arg RestoreMembershipParams) error { + _, err := q.db.Exec(ctx, restoreMembership, arg.ItemID, arg.ProjectID, arg.ItemPosition) + return err +} + +const restoreProject = `-- name: RestoreProject :exec +INSERT INTO projects (id, name, description, kind, status, status_reason, closed_at, position, created_at) +VALUES ( + $1, $2, $3, $4, + $5, $6, $7, $8, $9 +) +ON CONFLICT (id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + kind = excluded.kind, + status = excluded.status, + status_reason = excluded.status_reason, + closed_at = excluded.closed_at, + position = excluded.position +` + +type RestoreProjectParams struct { + ID string + Name string + Description *string + Kind string + Status string + StatusReason *string + ClosedAt *time.Time + ProjectPosition int32 + CreatedAt time.Time +} + +func (q *Queries) RestoreProject(ctx context.Context, arg RestoreProjectParams) error { + _, err := q.db.Exec(ctx, restoreProject, + arg.ID, + arg.Name, + arg.Description, + arg.Kind, + arg.Status, + arg.StatusReason, + arg.ClosedAt, + arg.ProjectPosition, + arg.CreatedAt, + ) + return err +} + +const restoreTask = `-- name: RestoreTask :exec +INSERT INTO project_item_tasks (id, item_id, title, completed, completed_at, position, created_at) +VALUES ( + $1, $2, $3, $4, + $5, $6, $7 +) +ON CONFLICT (id) DO UPDATE SET + title = excluded.title, + completed = excluded.completed, + completed_at = excluded.completed_at, + position = excluded.position +` + +type RestoreTaskParams struct { + ID string + ItemID string + Title string + Completed bool + CompletedAt *time.Time + TaskPosition int32 + CreatedAt time.Time +} + +func (q *Queries) RestoreTask(ctx context.Context, arg RestoreTaskParams) error { + _, err := q.db.Exec(ctx, restoreTask, + arg.ID, + arg.ItemID, + arg.Title, + arg.Completed, + arg.CompletedAt, + arg.TaskPosition, + arg.CreatedAt, + ) + return err +} + +const searchItems = `-- name: SearchItems :many +SELECT id, number, title, notes, repo, completed, completed_at, archived, created_at, updated_at +FROM project_items +WHERE title ILIKE '%' || $1::text || '%' OR notes ILIKE '%' || $1::text || '%' +ORDER BY created_at DESC, id ASC +` + +func (q *Queries) SearchItems(ctx context.Context, query string) ([]ProjectItem, error) { + rows, err := q.db.Query(ctx, searchItems, query) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ProjectItem + for rows.Next() { + var i ProjectItem + if err := rows.Scan( + &i.ID, + &i.Number, + &i.Title, + &i.Notes, + &i.Repo, + &i.Completed, + &i.CompletedAt, + &i.Archived, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const takeWriteLock = `-- name: TakeWriteLock :exec +SELECT pg_advisory_xact_lock($1::bigint) +` + +// The write lock is the analog of beginning a SQLite transaction immediate: a rule +// reads and then writes, and what it read has to hold at commit. +func (q *Queries) TakeWriteLock(ctx context.Context, lockKey int64) error { + _, err := q.db.Exec(ctx, takeWriteLock, lockKey) + return err +} + +const updateItem = `-- name: UpdateItem :one +UPDATE project_items +SET + title = $1, + notes = $2, + repo = $3, + completed = $4, + completed_at = $5, + archived = $6, + updated_at = now() +WHERE id = $7 +RETURNING id, number, title, notes, repo, completed, completed_at, archived, created_at, updated_at +` + +type UpdateItemParams struct { + Title string + Notes *string + Repo *string + Completed bool + CompletedAt *time.Time + Archived bool + ID string +} + +func (q *Queries) UpdateItem(ctx context.Context, arg UpdateItemParams) (ProjectItem, error) { + row := q.db.QueryRow(ctx, updateItem, + arg.Title, + arg.Notes, + arg.Repo, + arg.Completed, + arg.CompletedAt, + arg.Archived, + arg.ID, + ) + var i ProjectItem + err := row.Scan( + &i.ID, + &i.Number, + &i.Title, + &i.Notes, + &i.Repo, + &i.Completed, + &i.CompletedAt, + &i.Archived, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateItemPosition = `-- name: UpdateItemPosition :exec +UPDATE project_item_memberships +SET position = $1 +WHERE item_id = $2 AND project_id = $3 +` + +type UpdateItemPositionParams struct { + ItemPosition int32 + ItemID string + ProjectID string +} + +func (q *Queries) UpdateItemPosition(ctx context.Context, arg UpdateItemPositionParams) error { + _, err := q.db.Exec(ctx, updateItemPosition, arg.ItemPosition, arg.ItemID, arg.ProjectID) + return err +} + +const updateProject = `-- name: UpdateProject :one +UPDATE projects +SET + name = $1, + description = $2, + kind = $3, + status = $4, + status_reason = $5, + closed_at = $6, + position = $7 +WHERE id = $8 +RETURNING id, name, description, kind, status, status_reason, closed_at, position, created_at +` + +type UpdateProjectParams struct { + Name string + Description *string + Kind string + Status string + StatusReason *string + ClosedAt *time.Time + ProjectPosition int32 + ID string +} + +func (q *Queries) UpdateProject(ctx context.Context, arg UpdateProjectParams) (Project, error) { + row := q.db.QueryRow(ctx, updateProject, + arg.Name, + arg.Description, + arg.Kind, + arg.Status, + arg.StatusReason, + arg.ClosedAt, + arg.ProjectPosition, + arg.ID, + ) + var i Project + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.Kind, + &i.Status, + &i.StatusReason, + &i.ClosedAt, + &i.Position, + &i.CreatedAt, + ) + return i, err +} + +const updateProjectPosition = `-- name: UpdateProjectPosition :exec +UPDATE projects SET position = $1 WHERE id = $2 +` + +type UpdateProjectPositionParams struct { + ProjectPosition int32 + ID string +} + +func (q *Queries) UpdateProjectPosition(ctx context.Context, arg UpdateProjectPositionParams) error { + _, err := q.db.Exec(ctx, updateProjectPosition, arg.ProjectPosition, arg.ID) + return err +} + +const updateTask = `-- name: UpdateTask :one +UPDATE project_item_tasks +SET + title = $1, + completed = $2, + completed_at = $3, + position = $4 +WHERE id = $5 +RETURNING id, item_id, title, completed, completed_at, position, created_at +` + +type UpdateTaskParams struct { + Title string + Completed bool + CompletedAt *time.Time + TaskPosition int32 + ID string +} + +func (q *Queries) UpdateTask(ctx context.Context, arg UpdateTaskParams) (ProjectItemTask, error) { + row := q.db.QueryRow(ctx, updateTask, + arg.Title, + arg.Completed, + arg.CompletedAt, + arg.TaskPosition, + arg.ID, + ) + var i ProjectItemTask + err := row.Scan( + &i.ID, + &i.ItemID, + &i.Title, + &i.Completed, + &i.CompletedAt, + &i.Position, + &i.CreatedAt, + ) + return i, err +} diff --git a/store/postgres/migrations/00001_projects.sql b/store/postgres/migrations/00001_projects.sql new file mode 100644 index 0000000..86d11a5 --- /dev/null +++ b/store/postgres/migrations/00001_projects.sql @@ -0,0 +1,209 @@ +-- +goose Up +CREATE TABLE project_kinds ( + name text PRIMARY KEY +); + +INSERT INTO project_kinds (name) VALUES ('build'), ('chore'), ('life'); + +CREATE TABLE project_statuses ( + name text PRIMARY KEY +); + +INSERT INTO project_statuses (name) VALUES ('active'), ('someday'), ('completed'), ('dropped'); + +CREATE TABLE projects ( + id uuid CONSTRAINT pk_projects PRIMARY KEY, + name text NOT NULL, + description text, + kind text NOT NULL DEFAULT 'life' + CONSTRAINT fk_projects_kind_project_kinds REFERENCES project_kinds (name), + status text NOT NULL DEFAULT 'active' + CONSTRAINT fk_projects_status_project_statuses REFERENCES project_statuses (name), + status_reason text, + closed_at timestamptz, + position integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ck_projects_dropped_requires_reason CHECK (status <> 'dropped' OR status_reason IS NOT NULL) +); + +-- A name is held only by the active project bearing it, so a finished project gives +-- its name back. +CREATE UNIQUE INDEX uq_projects_name_active ON projects (name) WHERE status = 'active'; + +CREATE SEQUENCE item_numbers AS bigint START WITH 100000; + +CREATE TABLE project_items ( + id uuid CONSTRAINT pk_project_items PRIMARY KEY, + number bigint NOT NULL DEFAULT nextval('item_numbers') + CONSTRAINT uq_project_items_number UNIQUE, + title text NOT NULL, + notes text, + repo text, + completed boolean NOT NULL DEFAULT false, + completed_at timestamptz, + archived boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER SEQUENCE item_numbers OWNED BY project_items.number; + +CREATE INDEX idx_pi_active ON project_items (archived) WHERE archived = false; + +CREATE INDEX ix_project_items_repo ON project_items (repo); + +CREATE TABLE project_item_memberships ( + item_id uuid NOT NULL + CONSTRAINT fk_project_item_memberships_item_id_project_items REFERENCES project_items (id) ON DELETE CASCADE, + project_id uuid NOT NULL + CONSTRAINT fk_project_item_memberships_project_id_projects REFERENCES projects (id) ON DELETE CASCADE, + position integer NOT NULL DEFAULT 0, + CONSTRAINT pk_project_item_memberships PRIMARY KEY (item_id, project_id) +); + +CREATE INDEX idx_pim_item ON project_item_memberships (item_id); + +CREATE INDEX idx_pim_project ON project_item_memberships (project_id); + +CREATE INDEX idx_pim_position ON project_item_memberships (project_id, position); + +CREATE TABLE project_item_dependencies ( + item_id uuid NOT NULL + CONSTRAINT fk_project_item_dependencies_item_id_project_items REFERENCES project_items (id) ON DELETE CASCADE, + depends_on_id uuid NOT NULL + CONSTRAINT fk_project_item_dependencies_depends_on_id_project_items REFERENCES project_items (id) ON DELETE CASCADE, + CONSTRAINT pk_project_item_dependencies PRIMARY KEY (item_id, depends_on_id), + CONSTRAINT ck_project_item_dependencies_no_self_dependency CHECK (item_id <> depends_on_id) +); + +CREATE INDEX idx_pid_item ON project_item_dependencies (item_id); + +CREATE INDEX idx_pid_depends ON project_item_dependencies (depends_on_id); + +CREATE TABLE project_item_tasks ( + id uuid CONSTRAINT pk_project_item_tasks PRIMARY KEY, + item_id uuid NOT NULL + CONSTRAINT fk_project_item_tasks_item_id_project_items REFERENCES project_items (id) ON DELETE CASCADE, + title text NOT NULL, + completed boolean NOT NULL DEFAULT false, + completed_at timestamptz, + position integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_pit_item ON project_item_tasks (item_id); + +CREATE INDEX idx_pit_position ON project_item_tasks (item_id, position); + +-- previous_state is the service's own JSON, which this database holds as text. It +-- carries no audit trigger, because it would copy whole rows on every write and the +-- rows it describes are audited already. +CREATE TABLE undo_log ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + action text NOT NULL, + entity_type text NOT NULL, + entity_id text NOT NULL, + previous_state text, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Every insert, update and delete to a row of an audited table leaves one entry +-- here, whatever wrote it: a statement, a cascade, or a person at a SQL prompt. +CREATE TABLE audit_trail ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + changed_at timestamptz NOT NULL DEFAULT now(), + table_name text NOT NULL, + record_id text NOT NULL, + operation text NOT NULL, + changes jsonb NOT NULL, + actor text, + request_id text, + job_run_id text, + CONSTRAINT ck_audit_trail_operation_is_a_write CHECK (operation IN ('insert', 'update', 'delete')) +); + +CREATE INDEX idx_audit_trail_record ON audit_trail (table_name, record_id, changed_at); + +CREATE INDEX ix_audit_trail_changed_at ON audit_trail (changed_at); + +CREATE INDEX ix_audit_trail_actor ON audit_trail (actor); + +-- +goose StatementBegin +-- Holds each written column's old and new value. A trigger's arguments name columns +-- to strip. An update that changes nothing leaves no entry. Who wrote it comes from +-- transaction-local settings, so they end with the transaction that set them. +CREATE FUNCTION audit_trail_record() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE + old_row jsonb := CASE WHEN TG_OP <> 'INSERT' THEN to_jsonb(OLD) END; + new_row jsonb := CASE WHEN TG_OP <> 'DELETE' THEN to_jsonb(NEW) END; + primary_key_text text; + written jsonb; +BEGIN + SELECT string_agg(coalesce(new_row, old_row) ->> a.attname, ',' ORDER BY k.position) INTO primary_key_text + FROM pg_index AS i + CROSS JOIN LATERAL unnest(i.indkey::int2[]) WITH ORDINALITY AS k(attnum, position) + JOIN pg_attribute AS a ON a.attrelid = i.indrelid AND a.attnum = k.attnum + WHERE i.indrelid = TG_RELID AND i.indisprimary; + + old_row := old_row - coalesce(TG_ARGV, '{}'::text[]); + new_row := new_row - coalesce(TG_ARGV, '{}'::text[]); + IF TG_OP = 'INSERT' THEN + SELECT jsonb_object_agg(n.key, jsonb_build_object('new', n.value)) INTO written FROM jsonb_each(new_row) AS n; + ELSIF TG_OP = 'DELETE' THEN + SELECT jsonb_object_agg(o.key, jsonb_build_object('old', o.value)) INTO written FROM jsonb_each(old_row) AS o; + ELSE + SELECT jsonb_object_agg(n.key, jsonb_build_object('old', o.value, 'new', n.value)) INTO written + FROM jsonb_each(new_row) AS n JOIN jsonb_each(old_row) AS o ON o.key = n.key + WHERE n.value IS DISTINCT FROM o.value; + END IF; + IF written IS NULL THEN + RETURN NULL; + END IF; + + INSERT INTO audit_trail (table_name, record_id, operation, changes, actor, request_id, job_run_id) + VALUES ( + CASE WHEN TG_TABLE_SCHEMA = 'public' THEN TG_TABLE_NAME ELSE TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME END, + coalesce(primary_key_text, ''), + lower(TG_OP), + written, + nullif(current_setting('projects.actor', true), ''), + nullif(current_setting('projects.request_id', true), ''), + nullif(current_setting('projects.job_run_id', true), '') + ); + RETURN NULL; +END; +$$; +-- +goose StatementEnd + +CREATE TRIGGER audit_trail AFTER INSERT OR UPDATE OR DELETE ON project_kinds +FOR EACH ROW EXECUTE FUNCTION audit_trail_record(); + +CREATE TRIGGER audit_trail AFTER INSERT OR UPDATE OR DELETE ON project_statuses +FOR EACH ROW EXECUTE FUNCTION audit_trail_record(); + +CREATE TRIGGER audit_trail AFTER INSERT OR UPDATE OR DELETE ON projects +FOR EACH ROW EXECUTE FUNCTION audit_trail_record(); + +CREATE TRIGGER audit_trail AFTER INSERT OR UPDATE OR DELETE ON project_items +FOR EACH ROW EXECUTE FUNCTION audit_trail_record(); + +CREATE TRIGGER audit_trail AFTER INSERT OR UPDATE OR DELETE ON project_item_memberships +FOR EACH ROW EXECUTE FUNCTION audit_trail_record(); + +CREATE TRIGGER audit_trail AFTER INSERT OR UPDATE OR DELETE ON project_item_dependencies +FOR EACH ROW EXECUTE FUNCTION audit_trail_record(); + +CREATE TRIGGER audit_trail AFTER INSERT OR UPDATE OR DELETE ON project_item_tasks +FOR EACH ROW EXECUTE FUNCTION audit_trail_record(); + +-- +goose Down +DROP TABLE audit_trail; +DROP FUNCTION audit_trail_record(); +DROP TABLE undo_log; +DROP TABLE project_item_tasks; +DROP TABLE project_item_dependencies; +DROP TABLE project_item_memberships; +DROP TABLE project_items; +DROP TABLE projects; +DROP TABLE project_statuses; +DROP TABLE project_kinds; diff --git a/store/postgres/open.go b/store/postgres/open.go new file mode 100644 index 0000000..6d16139 --- /dev/null +++ b/store/postgres/open.go @@ -0,0 +1,50 @@ +// Package postgres is store.Store over Postgres. +package postgres + +import ( + "context" + "database/sql" + "embed" + "fmt" + "io/fs" + + "github.com/jackc/pgx/v5/pgxpool" + _ "github.com/jackc/pgx/v5/stdlib" // goose reads through database/sql + "github.com/pressly/goose/v3" +) + +//go:embed migrations/*.sql +var migrationFiles embed.FS + +// Migrate brings the database at url up to the newest migration. It runs from a +// job before the servers roll, so two servers starting together cannot race it and +// a bad migration fails the job rather than crash-looping what is serving. +func Migrate(ctx context.Context, url string) error { + database, err := sql.Open("pgx", url) + if err != nil { + return fmt.Errorf("opening database for migration: %w", err) + } + defer func() { _ = database.Close() }() + + files, err := fs.Sub(migrationFiles, "migrations") + if err != nil { + return fmt.Errorf("reading the embedded migrations: %w", err) + } + provider, err := goose.NewProvider(goose.DialectPostgres, database, files) + if err != nil { + return fmt.Errorf("preparing migrations: %w", err) + } + if _, err := provider.Up(ctx); err != nil { + return fmt.Errorf("running migrations: %w", err) + } + return nil +} + +// Connect opens a connection pool the caller closes. +func Connect(ctx context.Context, url string) (*pgxpool.Pool, error) { + pool, err := pgxpool.New(ctx, url) + if err != nil { + return nil, fmt.Errorf("opening connection pool: %w", err) + } + return pool, nil +} diff --git a/store/postgres/postgrestest/postgrestest.go b/store/postgres/postgrestest/postgrestest.go new file mode 100644 index 0000000..56de683 --- /dev/null +++ b/store/postgres/postgrestest/postgrestest.go @@ -0,0 +1,118 @@ +// Package postgrestest gives a test a migrated database of its own. One container +// serves the whole test binary, or the server TEST_DATABASE_URL names, and each test +// clones a migrated template, which costs a few milliseconds where migrating costs +// a hundred. +package postgrestest + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/testcontainers/testcontainers-go" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" + + "github.com/datapointchris/projects/store/postgres" +) + +const templateName = "projects_test_template" + +var ( + prepareServer sync.Once + adminURL string + errPrepare error + clones atomic.Int64 +) + +// URL answers the connection string of an empty migrated database the test owns. A +// short run skips, because the commit gate runs short and this needs a server. +func URL(t testing.TB) string { + t.Helper() + if testing.Short() { + t.Skip("needs a Postgres server, which a short run does not start") + } + prepareServer.Do(func() { adminURL, errPrepare = startServerWithTemplate(context.Background()) }) + if errPrepare != nil { + t.Fatalf("preparing Postgres: %v", errPrepare) + } + + ctx := context.Background() + name := fmt.Sprintf("projects_test_%d_%d", os.Getpid(), clones.Add(1)) + admin, err := pgx.Connect(ctx, adminURL) + if err != nil { + t.Fatalf("connecting to Postgres: %v", err) + } + defer func() { _ = admin.Close(ctx) }() + if _, err := admin.Exec(ctx, fmt.Sprintf("CREATE DATABASE %s TEMPLATE %s", name, templateName)); err != nil { + t.Fatalf("cloning the template: %v", err) + } + return withDatabase(adminURL, name) +} + +// Pool answers a pool over a database URL made, closed when the test ends. +func Pool(t testing.TB) *pgxpool.Pool { + t.Helper() + pool, err := postgres.Connect(context.Background(), URL(t)) + if err != nil { + t.Fatalf("connecting: %v", err) + } + t.Cleanup(pool.Close) + return pool +} + +func startServerWithTemplate(ctx context.Context) (string, error) { + server := os.Getenv("TEST_DATABASE_URL") + if server == "" { + container, err := tcpostgres.Run(ctx, "postgres:16-alpine", + tcpostgres.WithDatabase("projects_test"), + tcpostgres.WithUsername("projects"), + tcpostgres.WithPassword("projects"), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(60*time.Second), + ), + ) + if err != nil { + return "", fmt.Errorf("starting a container: %w", err) + } + server, err = container.ConnectionString(ctx, "sslmode=disable") + if err != nil { + return "", fmt.Errorf("reading the container's address: %w", err) + } + } + + admin, err := pgx.Connect(ctx, server) + if err != nil { + return "", fmt.Errorf("connecting: %w", err) + } + defer func() { _ = admin.Close(ctx) }() + if _, err := admin.Exec(ctx, "DROP DATABASE IF EXISTS "+templateName); err != nil { + return "", fmt.Errorf("dropping an old template: %w", err) + } + if _, err := admin.Exec(ctx, "CREATE DATABASE "+templateName); err != nil { + return "", fmt.Errorf("creating the template: %w", err) + } + if err := postgres.Migrate(ctx, withDatabase(server, templateName)); err != nil { + return "", fmt.Errorf("migrating the template: %w", err) + } + return server, nil +} + +func withDatabase(server, name string) string { + parsed, err := url.Parse(server) + if err != nil { + return server + } + parsed.Path = "/" + strings.TrimPrefix(name, "/") + return parsed.String() +} diff --git a/store/postgres/queries.sql b/store/postgres/queries.sql new file mode 100644 index 0000000..24f93a4 --- /dev/null +++ b/store/postgres/queries.sql @@ -0,0 +1,265 @@ +-- The write lock is the analog of beginning a SQLite transaction immediate: a rule +-- reads and then writes, and what it read has to hold at commit. +-- name: TakeWriteLock :exec +SELECT pg_advisory_xact_lock(sqlc.arg(lock_key)::bigint); + +-- The third argument makes each setting end with this transaction. +-- name: BindWriter :exec +SELECT + set_config('projects.actor', sqlc.arg(actor)::text, true), + set_config('projects.request_id', sqlc.arg(request_id)::text, true), + set_config('projects.job_run_id', sqlc.arg(job_run_id)::text, true); + +-- name: ListProjects :many +SELECT * FROM projects ORDER BY position, name; + +-- name: ListProjectItemFacts :many +SELECT m.project_id, pi.completed, pi.archived, pi.repo +FROM project_item_memberships AS m +INNER JOIN project_items AS pi ON m.item_id = pi.id; + +-- name: ListProjectKinds :many +SELECT name FROM project_kinds ORDER BY name; + +-- name: ListProjectStatuses :many +SELECT name FROM project_statuses ORDER BY name; + +-- name: GetProject :one +SELECT * FROM projects WHERE id = sqlc.arg(id); + +-- name: CreateProject :one +INSERT INTO projects (id, name, description, kind, status, status_reason, closed_at, position) +VALUES ( + sqlc.arg(id), sqlc.arg(name), sqlc.narg(description), sqlc.arg(kind), + sqlc.arg(status), sqlc.narg(status_reason), sqlc.narg(closed_at), sqlc.arg(project_position) +) +RETURNING *; + +-- name: UpdateProject :one +UPDATE projects +SET + name = sqlc.arg(name), + description = sqlc.narg(description), + kind = sqlc.arg(kind), + status = sqlc.arg(status), + status_reason = sqlc.narg(status_reason), + closed_at = sqlc.narg(closed_at), + position = sqlc.arg(project_position) +WHERE id = sqlc.arg(id) +RETURNING *; + +-- name: UpdateProjectPosition :exec +UPDATE projects SET position = sqlc.arg(project_position) WHERE id = sqlc.arg(id); + +-- name: DeleteProject :exec +DELETE FROM projects WHERE id = sqlc.arg(id); + +-- name: RestoreProject :exec +INSERT INTO projects (id, name, description, kind, status, status_reason, closed_at, position, created_at) +VALUES ( + sqlc.arg(id), sqlc.arg(name), sqlc.narg(description), sqlc.arg(kind), + sqlc.arg(status), sqlc.narg(status_reason), sqlc.narg(closed_at), sqlc.arg(project_position), sqlc.arg(created_at) +) +ON CONFLICT (id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + kind = excluded.kind, + status = excluded.status, + status_reason = excluded.status_reason, + closed_at = excluded.closed_at, + position = excluded.position; + +-- name: ListAllItems :many +SELECT * FROM project_items ORDER BY created_at DESC, id ASC; + +-- name: ListItemsInProject :many +SELECT + pi.*, + m.position AS membership_position, + (SELECT count(*) FROM project_item_memberships AS held WHERE held.item_id = pi.id) AS project_count +FROM project_items AS pi +INNER JOIN project_item_memberships AS m ON pi.id = m.item_id +WHERE m.project_id = sqlc.arg(project_id) +ORDER BY m.position ASC, pi.created_at ASC, pi.id ASC; + +-- name: ListBlockedItems :many +SELECT pi.* +FROM project_items AS pi +WHERE + pi.completed = false + AND pi.archived = false + AND EXISTS ( + SELECT 1 + FROM project_item_dependencies AS d + INNER JOIN project_items AS blocker ON d.depends_on_id = blocker.id + WHERE d.item_id = pi.id AND blocker.completed = false + ) +ORDER BY pi.created_at DESC, pi.id ASC; + +-- name: SearchItems :many +SELECT * +FROM project_items +WHERE title ILIKE '%' || sqlc.arg(query)::text || '%' OR notes ILIKE '%' || sqlc.arg(query)::text || '%' +ORDER BY created_at DESC, id ASC; + +-- name: GetItem :one +SELECT * FROM project_items WHERE id = sqlc.arg(id); + +-- name: GetItemByNumber :one +SELECT * FROM project_items WHERE number = sqlc.arg(item_number); + +-- name: NextItemNumber :one +SELECT nextval('item_numbers')::bigint; + +-- name: CreateItem :one +INSERT INTO project_items (id, number, title, notes, repo) +VALUES (sqlc.arg(id), sqlc.arg(item_number), sqlc.arg(title), sqlc.narg(notes), sqlc.narg(repo)) +RETURNING *; + +-- name: UpdateItem :one +UPDATE project_items +SET + title = sqlc.arg(title), + notes = sqlc.narg(notes), + repo = sqlc.narg(repo), + completed = sqlc.arg(completed), + completed_at = sqlc.narg(completed_at), + archived = sqlc.arg(archived), + updated_at = now() +WHERE id = sqlc.arg(id) +RETURNING *; + +-- name: DeleteItem :exec +DELETE FROM project_items WHERE id = sqlc.arg(id); + +-- name: RestoreItem :exec +INSERT INTO project_items (id, number, title, notes, repo, completed, completed_at, archived, created_at, updated_at) +VALUES ( + sqlc.arg(id), sqlc.arg(item_number), sqlc.arg(title), sqlc.narg(notes), sqlc.narg(repo), + sqlc.arg(completed), sqlc.narg(completed_at), sqlc.arg(archived), sqlc.arg(created_at), sqlc.arg(updated_at) +) +ON CONFLICT (id) DO UPDATE SET + number = excluded.number, + title = excluded.title, + notes = excluded.notes, + repo = excluded.repo, + completed = excluded.completed, + completed_at = excluded.completed_at, + archived = excluded.archived, + updated_at = excluded.updated_at; + +-- name: ListAllMemberships :many +SELECT * FROM project_item_memberships ORDER BY project_id, item_id; + +-- name: ListItemMemberships :many +SELECT * FROM project_item_memberships WHERE item_id = sqlc.arg(item_id) ORDER BY project_id; + +-- name: ListProjectMemberships :many +SELECT * FROM project_item_memberships WHERE project_id = sqlc.arg(project_id) ORDER BY position, item_id; + +-- name: GetMembership :one +SELECT * FROM project_item_memberships WHERE item_id = sqlc.arg(item_id) AND project_id = sqlc.arg(project_id); + +-- name: GetItemProjects :many +SELECT p.* +FROM projects AS p +INNER JOIN project_item_memberships AS m ON p.id = m.project_id +WHERE m.item_id = sqlc.arg(item_id) +ORDER BY p.position, p.created_at, p.id; + +-- name: AddItemToProject :exec +INSERT INTO project_item_memberships (item_id, project_id, position) +VALUES (sqlc.arg(item_id), sqlc.arg(project_id), sqlc.arg(item_position)); + +-- name: RemoveItemFromProject :exec +DELETE FROM project_item_memberships WHERE item_id = sqlc.arg(item_id) AND project_id = sqlc.arg(project_id); + +-- name: UpdateItemPosition :exec +UPDATE project_item_memberships +SET position = sqlc.arg(item_position) +WHERE item_id = sqlc.arg(item_id) AND project_id = sqlc.arg(project_id); + +-- name: RestoreMembership :exec +INSERT INTO project_item_memberships (item_id, project_id, position) +VALUES (sqlc.arg(item_id), sqlc.arg(project_id), sqlc.arg(item_position)) +ON CONFLICT (item_id, project_id) DO NOTHING; + +-- name: ListAllDependencies :many +SELECT * FROM project_item_dependencies ORDER BY item_id, depends_on_id; + +-- name: ListDependenciesInvolvingItem :many +SELECT * +FROM project_item_dependencies +WHERE item_id = sqlc.arg(item_id) OR depends_on_id = sqlc.arg(item_id) +ORDER BY item_id, depends_on_id; + +-- name: ListDependencyItems :many +SELECT pi.* +FROM project_items AS pi +INNER JOIN project_item_dependencies AS d ON pi.id = d.depends_on_id +WHERE d.item_id = sqlc.arg(item_id) +ORDER BY pi.number; + +-- name: AddDependency :exec +INSERT INTO project_item_dependencies (item_id, depends_on_id) +VALUES (sqlc.arg(item_id), sqlc.arg(depends_on_id)); + +-- name: RemoveDependency :exec +DELETE FROM project_item_dependencies WHERE item_id = sqlc.arg(item_id) AND depends_on_id = sqlc.arg(depends_on_id); + +-- name: RestoreDependency :exec +INSERT INTO project_item_dependencies (item_id, depends_on_id) +VALUES (sqlc.arg(item_id), sqlc.arg(depends_on_id)) +ON CONFLICT (item_id, depends_on_id) DO NOTHING; + +-- name: ListAllTasks :many +SELECT * FROM project_item_tasks ORDER BY item_id, position, created_at; + +-- name: ListTasksByItem :many +SELECT * FROM project_item_tasks WHERE item_id = sqlc.arg(item_id) ORDER BY position, created_at; + +-- name: GetTask :one +SELECT * FROM project_item_tasks WHERE id = sqlc.arg(id); + +-- name: CreateTask :one +INSERT INTO project_item_tasks (id, item_id, title, position) +VALUES (sqlc.arg(id), sqlc.arg(item_id), sqlc.arg(title), sqlc.arg(task_position)) +RETURNING *; + +-- name: UpdateTask :one +UPDATE project_item_tasks +SET + title = sqlc.arg(title), + completed = sqlc.arg(completed), + completed_at = sqlc.narg(completed_at), + position = sqlc.arg(task_position) +WHERE id = sqlc.arg(id) +RETURNING *; + +-- name: DeleteTask :exec +DELETE FROM project_item_tasks WHERE id = sqlc.arg(id); + +-- name: RestoreTask :exec +INSERT INTO project_item_tasks (id, item_id, title, completed, completed_at, position, created_at) +VALUES ( + sqlc.arg(id), sqlc.arg(item_id), sqlc.arg(title), sqlc.arg(completed), + sqlc.narg(completed_at), sqlc.arg(task_position), sqlc.arg(created_at) +) +ON CONFLICT (id) DO UPDATE SET + title = excluded.title, + completed = excluded.completed, + completed_at = excluded.completed_at, + position = excluded.position; + +-- name: InsertUndoEntry :exec +INSERT INTO undo_log (action, entity_type, entity_id, previous_state) +VALUES (sqlc.arg(action), sqlc.arg(entity_type), sqlc.arg(entity_id), sqlc.narg(previous_state)); + +-- name: GetLatestUndoEntry :one +SELECT * FROM undo_log ORDER BY id DESC LIMIT 1; + +-- name: DeleteUndoEntry :exec +DELETE FROM undo_log WHERE id = sqlc.arg(id); + +-- name: CountUndoEntries :one +SELECT count(*) FROM undo_log; diff --git a/store/postgres/sqlc.yaml b/store/postgres/sqlc.yaml new file mode 100644 index 0000000..f983f4f --- /dev/null +++ b/store/postgres/sqlc.yaml @@ -0,0 +1,24 @@ +version: "2" +sql: + - engine: "postgresql" + queries: "queries.sql" + schema: "migrations" + gen: + go: + package: "generated" + out: "generated" + sql_package: "pgx/v5" + emit_pointers_for_null_types: true + overrides: + - db_type: "uuid" + go_type: "string" + - db_type: "timestamptz" + go_type: + import: "time" + type: "Time" + - db_type: "timestamptz" + nullable: true + go_type: + import: "time" + type: "Time" + pointer: true diff --git a/store/postgres/store.go b/store/postgres/store.go new file mode 100644 index 0000000..d511e60 --- /dev/null +++ b/store/postgres/store.go @@ -0,0 +1,531 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/datapointchris/projects/model" + "github.com/datapointchris/projects/store" + "github.com/datapointchris/projects/store/postgres/generated" +) + +// writeLockKey names the one advisory lock every transaction takes, chosen for +// this database alone, since an advisory lock's key is shared by whoever else uses +// the same server's database. +const writeLockKey = 7_370_001 + +// The Postgres error codes a store names for its callers. +const ( + uniqueViolation = "23505" + invalidTextInput = "22P02" +) + +// The constraints a unique violation can name that store.Store promises a sentinel on. +const ( + activeNameIndex = "uq_projects_name_active" + projectIDKey = "pk_projects" + itemIDKey = "pk_project_items" + taskIDKey = "pk_project_item_tasks" +) + +// Store is store.Store over a connection pool. +type Store struct { + operations + pool *pgxpool.Pool +} + +var _ store.Store = (*Store)(nil) + +// New wraps a pool Connect returned. The caller keeps ownership of closing it. +func New(pool *pgxpool.Pool) *Store { + return &Store{operations: operations{q: generated.New(pool)}, pool: pool} +} + +// Ping reports whether the database answers. +func (s *Store) Ping(ctx context.Context) error { + return s.pool.Ping(ctx) +} + +// InTx implements store.Store. It holds the write lock from the start of each +// transaction, which meets the promise that what fn read holds at commit without a +// retry loop, at no cost a person's write rate can notice. The writer on ctx binds +// to transaction-local settings, which the audit trigger reads. +func (s *Store) InTx(ctx context.Context, fn func(tx store.Operations) error) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("beginning transaction: %w", err) + } + defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }() + + q := s.q.WithTx(tx) + if err := q.TakeWriteLock(ctx, writeLockKey); err != nil { + return fmt.Errorf("taking the write lock: %w", err) + } + writer := store.WriterFrom(ctx) + if err := q.BindWriter(ctx, generated.BindWriterParams{ + Actor: writer.Actor, + RequestID: writer.RequestID, + JobRunID: writer.JobRunID, + }); err != nil { + return fmt.Errorf("binding the writer: %w", err) + } + + if err := fn(operations{q: q}); err != nil { + return err + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("committing transaction: %w", err) + } + return nil +} + +// operations runs each store.Operations method over whichever pool or transaction +// its queries were bound to. +type operations struct { + q *generated.Queries +} + +// rowError gives the driver's no-row answer the name every store answers with. An +// id that does not parse names no row either. +func rowError(err error) error { + if errors.Is(err, pgx.ErrNoRows) { + return model.ErrNotFound + } + var driverErr *pgconn.PgError + if errors.As(err, &driverErr) && driverErr.Code == invalidTextInput { + return model.ErrNotFound + } + return err +} + +// duplicateError names a unique violation for the writes store.Store promises a +// sentinel on. +func duplicateError(err error) error { + var driverErr *pgconn.PgError + if errors.As(err, &driverErr) && driverErr.Code == uniqueViolation { + switch driverErr.ConstraintName { + case activeNameIndex: + return model.ErrDuplicateName + case projectIDKey, itemIDKey, taskIDKey: + return model.ErrDuplicateID + } + } + return rowError(err) +} + +func (o operations) ListProjects(ctx context.Context) ([]model.Project, error) { + rows, err := o.q.ListProjects(ctx) + if err != nil { + return nil, err + } + return listOf(rows, toModelProject), nil +} + +func (o operations) ListProjectItemFacts(ctx context.Context) ([]model.ProjectItemFact, error) { + rows, err := o.q.ListProjectItemFacts(ctx) + if err != nil { + return nil, err + } + return listOf(rows, func(r generated.ListProjectItemFactsRow) model.ProjectItemFact { + return model.ProjectItemFact{ProjectID: r.ProjectID, Completed: r.Completed, Archived: r.Archived, Repo: r.Repo} + }), nil +} + +func (o operations) ListProjectKinds(ctx context.Context) ([]string, error) { + return o.q.ListProjectKinds(ctx) +} + +func (o operations) ListProjectStatuses(ctx context.Context) ([]string, error) { + return o.q.ListProjectStatuses(ctx) +} + +func (o operations) GetProject(ctx context.Context, id string) (model.Project, error) { + row, err := o.q.GetProject(ctx, id) + if err != nil { + return model.Project{}, rowError(err) + } + return toModelProject(row), nil +} + +func (o operations) CreateProject(ctx context.Context, project model.Project) (model.Project, error) { + row, err := o.q.CreateProject(ctx, generated.CreateProjectParams{ + ID: project.ID, + Name: project.Name, + Description: project.Description, + Kind: project.Kind, + Status: project.Status, + StatusReason: project.StatusReason, + ClosedAt: project.ClosedAt, + ProjectPosition: int32(project.Position), + }) + if err != nil { + return model.Project{}, duplicateError(err) + } + return toModelProject(row), nil +} + +func (o operations) UpdateProject(ctx context.Context, project model.Project) (model.Project, error) { + row, err := o.q.UpdateProject(ctx, generated.UpdateProjectParams{ + ID: project.ID, + Name: project.Name, + Description: project.Description, + Kind: project.Kind, + Status: project.Status, + StatusReason: project.StatusReason, + ClosedAt: project.ClosedAt, + ProjectPosition: int32(project.Position), + }) + if err != nil { + return model.Project{}, duplicateError(err) + } + return toModelProject(row), nil +} + +func (o operations) UpdateProjectPosition(ctx context.Context, id string, position int) error { + return o.q.UpdateProjectPosition(ctx, generated.UpdateProjectPositionParams{ID: id, ProjectPosition: int32(position)}) +} + +func (o operations) DeleteProject(ctx context.Context, id string) error { + return o.q.DeleteProject(ctx, id) +} + +func (o operations) RestoreProject(ctx context.Context, project model.Project) error { + return duplicateError(o.q.RestoreProject(ctx, generated.RestoreProjectParams{ + ID: project.ID, + Name: project.Name, + Description: project.Description, + Kind: project.Kind, + Status: project.Status, + StatusReason: project.StatusReason, + ClosedAt: project.ClosedAt, + ProjectPosition: int32(project.Position), + CreatedAt: project.CreatedAt, + })) +} + +func (o operations) ListAllItems(ctx context.Context) ([]model.ProjectItem, error) { + rows, err := o.q.ListAllItems(ctx) + if err != nil { + return nil, err + } + return listOf(rows, toModelItem), nil +} + +func (o operations) ListItemsInProject(ctx context.Context, projectID string) ([]model.ProjectItemInProject, error) { + rows, err := o.q.ListItemsInProject(ctx, projectID) + if err != nil { + return nil, err + } + return listOf(rows, toModelItemInProject), nil +} + +func (o operations) ListBlockedItems(ctx context.Context) ([]model.ProjectItem, error) { + rows, err := o.q.ListBlockedItems(ctx) + if err != nil { + return nil, err + } + return listOf(rows, toModelItem), nil +} + +func (o operations) SearchItems(ctx context.Context, query string) ([]model.ProjectItem, error) { + rows, err := o.q.SearchItems(ctx, query) + if err != nil { + return nil, err + } + return listOf(rows, toModelItem), nil +} + +func (o operations) GetItem(ctx context.Context, id string) (model.ProjectItem, error) { + row, err := o.q.GetItem(ctx, id) + if err != nil { + return model.ProjectItem{}, rowError(err) + } + return toModelItem(row), nil +} + +func (o operations) GetItemByNumber(ctx context.Context, number int) (model.ProjectItem, error) { + row, err := o.q.GetItemByNumber(ctx, int64(number)) + if err != nil { + return model.ProjectItem{}, rowError(err) + } + return toModelItem(row), nil +} + +func (o operations) NextItemNumber(ctx context.Context) (int, error) { + next, err := o.q.NextItemNumber(ctx) + if err != nil { + return 0, err + } + return int(next), nil +} + +// errItemNeedsNumber is a service built without AssigningItemNumbers, because this +// schema is its own authority for numbers and the column holds no null. +var errItemNeedsNumber = errors.New("an item on Postgres needs a number from NextItemNumber") + +func (o operations) CreateItem(ctx context.Context, item model.ProjectItem) (model.ProjectItem, error) { + if item.Number == nil { + return model.ProjectItem{}, errItemNeedsNumber + } + row, err := o.q.CreateItem(ctx, generated.CreateItemParams{ + ID: item.ID, + ItemNumber: int64(*item.Number), + Title: item.Title, + Notes: item.Notes, + Repo: item.Repo, + }) + if err != nil { + return model.ProjectItem{}, duplicateError(err) + } + return toModelItem(row), nil +} + +func (o operations) UpdateItem(ctx context.Context, item model.ProjectItem) (model.ProjectItem, error) { + row, err := o.q.UpdateItem(ctx, generated.UpdateItemParams{ + ID: item.ID, + Title: item.Title, + Notes: item.Notes, + Repo: item.Repo, + Completed: item.Completed, + CompletedAt: item.CompletedAt, + Archived: item.Archived, + }) + if err != nil { + return model.ProjectItem{}, rowError(err) + } + return toModelItem(row), nil +} + +func (o operations) DeleteItem(ctx context.Context, id string) error { + return o.q.DeleteItem(ctx, id) +} + +func (o operations) RestoreItem(ctx context.Context, item model.ProjectItem) error { + if item.Number == nil { + return errItemNeedsNumber + } + return o.q.RestoreItem(ctx, generated.RestoreItemParams{ + ID: item.ID, + ItemNumber: int64(*item.Number), + Title: item.Title, + Notes: item.Notes, + Repo: item.Repo, + Completed: item.Completed, + CompletedAt: item.CompletedAt, + Archived: item.Archived, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + }) +} + +func (o operations) ListAllMemberships(ctx context.Context) ([]model.Membership, error) { + rows, err := o.q.ListAllMemberships(ctx) + if err != nil { + return nil, err + } + return listOf(rows, toModelMembership), nil +} + +func (o operations) ListItemMemberships(ctx context.Context, itemID string) ([]model.Membership, error) { + rows, err := o.q.ListItemMemberships(ctx, itemID) + if err != nil { + return nil, err + } + return listOf(rows, toModelMembership), nil +} + +func (o operations) ListProjectMemberships(ctx context.Context, projectID string) ([]model.Membership, error) { + rows, err := o.q.ListProjectMemberships(ctx, projectID) + if err != nil { + return nil, err + } + return listOf(rows, toModelMembership), nil +} + +func (o operations) GetMembership(ctx context.Context, itemID, projectID string) (model.Membership, error) { + row, err := o.q.GetMembership(ctx, generated.GetMembershipParams{ItemID: itemID, ProjectID: projectID}) + if err != nil { + return model.Membership{}, rowError(err) + } + return toModelMembership(row), nil +} + +func (o operations) GetItemProjects(ctx context.Context, itemID string) ([]model.Project, error) { + rows, err := o.q.GetItemProjects(ctx, itemID) + if err != nil { + return nil, err + } + return listOf(rows, toModelProject), nil +} + +func (o operations) AddItemToProject(ctx context.Context, membership model.Membership) error { + return o.q.AddItemToProject(ctx, generated.AddItemToProjectParams{ + ItemID: membership.ItemID, + ProjectID: membership.ProjectID, + ItemPosition: int32(membership.Position), + }) +} + +func (o operations) RemoveItemFromProject(ctx context.Context, itemID, projectID string) error { + return o.q.RemoveItemFromProject(ctx, generated.RemoveItemFromProjectParams{ItemID: itemID, ProjectID: projectID}) +} + +func (o operations) UpdateItemPosition(ctx context.Context, membership model.Membership) error { + return o.q.UpdateItemPosition(ctx, generated.UpdateItemPositionParams{ + ItemID: membership.ItemID, + ProjectID: membership.ProjectID, + ItemPosition: int32(membership.Position), + }) +} + +func (o operations) RestoreMembership(ctx context.Context, membership model.Membership) error { + return o.q.RestoreMembership(ctx, generated.RestoreMembershipParams{ + ItemID: membership.ItemID, + ProjectID: membership.ProjectID, + ItemPosition: int32(membership.Position), + }) +} + +func (o operations) ListAllDependencies(ctx context.Context) ([]model.Dependency, error) { + rows, err := o.q.ListAllDependencies(ctx) + if err != nil { + return nil, err + } + return listOf(rows, toModelDependency), nil +} + +func (o operations) ListDependenciesInvolvingItem(ctx context.Context, itemID string) ([]model.Dependency, error) { + rows, err := o.q.ListDependenciesInvolvingItem(ctx, itemID) + if err != nil { + return nil, err + } + return listOf(rows, toModelDependency), nil +} + +func (o operations) ListDependencyItems(ctx context.Context, itemID string) ([]model.ProjectItem, error) { + rows, err := o.q.ListDependencyItems(ctx, itemID) + if err != nil { + return nil, err + } + return listOf(rows, toModelItem), nil +} + +func (o operations) AddDependency(ctx context.Context, edge model.Dependency) error { + return o.q.AddDependency(ctx, generated.AddDependencyParams{ItemID: edge.ItemID, DependsOnID: edge.DependsOnID}) +} + +func (o operations) RemoveDependency(ctx context.Context, edge model.Dependency) error { + return o.q.RemoveDependency(ctx, generated.RemoveDependencyParams{ItemID: edge.ItemID, DependsOnID: edge.DependsOnID}) +} + +func (o operations) RestoreDependency(ctx context.Context, edge model.Dependency) error { + return o.q.RestoreDependency(ctx, generated.RestoreDependencyParams{ItemID: edge.ItemID, DependsOnID: edge.DependsOnID}) +} + +func (o operations) ListAllTasks(ctx context.Context) ([]model.ProjectItemTask, error) { + rows, err := o.q.ListAllTasks(ctx) + if err != nil { + return nil, err + } + return listOf(rows, toModelTask), nil +} + +func (o operations) ListTasksByItem(ctx context.Context, itemID string) ([]model.ProjectItemTask, error) { + rows, err := o.q.ListTasksByItem(ctx, itemID) + if err != nil { + return nil, err + } + return listOf(rows, toModelTask), nil +} + +func (o operations) GetTask(ctx context.Context, id string) (model.ProjectItemTask, error) { + row, err := o.q.GetTask(ctx, id) + if err != nil { + return model.ProjectItemTask{}, rowError(err) + } + return toModelTask(row), nil +} + +func (o operations) CreateTask(ctx context.Context, task model.ProjectItemTask) (model.ProjectItemTask, error) { + row, err := o.q.CreateTask(ctx, generated.CreateTaskParams{ + ID: task.ID, + ItemID: task.ItemID, + Title: task.Title, + TaskPosition: int32(task.Position), + }) + if err != nil { + return model.ProjectItemTask{}, duplicateError(err) + } + return toModelTask(row), nil +} + +func (o operations) UpdateTask(ctx context.Context, task model.ProjectItemTask) (model.ProjectItemTask, error) { + row, err := o.q.UpdateTask(ctx, generated.UpdateTaskParams{ + ID: task.ID, + Title: task.Title, + Completed: task.Completed, + CompletedAt: task.CompletedAt, + TaskPosition: int32(task.Position), + }) + if err != nil { + return model.ProjectItemTask{}, rowError(err) + } + return toModelTask(row), nil +} + +func (o operations) DeleteTask(ctx context.Context, id string) error { + return o.q.DeleteTask(ctx, id) +} + +func (o operations) RestoreTask(ctx context.Context, task model.ProjectItemTask) error { + return o.q.RestoreTask(ctx, generated.RestoreTaskParams{ + ID: task.ID, + ItemID: task.ItemID, + Title: task.Title, + Completed: task.Completed, + CompletedAt: task.CompletedAt, + TaskPosition: int32(task.Position), + CreatedAt: task.CreatedAt, + }) +} + +func (o operations) InsertUndoEntry(ctx context.Context, entry model.UndoEntry) error { + return o.q.InsertUndoEntry(ctx, generated.InsertUndoEntryParams{ + Action: entry.Action, + EntityType: entry.EntityType, + EntityID: entry.EntityID, + PreviousState: entry.PreviousState, + }) +} + +func (o operations) GetLatestUndoEntry(ctx context.Context) (model.UndoEntry, error) { + row, err := o.q.GetLatestUndoEntry(ctx) + if err != nil { + return model.UndoEntry{}, rowError(err) + } + return model.UndoEntry{ + ID: row.ID, + Action: row.Action, + EntityType: row.EntityType, + EntityID: row.EntityID, + PreviousState: row.PreviousState, + CreatedAt: row.CreatedAt.UTC(), + }, nil +} + +func (o operations) DeleteUndoEntry(ctx context.Context, id int64) error { + return o.q.DeleteUndoEntry(ctx, id) +} + +func (o operations) CountUndoEntries(ctx context.Context) (int, error) { + count, err := o.q.CountUndoEntries(ctx) + if err != nil { + return 0, err + } + return int(count), nil +} diff --git a/store/sqlite/convert.go b/store/sqlite/convert.go index 1235905..dd7df27 100644 --- a/store/sqlite/convert.go +++ b/store/sqlite/convert.go @@ -22,49 +22,25 @@ func parseTime(s string) time.Time { return time.Time{} } -// projectFields is what every project-shaped row has in common. sqlc generates a -// distinct struct per query, so without this the same eight-field mapping is -// written three times and only two of them get updated when a column is added. -type projectFields struct { - ID string - Name string - Description sql.NullString - Status string - StatusReason sql.NullString - ClosedAt sql.NullString - Position int64 - CreatedAt string -} - -func toModelProjectFields(f projectFields) model.Project { +func toModelProject(p generated.Project) model.Project { proj := model.Project{ - ID: f.ID, - Name: f.Name, - Status: f.Status, - Position: int(f.Position), - CreatedAt: parseTime(f.CreatedAt), - } - if f.Description.Valid { - proj.Description = &f.Description.String + ID: p.ID, + Name: p.Name, + Kind: p.Kind, + Status: p.Status, + Position: int(p.Position), + CreatedAt: parseTime(p.CreatedAt), } - if f.StatusReason.Valid { - proj.StatusReason = &f.StatusReason.String + if p.Description.Valid { + proj.Description = &p.Description.String } - if f.ClosedAt.Valid { - closed := parseTime(f.ClosedAt.String) - proj.ClosedAt = &closed + if p.StatusReason.Valid { + proj.StatusReason = &p.StatusReason.String } + proj.ClosedAt = nullTimeToPointer(p.ClosedAt) return proj } -func toModelProject(p generated.Project) model.Project { - return toModelProjectFields(projectFields{ - ID: p.ID, Name: p.Name, Description: p.Description, - Status: p.Status, StatusReason: p.StatusReason, ClosedAt: p.ClosedAt, - Position: p.Position, CreatedAt: p.CreatedAt, - }) -} - func toModelProjects(ps []generated.Project) []model.Project { out := make([]model.Project, len(ps)) for i, p := range ps { @@ -73,39 +49,16 @@ func toModelProjects(ps []generated.Project) []model.Project { return out } -func toModelProjectWithItemCount(row generated.ListProjectsWithItemCountRow) model.ProjectWithItemCount { - return model.ProjectWithItemCount{ - Project: toModelProjectFields(projectFields{ - ID: row.ID, Name: row.Name, Description: row.Description, - Status: row.Status, StatusReason: row.StatusReason, ClosedAt: row.ClosedAt, - Position: row.Position, CreatedAt: row.CreatedAt, - }), - ItemCount: int(row.ItemCount), - CompletedCount: int(row.CompletedCount), - } -} - -func toModelProjectWithItemCountFromGet(row generated.GetProjectWithItemCountRow) model.ProjectWithItemCount { - return model.ProjectWithItemCount{ - Project: toModelProjectFields(projectFields{ - ID: row.ID, Name: row.Name, Description: row.Description, - Status: row.Status, StatusReason: row.StatusReason, ClosedAt: row.ClosedAt, - Position: row.Position, CreatedAt: row.CreatedAt, - }), - ItemCount: int(row.ItemCount), - CompletedCount: int(row.CompletedCount), - } -} - func toModelProjectItem(pi generated.ProjectItem) model.ProjectItem { item := model.ProjectItem{ - ID: pi.ID, - Number: nullInt64ToIntPtr(pi.Number), - Title: pi.Title, - Completed: pi.Completed != 0, - Archived: pi.Archived != 0, - CreatedAt: parseTime(pi.CreatedAt), - UpdatedAt: parseTime(pi.UpdatedAt), + ID: pi.ID, + Number: nullInt64ToIntPtr(pi.Number), + Title: pi.Title, + Completed: pi.Completed != 0, + CompletedAt: nullTimeToPointer(pi.CompletedAt), + Archived: pi.Archived != 0, + CreatedAt: parseTime(pi.CreatedAt), + UpdatedAt: parseTime(pi.UpdatedAt), } if pi.Notes.Valid { item.Notes = &pi.Notes.String @@ -124,60 +77,36 @@ func toModelProjectItems(pis []generated.ProjectItem) []model.ProjectItem { return out } -func toModelProjectItemInProject(row generated.ListItemsByProjectRow) model.ProjectItemInProject { - item := model.ProjectItem{ - ID: row.ID, - Number: nullInt64ToIntPtr(row.Number), - Title: row.Title, - Completed: row.Completed != 0, - Archived: row.Archived != 0, - CreatedAt: parseTime(row.CreatedAt), - UpdatedAt: parseTime(row.UpdatedAt), - } - if row.Notes.Valid { - item.Notes = &row.Notes.String - } - if row.Repo.Valid { - item.Repo = &row.Repo.String - } +func toModelProjectItemInProject(row generated.ListItemsInProjectRow) model.ProjectItemInProject { return model.ProjectItemInProject{ - ProjectItem: item, + ProjectItem: toModelProjectItem(generated.ProjectItem{ + ID: row.ID, Number: row.Number, Title: row.Title, Notes: row.Notes, Repo: row.Repo, + Completed: row.Completed, CompletedAt: row.CompletedAt, Archived: row.Archived, + CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, + }), Position: int(row.MembershipPosition), ProjectCount: int(row.ProjectCount), } } -func toModelProjectItemInProjectFromArchived(row generated.ListArchivedItemsRow) model.ProjectItemInProject { - item := model.ProjectItem{ - ID: row.ID, - Number: nullInt64ToIntPtr(row.Number), - Title: row.Title, - Completed: row.Completed != 0, - Archived: row.Archived != 0, - CreatedAt: parseTime(row.CreatedAt), - UpdatedAt: parseTime(row.UpdatedAt), - } - if row.Notes.Valid { - item.Notes = &row.Notes.String - } - if row.Repo.Valid { - item.Repo = &row.Repo.String - } - return model.ProjectItemInProject{ - ProjectItem: item, - Position: int(row.MembershipPosition), +func toModelProjectItemTask(t generated.ProjectItemTask) model.ProjectItemTask { + return model.ProjectItemTask{ + ID: t.ID, + ItemID: t.ItemID, + Title: t.Title, + Completed: t.Completed != 0, + CompletedAt: nullTimeToPointer(t.CompletedAt), + Position: int(t.Position), + CreatedAt: parseTime(t.CreatedAt), } } -func toModelProjectItemTask(t generated.ProjectItemTask) model.ProjectItemTask { - return model.ProjectItemTask{ - ID: t.ID, - ItemID: t.ItemID, - Title: t.Title, - Completed: t.Completed != 0, - Position: int(t.Position), - CreatedAt: parseTime(t.CreatedAt), +func nullTimeToPointer(s sql.NullString) *time.Time { + if !s.Valid { + return nil } + parsed := parseTime(s.String) + return &parsed } func toNullString(s *string) sql.NullString { diff --git a/store/sqlite/generated/models.go b/store/sqlite/generated/models.go index 24c0703..98361e1 100644 --- a/store/sqlite/generated/models.go +++ b/store/sqlite/generated/models.go @@ -23,6 +23,7 @@ type Project struct { ID string Name string Description sql.NullString + Kind string Status string StatusReason sql.NullString ClosedAt sql.NullString @@ -31,15 +32,16 @@ type Project struct { } type ProjectItem struct { - ID string - Number sql.NullInt64 - Title string - Notes sql.NullString - Repo sql.NullString - Completed int64 - Archived int64 - CreatedAt string - UpdatedAt string + ID string + Number sql.NullInt64 + Title string + Notes sql.NullString + Repo sql.NullString + Completed int64 + CompletedAt sql.NullString + Archived int64 + CreatedAt string + UpdatedAt string } type ProjectItemDependency struct { @@ -54,12 +56,21 @@ type ProjectItemMembership struct { } type ProjectItemTask struct { - ID string - ItemID string - Title string - Completed int64 - Position int64 - CreatedAt string + ID string + ItemID string + Title string + Completed int64 + CompletedAt sql.NullString + Position int64 + CreatedAt string +} + +type ProjectKind struct { + Name string +} + +type ProjectStatus struct { + Name string } type SyncOrigin struct { diff --git a/store/sqlite/generated/queries.sql.go b/store/sqlite/generated/queries.sql.go index b4be583..19d2e3d 100644 --- a/store/sqlite/generated/queries.sql.go +++ b/store/sqlite/generated/queries.sql.go @@ -26,17 +26,17 @@ func (q *Queries) AddDependency(ctx context.Context, arg AddDependencyParams) er const addItemToProject = `-- name: AddItemToProject :exec INSERT INTO project_item_memberships (item_id, project_id, position) -VALUES (?, ?, (SELECT COALESCE(MAX(m.position), 0) + 1 FROM project_item_memberships m WHERE m.project_id = ?)) +VALUES (?, ?, ?) ` type AddItemToProjectParams struct { - ItemID string - ProjectID string - ProjectID_2 string + ItemID string + ProjectID string + Position int64 } func (q *Queries) AddItemToProject(ctx context.Context, arg AddItemToProjectParams) error { - _, err := q.db.ExecContext(ctx, addItemToProject, arg.ItemID, arg.ProjectID, arg.ProjectID_2) + _, err := q.db.ExecContext(ctx, addItemToProject, arg.ItemID, arg.ProjectID, arg.Position) return err } @@ -91,7 +91,7 @@ func (q *Queries) CountUndoLogs(ctx context.Context) (int64, error) { const createItem = `-- name: CreateItem :one INSERT INTO project_items (id, number, title, notes, repo) VALUES (?, ?, ?, ?, ?) -RETURNING id, number, title, notes, repo, completed, archived, created_at, updated_at +RETURNING id, number, title, notes, repo, completed, completed_at, archived, created_at, updated_at ` type CreateItemParams struct { @@ -118,6 +118,7 @@ func (q *Queries) CreateItem(ctx context.Context, arg CreateItemParams) (Project &i.Notes, &i.Repo, &i.Completed, + &i.CompletedAt, &i.Archived, &i.CreatedAt, &i.UpdatedAt, @@ -126,24 +127,39 @@ func (q *Queries) CreateItem(ctx context.Context, arg CreateItemParams) (Project } const createProject = `-- name: CreateProject :one -INSERT INTO projects (id, name, description, position) -VALUES (?, ?, ?, (SELECT COALESCE(MAX(position), 0) + 1 FROM projects)) -RETURNING id, name, description, status, status_reason, closed_at, position, created_at +INSERT INTO projects (id, name, description, kind, status, status_reason, closed_at, position) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +RETURNING id, name, description, kind, status, status_reason, closed_at, position, created_at ` type CreateProjectParams struct { - ID string - Name string - Description sql.NullString + ID string + Name string + Description sql.NullString + Kind string + Status string + StatusReason sql.NullString + ClosedAt sql.NullString + Position int64 } func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) { - row := q.db.QueryRowContext(ctx, createProject, arg.ID, arg.Name, arg.Description) + row := q.db.QueryRowContext(ctx, createProject, + arg.ID, + arg.Name, + arg.Description, + arg.Kind, + arg.Status, + arg.StatusReason, + arg.ClosedAt, + arg.Position, + ) var i Project err := row.Scan( &i.ID, &i.Name, &i.Description, + &i.Kind, &i.Status, &i.StatusReason, &i.ClosedAt, @@ -155,15 +171,15 @@ func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (P const createTask = `-- name: CreateTask :one INSERT INTO project_item_tasks (id, item_id, title, position) -VALUES (?, ?, ?, (SELECT COALESCE(MAX(t.position), 0) + 1 FROM project_item_tasks t WHERE t.item_id = ?)) -RETURNING id, item_id, title, completed, position, created_at +VALUES (?, ?, ?, ?) +RETURNING id, item_id, title, completed, completed_at, position, created_at ` type CreateTaskParams struct { ID string ItemID string Title string - ItemID_2 string + Position int64 } func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (ProjectItemTask, error) { @@ -171,7 +187,7 @@ func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Project arg.ID, arg.ItemID, arg.Title, - arg.ItemID_2, + arg.Position, ) var i ProjectItemTask err := row.Scan( @@ -179,6 +195,7 @@ func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Project &i.ItemID, &i.Title, &i.Completed, + &i.CompletedAt, &i.Position, &i.CreatedAt, ) @@ -279,7 +296,7 @@ func (q *Queries) DeleteUndoLog(ctx context.Context, id int64) error { } const getAllDependencies = `-- name: GetAllDependencies :many -SELECT item_id, depends_on_id FROM project_item_dependencies +SELECT item_id, depends_on_id FROM project_item_dependencies ORDER BY item_id, depends_on_id ` func (q *Queries) GetAllDependencies(ctx context.Context) ([]ProjectItemDependency, error) { @@ -305,48 +322,10 @@ func (q *Queries) GetAllDependencies(ctx context.Context) ([]ProjectItemDependen return items, nil } -const getBlockers = `-- name: GetBlockers :many -SELECT pi.id, pi.number, pi.title, pi.notes, pi.repo, pi.completed, pi.archived, pi.created_at, pi.updated_at -FROM project_items pi -INNER JOIN project_item_dependencies d ON pi.id = d.depends_on_id -WHERE d.item_id = ? AND pi.completed = 0 -` - -func (q *Queries) GetBlockers(ctx context.Context, itemID string) ([]ProjectItem, error) { - rows, err := q.db.QueryContext(ctx, getBlockers, itemID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ProjectItem - for rows.Next() { - var i ProjectItem - if err := rows.Scan( - &i.ID, - &i.Number, - &i.Title, - &i.Notes, - &i.Repo, - &i.Completed, - &i.Archived, - &i.CreatedAt, - &i.UpdatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const getDependenciesInvolvingItem = `-- name: GetDependenciesInvolvingItem :many -SELECT item_id, depends_on_id FROM project_item_dependencies WHERE item_id = ? OR depends_on_id = ? +SELECT item_id, depends_on_id FROM project_item_dependencies +WHERE item_id = ? OR depends_on_id = ? +ORDER BY item_id, depends_on_id ` type GetDependenciesInvolvingItemParams struct { @@ -379,39 +358,34 @@ func (q *Queries) GetDependenciesInvolvingItem(ctx context.Context, arg GetDepen return items, nil } -const getDependencyIDs = `-- name: GetDependencyIDs :many -SELECT depends_on_id FROM project_item_dependencies WHERE item_id = ? +const getItem = `-- name: GetItem :one +SELECT id, number, title, notes, repo, completed, completed_at, archived, created_at, updated_at FROM project_items WHERE id = ? ` -func (q *Queries) GetDependencyIDs(ctx context.Context, itemID string) ([]string, error) { - rows, err := q.db.QueryContext(ctx, getDependencyIDs, itemID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []string - for rows.Next() { - var depends_on_id string - if err := rows.Scan(&depends_on_id); err != nil { - return nil, err - } - items = append(items, depends_on_id) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil +func (q *Queries) GetItem(ctx context.Context, id string) (ProjectItem, error) { + row := q.db.QueryRowContext(ctx, getItem, id) + var i ProjectItem + err := row.Scan( + &i.ID, + &i.Number, + &i.Title, + &i.Notes, + &i.Repo, + &i.Completed, + &i.CompletedAt, + &i.Archived, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err } -const getItem = `-- name: GetItem :one -SELECT id, number, title, notes, repo, completed, archived, created_at, updated_at FROM project_items WHERE id = ? +const getItemByNumber = `-- name: GetItemByNumber :one +SELECT id, number, title, notes, repo, completed, completed_at, archived, created_at, updated_at FROM project_items WHERE number = ? ` -func (q *Queries) GetItem(ctx context.Context, id string) (ProjectItem, error) { - row := q.db.QueryRowContext(ctx, getItem, id) +func (q *Queries) GetItemByNumber(ctx context.Context, number sql.NullInt64) (ProjectItem, error) { + row := q.db.QueryRowContext(ctx, getItemByNumber, number) var i ProjectItem err := row.Scan( &i.ID, @@ -420,6 +394,7 @@ func (q *Queries) GetItem(ctx context.Context, id string) (ProjectItem, error) { &i.Notes, &i.Repo, &i.Completed, + &i.CompletedAt, &i.Archived, &i.CreatedAt, &i.UpdatedAt, @@ -428,7 +403,7 @@ func (q *Queries) GetItem(ctx context.Context, id string) (ProjectItem, error) { } const getItemMemberships = `-- name: GetItemMemberships :many -SELECT item_id, project_id, position FROM project_item_memberships WHERE item_id = ? +SELECT item_id, project_id, position FROM project_item_memberships WHERE item_id = ? ORDER BY project_id ` func (q *Queries) GetItemMemberships(ctx context.Context, itemID string) ([]ProjectItemMembership, error) { @@ -455,11 +430,11 @@ func (q *Queries) GetItemMemberships(ctx context.Context, itemID string) ([]Proj } const getItemProjects = `-- name: GetItemProjects :many -SELECT p.id, p.name, p.description, p.status, p.status_reason, p.closed_at, p.position, p.created_at +SELECT p.id, p.name, p.description, p.kind, p.status, p.status_reason, p.closed_at, p.position, p.created_at FROM projects p INNER JOIN project_item_memberships m ON p.id = m.project_id WHERE m.item_id = ? -ORDER BY p.name +ORDER BY p.position, p.created_at, p.id ` func (q *Queries) GetItemProjects(ctx context.Context, itemID string) ([]Project, error) { @@ -475,6 +450,7 @@ func (q *Queries) GetItemProjects(ctx context.Context, itemID string) ([]Project &i.ID, &i.Name, &i.Description, + &i.Kind, &i.Status, &i.StatusReason, &i.ClosedAt, @@ -549,7 +525,7 @@ func (q *Queries) GetOldestPendingSync(ctx context.Context) (PendingSync, error) } const getProject = `-- name: GetProject :one -SELECT id, name, description, status, status_reason, closed_at, position, created_at FROM projects WHERE id = ? +SELECT id, name, description, kind, status, status_reason, closed_at, position, created_at FROM projects WHERE id = ? ` func (q *Queries) GetProject(ctx context.Context, id string) (Project, error) { @@ -559,6 +535,7 @@ func (q *Queries) GetProject(ctx context.Context, id string) (Project, error) { &i.ID, &i.Name, &i.Description, + &i.Kind, &i.Status, &i.StatusReason, &i.ClosedAt, @@ -569,7 +546,7 @@ func (q *Queries) GetProject(ctx context.Context, id string) (Project, error) { } const getProjectMemberships = `-- name: GetProjectMemberships :many -SELECT item_id, project_id, position FROM project_item_memberships WHERE project_id = ? +SELECT item_id, project_id, position FROM project_item_memberships WHERE project_id = ? ORDER BY position, item_id ` func (q *Queries) GetProjectMemberships(ctx context.Context, projectID string) ([]ProjectItemMembership, error) { @@ -595,50 +572,6 @@ func (q *Queries) GetProjectMemberships(ctx context.Context, projectID string) ( return items, nil } -const getProjectWithItemCount = `-- name: GetProjectWithItemCount :one -SELECT - p.id, p.name, p.description, p.status, p.status_reason, p.closed_at, p.position, p.created_at, - CAST(COALESCE(SUM(CASE WHEN pi.completed = 1 THEN 1 ELSE 0 END), 0) AS INTEGER) - AS completed_count, - COUNT(pi.id) AS item_count -FROM projects p -LEFT JOIN project_item_memberships m ON p.id = m.project_id -LEFT JOIN project_items pi ON m.item_id = pi.id AND pi.archived = 0 -WHERE p.id = ? -GROUP BY p.id -` - -type GetProjectWithItemCountRow struct { - ID string - Name string - Description sql.NullString - Status string - StatusReason sql.NullString - ClosedAt sql.NullString - Position int64 - CreatedAt string - CompletedCount int64 - ItemCount int64 -} - -func (q *Queries) GetProjectWithItemCount(ctx context.Context, id string) (GetProjectWithItemCountRow, error) { - row := q.db.QueryRowContext(ctx, getProjectWithItemCount, id) - var i GetProjectWithItemCountRow - err := row.Scan( - &i.ID, - &i.Name, - &i.Description, - &i.Status, - &i.StatusReason, - &i.ClosedAt, - &i.Position, - &i.CreatedAt, - &i.CompletedCount, - &i.ItemCount, - ) - return i, err -} - const getSyncOrigin = `-- name: GetSyncOrigin :one SELECT api_url FROM sync_origin WHERE id = 1 ` @@ -651,7 +584,7 @@ func (q *Queries) GetSyncOrigin(ctx context.Context) (string, error) { } const getTask = `-- name: GetTask :one -SELECT id, item_id, title, completed, position, created_at FROM project_item_tasks WHERE id = ? +SELECT id, item_id, title, completed, completed_at, position, created_at FROM project_item_tasks WHERE id = ? ` func (q *Queries) GetTask(ctx context.Context, id string) (ProjectItemTask, error) { @@ -662,6 +595,7 @@ func (q *Queries) GetTask(ctx context.Context, id string) (ProjectItemTask, erro &i.ItemID, &i.Title, &i.Completed, + &i.CompletedAt, &i.Position, &i.CreatedAt, ) @@ -730,7 +664,7 @@ func (q *Queries) InsertUndoLog(ctx context.Context, arg InsertUndoLogParams) er } const listAllItemsRaw = `-- name: ListAllItemsRaw :many -SELECT id, number, title, notes, repo, completed, archived, created_at, updated_at FROM project_items ORDER BY created_at DESC +SELECT id, number, title, notes, repo, completed, completed_at, archived, created_at, updated_at FROM project_items ORDER BY created_at DESC ` func (q *Queries) ListAllItemsRaw(ctx context.Context) ([]ProjectItem, error) { @@ -749,6 +683,7 @@ func (q *Queries) ListAllItemsRaw(ctx context.Context) ([]ProjectItem, error) { &i.Notes, &i.Repo, &i.Completed, + &i.CompletedAt, &i.Archived, &i.CreatedAt, &i.UpdatedAt, @@ -767,7 +702,7 @@ func (q *Queries) ListAllItemsRaw(ctx context.Context) ([]ProjectItem, error) { } const listAllMemberships = `-- name: ListAllMemberships :many -SELECT item_id, project_id, position FROM project_item_memberships +SELECT item_id, project_id, position FROM project_item_memberships ORDER BY project_id, item_id ` func (q *Queries) ListAllMemberships(ctx context.Context) ([]ProjectItemMembership, error) { @@ -794,7 +729,7 @@ func (q *Queries) ListAllMemberships(ctx context.Context) ([]ProjectItemMembersh } const listAllProjectsRaw = `-- name: ListAllProjectsRaw :many -SELECT id, name, description, status, status_reason, closed_at, position, created_at FROM projects ORDER BY position, name +SELECT id, name, description, kind, status, status_reason, closed_at, position, created_at FROM projects ORDER BY position, name ` func (q *Queries) ListAllProjectsRaw(ctx context.Context) ([]Project, error) { @@ -810,6 +745,7 @@ func (q *Queries) ListAllProjectsRaw(ctx context.Context) ([]Project, error) { &i.ID, &i.Name, &i.Description, + &i.Kind, &i.Status, &i.StatusReason, &i.ClosedAt, @@ -830,7 +766,7 @@ func (q *Queries) ListAllProjectsRaw(ctx context.Context) ([]Project, error) { } const listAllTasks = `-- name: ListAllTasks :many -SELECT id, item_id, title, completed, position, created_at FROM project_item_tasks ORDER BY item_id, position +SELECT id, item_id, title, completed, completed_at, position, created_at FROM project_item_tasks ORDER BY item_id, position, created_at ` func (q *Queries) ListAllTasks(ctx context.Context) ([]ProjectItemTask, error) { @@ -847,6 +783,7 @@ func (q *Queries) ListAllTasks(ctx context.Context) ([]ProjectItemTask, error) { &i.ItemID, &i.Title, &i.Completed, + &i.CompletedAt, &i.Position, &i.CreatedAt, ); err != nil { @@ -863,36 +800,26 @@ func (q *Queries) ListAllTasks(ctx context.Context) ([]ProjectItemTask, error) { return items, nil } -const listArchivedItems = `-- name: ListArchivedItems :many -SELECT pi.id, pi.number, pi.title, pi.notes, pi.repo, pi.completed, pi.archived, pi.created_at, pi.updated_at, m.position AS membership_position +const listBlockedItems = `-- name: ListBlockedItems :many +SELECT DISTINCT pi.id, pi.number, pi.title, pi.notes, pi.repo, pi.completed, pi.completed_at, pi.archived, pi.created_at, pi.updated_at FROM project_items pi -INNER JOIN project_item_memberships m ON pi.id = m.item_id -WHERE m.project_id = ? AND pi.archived = 1 -ORDER BY pi.updated_at DESC +INNER JOIN project_item_dependencies d ON pi.id = d.item_id +INNER JOIN project_items blocker ON d.depends_on_id = blocker.id +WHERE blocker.completed = 0 + AND pi.completed = 0 + AND pi.archived = 0 +ORDER BY pi.created_at DESC, pi.id ASC ` -type ListArchivedItemsRow struct { - ID string - Number sql.NullInt64 - Title string - Notes sql.NullString - Repo sql.NullString - Completed int64 - Archived int64 - CreatedAt string - UpdatedAt string - MembershipPosition int64 -} - -func (q *Queries) ListArchivedItems(ctx context.Context, projectID string) ([]ListArchivedItemsRow, error) { - rows, err := q.db.QueryContext(ctx, listArchivedItems, projectID) +func (q *Queries) ListBlockedItems(ctx context.Context) ([]ProjectItem, error) { + rows, err := q.db.QueryContext(ctx, listBlockedItems) if err != nil { return nil, err } defer rows.Close() - var items []ListArchivedItemsRow + var items []ProjectItem for rows.Next() { - var i ListArchivedItemsRow + var i ProjectItem if err := rows.Scan( &i.ID, &i.Number, @@ -900,10 +827,10 @@ func (q *Queries) ListArchivedItems(ctx context.Context, projectID string) ([]Li &i.Notes, &i.Repo, &i.Completed, + &i.CompletedAt, &i.Archived, &i.CreatedAt, &i.UpdatedAt, - &i.MembershipPosition, ); err != nil { return nil, err } @@ -918,18 +845,16 @@ func (q *Queries) ListArchivedItems(ctx context.Context, projectID string) ([]Li return items, nil } -const listBlockedItems = `-- name: ListBlockedItems :many -SELECT DISTINCT pi.id, pi.number, pi.title, pi.notes, pi.repo, pi.completed, pi.archived, pi.created_at, pi.updated_at +const listDependencyItems = `-- name: ListDependencyItems :many +SELECT pi.id, pi.number, pi.title, pi.notes, pi.repo, pi.completed, pi.completed_at, pi.archived, pi.created_at, pi.updated_at FROM project_items pi -INNER JOIN project_item_dependencies d ON pi.id = d.item_id -INNER JOIN project_items blocker ON d.depends_on_id = blocker.id -WHERE blocker.completed = 0 - AND pi.completed = 0 - AND pi.archived = 0 +INNER JOIN project_item_dependencies d ON pi.id = d.depends_on_id +WHERE d.item_id = ? +ORDER BY pi.number, pi.id ` -func (q *Queries) ListBlockedItems(ctx context.Context) ([]ProjectItem, error) { - rows, err := q.db.QueryContext(ctx, listBlockedItems) +func (q *Queries) ListDependencyItems(ctx context.Context, itemID string) ([]ProjectItem, error) { + rows, err := q.db.QueryContext(ctx, listDependencyItems, itemID) if err != nil { return nil, err } @@ -944,6 +869,7 @@ func (q *Queries) ListBlockedItems(ctx context.Context) ([]ProjectItem, error) { &i.Notes, &i.Repo, &i.Completed, + &i.CompletedAt, &i.Archived, &i.CreatedAt, &i.UpdatedAt, @@ -961,22 +887,23 @@ func (q *Queries) ListBlockedItems(ctx context.Context) ([]ProjectItem, error) { return items, nil } -const listItemsByProject = `-- name: ListItemsByProject :many -SELECT pi.id, pi.number, pi.title, pi.notes, pi.repo, pi.completed, pi.archived, pi.created_at, pi.updated_at, m.position AS membership_position, +const listItemsInProject = `-- name: ListItemsInProject :many +SELECT pi.id, pi.number, pi.title, pi.notes, pi.repo, pi.completed, pi.completed_at, pi.archived, pi.created_at, pi.updated_at, m.position AS membership_position, (SELECT COUNT(*) FROM project_item_memberships m2 WHERE m2.item_id = pi.id) AS project_count FROM project_items pi INNER JOIN project_item_memberships m ON pi.id = m.item_id -WHERE m.project_id = ? AND pi.archived = 0 -ORDER BY m.position, pi.created_at +WHERE m.project_id = ? +ORDER BY m.position, pi.created_at, pi.id ` -type ListItemsByProjectRow struct { +type ListItemsInProjectRow struct { ID string Number sql.NullInt64 Title string Notes sql.NullString Repo sql.NullString Completed int64 + CompletedAt sql.NullString Archived int64 CreatedAt string UpdatedAt string @@ -984,15 +911,15 @@ type ListItemsByProjectRow struct { ProjectCount int64 } -func (q *Queries) ListItemsByProject(ctx context.Context, projectID string) ([]ListItemsByProjectRow, error) { - rows, err := q.db.QueryContext(ctx, listItemsByProject, projectID) +func (q *Queries) ListItemsInProject(ctx context.Context, projectID string) ([]ListItemsInProjectRow, error) { + rows, err := q.db.QueryContext(ctx, listItemsInProject, projectID) if err != nil { return nil, err } defer rows.Close() - var items []ListItemsByProjectRow + var items []ListItemsInProjectRow for rows.Next() { - var i ListItemsByProjectRow + var i ListItemsInProjectRow if err := rows.Scan( &i.ID, &i.Number, @@ -1000,6 +927,7 @@ func (q *Queries) ListItemsByProject(ctx context.Context, projectID string) ([]L &i.Notes, &i.Repo, &i.Completed, + &i.CompletedAt, &i.Archived, &i.CreatedAt, &i.UpdatedAt, @@ -1082,67 +1010,34 @@ func (q *Queries) ListPendingSyncEntityIDsAfter(ctx context.Context, id int64) ( return items, nil } -const listProjectsWithItemCount = `-- name: ListProjectsWithItemCount :many -SELECT - p.id, p.name, p.description, p.status, p.status_reason, p.closed_at, p.position, p.created_at, - CAST(COALESCE(SUM(CASE WHEN pi.completed = 1 THEN 1 ELSE 0 END), 0) AS INTEGER) - AS completed_count, - COUNT(pi.id) AS item_count -FROM projects p -LEFT JOIN project_item_memberships m ON p.id = m.project_id -LEFT JOIN project_items pi ON m.item_id = pi.id AND pi.archived = 0 -WHERE CAST(?1 AS TEXT) = 'all' OR p.status = CAST(?1 AS TEXT) -GROUP BY p.id -ORDER BY - CASE p.status WHEN 'active' THEN 0 WHEN 'someday' THEN 1 ELSE 2 END, - p.closed_at DESC, p.position ASC, p.name ASC -` - -type ListProjectsWithItemCountRow struct { - ID string - Name string - Description sql.NullString - Status string - StatusReason sql.NullString - ClosedAt sql.NullString - Position int64 - CreatedAt string - CompletedCount int64 - ItemCount int64 -} - -// Terminal projects are hidden unless asked for: closing a project is what takes -// it out of the list, so an unfiltered default would make completing one do -// nothing visible. Named parameter so the filter reads as a status rather than -// a bare `?`, and so sqlfluff sees a bind rather than an unqualified column. -// -// The completed split rides along with the total rather than being a second -// query: the pane draws both numbers on every row, so asking twice would mean a -// query per project. -// -// The order is active, then someday, then closed. A someday project has no -// closed_at, so ordering on closed_at alone would mix it in among the active -// ones. -func (q *Queries) ListProjectsWithItemCount(ctx context.Context, statusFilter string) ([]ListProjectsWithItemCountRow, error) { - rows, err := q.db.QueryContext(ctx, listProjectsWithItemCount, statusFilter) +const listProjectItemFacts = `-- name: ListProjectItemFacts :many +SELECT m.project_id, pi.completed, pi.archived, pi.repo +FROM project_item_memberships m +INNER JOIN project_items pi ON m.item_id = pi.id +` + +type ListProjectItemFactsRow struct { + ProjectID string + Completed int64 + Archived int64 + Repo sql.NullString +} + +// One row per membership, which the rule adding them up reads whole. +func (q *Queries) ListProjectItemFacts(ctx context.Context) ([]ListProjectItemFactsRow, error) { + rows, err := q.db.QueryContext(ctx, listProjectItemFacts) if err != nil { return nil, err } defer rows.Close() - var items []ListProjectsWithItemCountRow + var items []ListProjectItemFactsRow for rows.Next() { - var i ListProjectsWithItemCountRow + var i ListProjectItemFactsRow if err := rows.Scan( - &i.ID, - &i.Name, - &i.Description, - &i.Status, - &i.StatusReason, - &i.ClosedAt, - &i.Position, - &i.CreatedAt, - &i.CompletedCount, - &i.ItemCount, + &i.ProjectID, + &i.Completed, + &i.Archived, + &i.Repo, ); err != nil { return nil, err } @@ -1157,8 +1052,62 @@ func (q *Queries) ListProjectsWithItemCount(ctx context.Context, statusFilter st return items, nil } +const listProjectKinds = `-- name: ListProjectKinds :many +SELECT name FROM project_kinds ORDER BY name +` + +func (q *Queries) ListProjectKinds(ctx context.Context) ([]string, error) { + rows, err := q.db.QueryContext(ctx, listProjectKinds) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + items = append(items, name) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listProjectStatuses = `-- name: ListProjectStatuses :many +SELECT name FROM project_statuses ORDER BY name +` + +func (q *Queries) ListProjectStatuses(ctx context.Context) ([]string, error) { + rows, err := q.db.QueryContext(ctx, listProjectStatuses) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + items = append(items, name) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listTasksByItem = `-- name: ListTasksByItem :many -SELECT id, item_id, title, completed, position, created_at FROM project_item_tasks +SELECT id, item_id, title, completed, completed_at, position, created_at FROM project_item_tasks WHERE item_id = ? ORDER BY position, created_at ` @@ -1177,6 +1126,7 @@ func (q *Queries) ListTasksByItem(ctx context.Context, itemID string) ([]Project &i.ItemID, &i.Title, &i.Completed, + &i.CompletedAt, &i.Position, &i.CreatedAt, ); err != nil { @@ -1246,6 +1196,49 @@ func (q *Queries) RemoveItemFromProject(ctx context.Context, arg RemoveItemFromP return err } +const restoreItem = `-- name: RestoreItem :exec +INSERT INTO project_items (id, number, title, notes, repo, completed, completed_at, archived, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(id) DO UPDATE SET + number = excluded.number, + title = excluded.title, + notes = excluded.notes, + repo = excluded.repo, + completed = excluded.completed, + completed_at = excluded.completed_at, + archived = excluded.archived, + updated_at = excluded.updated_at +` + +type RestoreItemParams struct { + ID string + Number sql.NullInt64 + Title string + Notes sql.NullString + Repo sql.NullString + Completed int64 + CompletedAt sql.NullString + Archived int64 + CreatedAt string + UpdatedAt string +} + +func (q *Queries) RestoreItem(ctx context.Context, arg RestoreItemParams) error { + _, err := q.db.ExecContext(ctx, restoreItem, + arg.ID, + arg.Number, + arg.Title, + arg.Notes, + arg.Repo, + arg.Completed, + arg.CompletedAt, + arg.Archived, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + const restoreMembership = `-- name: RestoreMembership :exec INSERT OR IGNORE INTO project_item_memberships (item_id, project_id, position) VALUES (?, ?, ?) @@ -1265,11 +1258,85 @@ func (q *Queries) RestoreMembership(ctx context.Context, arg RestoreMembershipPa return err } +const restoreProject = `-- name: RestoreProject :exec + +INSERT INTO projects (id, name, description, kind, status, status_reason, closed_at, position, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + kind = excluded.kind, + status = excluded.status, + status_reason = excluded.status_reason, + closed_at = excluded.closed_at, + position = excluded.position +` + +type RestoreProjectParams struct { + ID string + Name string + Description sql.NullString + Kind string + Status string + StatusReason sql.NullString + ClosedAt sql.NullString + Position int64 + CreatedAt string +} + +// Undo: a restore carries each column a pull leaves alone. +func (q *Queries) RestoreProject(ctx context.Context, arg RestoreProjectParams) error { + _, err := q.db.ExecContext(ctx, restoreProject, + arg.ID, + arg.Name, + arg.Description, + arg.Kind, + arg.Status, + arg.StatusReason, + arg.ClosedAt, + arg.Position, + arg.CreatedAt, + ) + return err +} + +const restoreTask = `-- name: RestoreTask :exec +INSERT INTO project_item_tasks (id, item_id, title, completed, completed_at, position, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(id) DO UPDATE SET + title = excluded.title, + completed = excluded.completed, + completed_at = excluded.completed_at, + position = excluded.position +` + +type RestoreTaskParams struct { + ID string + ItemID string + Title string + Completed int64 + CompletedAt sql.NullString + Position int64 + CreatedAt string +} + +func (q *Queries) RestoreTask(ctx context.Context, arg RestoreTaskParams) error { + _, err := q.db.ExecContext(ctx, restoreTask, + arg.ID, + arg.ItemID, + arg.Title, + arg.Completed, + arg.CompletedAt, + arg.Position, + arg.CreatedAt, + ) + return err +} + const searchItems = `-- name: SearchItems :many -SELECT id, number, title, notes, repo, completed, archived, created_at, updated_at FROM project_items -WHERE (title LIKE '%' || ? || '%' OR notes LIKE '%' || ? || '%') - AND archived = 0 -ORDER BY created_at DESC +SELECT id, number, title, notes, repo, completed, completed_at, archived, created_at, updated_at FROM project_items +WHERE title LIKE '%' || ? || '%' OR notes LIKE '%' || ? || '%' +ORDER BY created_at DESC, id ASC ` type SearchItemsParams struct { @@ -1293,6 +1360,7 @@ func (q *Queries) SearchItems(ctx context.Context, arg SearchItemsParams) ([]Pro &i.Notes, &i.Repo, &i.Completed, + &i.CompletedAt, &i.Archived, &i.CreatedAt, &i.UpdatedAt, @@ -1324,43 +1392,6 @@ func (q *Queries) SetItemNumber(ctx context.Context, arg SetItemNumberParams) er return err } -const setProjectStatus = `-- name: SetProjectStatus :one -UPDATE projects -SET status = ?, - status_reason = ?, - closed_at = ? -WHERE id = ? -RETURNING id, name, description, status, status_reason, closed_at, position, created_at -` - -type SetProjectStatusParams struct { - Status string - StatusReason sql.NullString - ClosedAt sql.NullString - ID string -} - -func (q *Queries) SetProjectStatus(ctx context.Context, arg SetProjectStatusParams) (Project, error) { - row := q.db.QueryRowContext(ctx, setProjectStatus, - arg.Status, - arg.StatusReason, - arg.ClosedAt, - arg.ID, - ) - var i Project - err := row.Scan( - &i.ID, - &i.Name, - &i.Description, - &i.Status, - &i.StatusReason, - &i.ClosedAt, - &i.Position, - &i.CreatedAt, - ) - return i, err -} - const setSyncOrigin = `-- name: SetSyncOrigin :exec INSERT INTO sync_origin (id, api_url, adopted_at) VALUES (1, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) @@ -1382,19 +1413,21 @@ SET title = ?, notes = ?, repo = ?, completed = ?, + completed_at = ?, archived = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? -RETURNING id, number, title, notes, repo, completed, archived, created_at, updated_at +RETURNING id, number, title, notes, repo, completed, completed_at, archived, created_at, updated_at ` type UpdateItemParams struct { - Title string - Notes sql.NullString - Repo sql.NullString - Completed int64 - Archived int64 - ID string + Title string + Notes sql.NullString + Repo sql.NullString + Completed int64 + CompletedAt sql.NullString + Archived int64 + ID string } func (q *Queries) UpdateItem(ctx context.Context, arg UpdateItemParams) (ProjectItem, error) { @@ -1403,6 +1436,7 @@ func (q *Queries) UpdateItem(ctx context.Context, arg UpdateItemParams) (Project arg.Notes, arg.Repo, arg.Completed, + arg.CompletedAt, arg.Archived, arg.ID, ) @@ -1414,6 +1448,7 @@ func (q *Queries) UpdateItem(ctx context.Context, arg UpdateItemParams) (Project &i.Notes, &i.Repo, &i.Completed, + &i.CompletedAt, &i.Archived, &i.CreatedAt, &i.UpdatedAt, @@ -1454,22 +1489,34 @@ const updateProject = `-- name: UpdateProject :one UPDATE projects SET name = ?, description = ?, + kind = ?, + status = ?, + status_reason = ?, + closed_at = ?, position = ? WHERE id = ? -RETURNING id, name, description, status, status_reason, closed_at, position, created_at +RETURNING id, name, description, kind, status, status_reason, closed_at, position, created_at ` type UpdateProjectParams struct { - Name string - Description sql.NullString - Position int64 - ID string + Name string + Description sql.NullString + Kind string + Status string + StatusReason sql.NullString + ClosedAt sql.NullString + Position int64 + ID string } func (q *Queries) UpdateProject(ctx context.Context, arg UpdateProjectParams) (Project, error) { row := q.db.QueryRowContext(ctx, updateProject, arg.Name, arg.Description, + arg.Kind, + arg.Status, + arg.StatusReason, + arg.ClosedAt, arg.Position, arg.ID, ) @@ -1478,6 +1525,7 @@ func (q *Queries) UpdateProject(ctx context.Context, arg UpdateProjectParams) (P &i.ID, &i.Name, &i.Description, + &i.Kind, &i.Status, &i.StatusReason, &i.ClosedAt, @@ -1505,22 +1553,25 @@ const updateTask = `-- name: UpdateTask :one UPDATE project_item_tasks SET title = ?, completed = ?, + completed_at = ?, position = ? WHERE id = ? -RETURNING id, item_id, title, completed, position, created_at +RETURNING id, item_id, title, completed, completed_at, position, created_at ` type UpdateTaskParams struct { - Title string - Completed int64 - Position int64 - ID string + Title string + Completed int64 + CompletedAt sql.NullString + Position int64 + ID string } func (q *Queries) UpdateTask(ctx context.Context, arg UpdateTaskParams) (ProjectItemTask, error) { row := q.db.QueryRowContext(ctx, updateTask, arg.Title, arg.Completed, + arg.CompletedAt, arg.Position, arg.ID, ) @@ -1530,6 +1581,7 @@ func (q *Queries) UpdateTask(ctx context.Context, arg UpdateTaskParams) (Project &i.ItemID, &i.Title, &i.Completed, + &i.CompletedAt, &i.Position, &i.CreatedAt, ) diff --git a/store/sqlite/lookup_rows.sql b/store/sqlite/lookup_rows.sql new file mode 100644 index 0000000..9b73b72 --- /dev/null +++ b/store/sqlite/lookup_rows.sql @@ -0,0 +1,6 @@ +-- The rows the lookup tables start with. Run on every open, so a database created +-- before a kind or a status existed gains it. +INSERT OR IGNORE INTO project_kinds (name) VALUES ('build'), ('chore'), ('life'); + +INSERT OR IGNORE INTO project_statuses (name) +VALUES ('active'), ('someday'), ('completed'), ('dropped'); diff --git a/store/sqlite/open.go b/store/sqlite/open.go index 8f8f9f8..bf1963d 100644 --- a/store/sqlite/open.go +++ b/store/sqlite/open.go @@ -15,6 +15,9 @@ var schema string //go:embed indexes.sql var indexes string +//go:embed lookup_rows.sql +var lookupRows string + // Pragmas ride in the DSN rather than being executed after the open, because // PRAGMA is per-connection and database/sql hands out a pool. Running // `PRAGMA foreign_keys=ON` through db.Exec sets it on whichever single @@ -51,6 +54,11 @@ func Open(path string) (*sql.DB, error) { return nil, fmt.Errorf("creating indexes: %w", err) } + if _, err := db.Exec(lookupRows); err != nil { + _ = db.Close() + return nil, fmt.Errorf("seeding lookup tables: %w", err) + } + return db, nil } @@ -87,6 +95,45 @@ func migrate(db *sql.DB) error { if err := migrateProjectStatus(db); err != nil { return err } + return migrateKindAndCompletedAt(db) +} + +// migrateKindAndCompletedAt adds the lookup tables and the columns a database from +// before them lacks. A completed_at stays null, because the time an item was +// finished before the column existed is unknown. +func migrateKindAndCompletedAt(db *sql.DB) error { + const lookups = ` +CREATE TABLE IF NOT EXISTS project_kinds (name TEXT PRIMARY KEY); +CREATE TABLE IF NOT EXISTS project_statuses (name TEXT PRIMARY KEY);` + if _, err := db.Exec(lookups); err != nil { + return fmt.Errorf("creating lookup tables: %w", err) + } + columns := []struct{ table, column, definition string }{ + {"projects", "kind", "TEXT NOT NULL DEFAULT 'life'"}, + {"project_items", "completed_at", "TEXT"}, + {"project_item_tasks", "completed_at", "TEXT"}, + } + for _, c := range columns { + // Tasks arrived with the schema rather than through a migration, so a + // database old enough may not have the table at all. + var name string + err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", c.table).Scan(&name) + if err == sql.ErrNoRows { + continue + } else if err != nil { + return fmt.Errorf("checking %s: %w", c.table, err) + } + err = db.QueryRow("SELECT name FROM pragma_table_info(?) WHERE name = ?", c.table, c.column).Scan(&name) + if err == nil { + continue + } + if err != sql.ErrNoRows { + return fmt.Errorf("checking %s.%s: %w", c.table, c.column, err) + } + if _, err := db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", c.table, c.column, c.definition)); err != nil { + return fmt.Errorf("adding %s.%s: %w", c.table, c.column, err) + } + } return nil } diff --git a/store/sqlite/queries.sql b/store/sqlite/queries.sql index 4eb7322..092e942 100644 --- a/store/sqlite/queries.sql +++ b/store/sqlite/queries.sql @@ -1,63 +1,32 @@ --- Terminal projects are hidden unless asked for: closing a project is what takes --- it out of the list, so an unfiltered default would make completing one do --- nothing visible. Named parameter so the filter reads as a status rather than --- a bare `?`, and so sqlfluff sees a bind rather than an unqualified column. --- --- The completed split rides along with the total rather than being a second --- query: the pane draws both numbers on every row, so asking twice would mean a --- query per project. --- --- The order is active, then someday, then closed. A someday project has no --- closed_at, so ordering on closed_at alone would mix it in among the active --- ones. --- name: ListProjectsWithItemCount :many -SELECT - p.*, - CAST(COALESCE(SUM(CASE WHEN pi.completed = 1 THEN 1 ELSE 0 END), 0) AS INTEGER) - AS completed_count, - COUNT(pi.id) AS item_count -FROM projects p -LEFT JOIN project_item_memberships m ON p.id = m.project_id -LEFT JOIN project_items pi ON m.item_id = pi.id AND pi.archived = 0 -WHERE CAST(@status_filter AS TEXT) = 'all' OR p.status = CAST(@status_filter AS TEXT) -GROUP BY p.id -ORDER BY - CASE p.status WHEN 'active' THEN 0 WHEN 'someday' THEN 1 ELSE 2 END, - p.closed_at DESC, p.position ASC, p.name ASC; +-- name: ListProjectKinds :many +SELECT name FROM project_kinds ORDER BY name; + +-- name: ListProjectStatuses :many +SELECT name FROM project_statuses ORDER BY name; + +-- One row per membership, which the rule adding them up reads whole. +-- name: ListProjectItemFacts :many +SELECT m.project_id, pi.completed, pi.archived, pi.repo +FROM project_item_memberships m +INNER JOIN project_items pi ON m.item_id = pi.id; -- name: GetProject :one SELECT * FROM projects WHERE id = ?; --- name: GetProjectWithItemCount :one -SELECT - p.*, - CAST(COALESCE(SUM(CASE WHEN pi.completed = 1 THEN 1 ELSE 0 END), 0) AS INTEGER) - AS completed_count, - COUNT(pi.id) AS item_count -FROM projects p -LEFT JOIN project_item_memberships m ON p.id = m.project_id -LEFT JOIN project_items pi ON m.item_id = pi.id AND pi.archived = 0 -WHERE p.id = ? -GROUP BY p.id; - -- name: CreateProject :one -INSERT INTO projects (id, name, description, position) -VALUES (?, ?, ?, (SELECT COALESCE(MAX(position), 0) + 1 FROM projects)) +INSERT INTO projects (id, name, description, kind, status, status_reason, closed_at, position) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *; -- name: UpdateProject :one UPDATE projects SET name = ?, description = ?, - position = ? -WHERE id = ? -RETURNING *; - --- name: SetProjectStatus :one -UPDATE projects -SET status = ?, + kind = ?, + status = ?, status_reason = ?, - closed_at = ? + closed_at = ?, + position = ? WHERE id = ? RETURNING *; @@ -67,17 +36,20 @@ DELETE FROM projects WHERE id = ?; -- name: UpdateProjectPosition :exec UPDATE projects SET position = ? WHERE id = ?; --- name: ListItemsByProject :many +-- name: ListItemsInProject :many SELECT pi.*, m.position AS membership_position, (SELECT COUNT(*) FROM project_item_memberships m2 WHERE m2.item_id = pi.id) AS project_count FROM project_items pi INNER JOIN project_item_memberships m ON pi.id = m.item_id -WHERE m.project_id = ? AND pi.archived = 0 -ORDER BY m.position, pi.created_at; +WHERE m.project_id = ? +ORDER BY m.position, pi.created_at, pi.id; -- name: GetItem :one SELECT * FROM project_items WHERE id = ?; +-- name: GetItemByNumber :one +SELECT * FROM project_items WHERE number = ?; + -- name: CreateItem :one INSERT INTO project_items (id, number, title, notes, repo) VALUES (?, ?, ?, ?, ?) @@ -98,6 +70,7 @@ SET title = ?, notes = ?, repo = ?, completed = ?, + completed_at = ?, archived = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? @@ -108,7 +81,7 @@ DELETE FROM project_items WHERE id = ?; -- name: AddItemToProject :exec INSERT INTO project_item_memberships (item_id, project_id, position) -VALUES (?, ?, (SELECT COALESCE(MAX(m.position), 0) + 1 FROM project_item_memberships m WHERE m.project_id = ?)); +VALUES (?, ?, ?); -- name: RemoveItemFromProject :exec DELETE FROM project_item_memberships WHERE item_id = ? AND project_id = ?; @@ -118,7 +91,7 @@ SELECT p.* FROM projects p INNER JOIN project_item_memberships m ON p.id = m.project_id WHERE m.item_id = ? -ORDER BY p.name; +ORDER BY p.position, p.created_at, p.id; -- name: UpdateItemPosition :exec UPDATE project_item_memberships SET position = ? WHERE item_id = ? AND project_id = ?; @@ -134,10 +107,10 @@ VALUES (?, ?, ?); SELECT * FROM project_item_memberships WHERE item_id = ? AND project_id = ?; -- name: GetItemMemberships :many -SELECT * FROM project_item_memberships WHERE item_id = ?; +SELECT * FROM project_item_memberships WHERE item_id = ? ORDER BY project_id; -- name: GetProjectMemberships :many -SELECT * FROM project_item_memberships WHERE project_id = ?; +SELECT * FROM project_item_memberships WHERE project_id = ? ORDER BY position, item_id; -- name: AddDependency :exec INSERT INTO project_item_dependencies (item_id, depends_on_id) VALUES (?, ?); @@ -145,28 +118,27 @@ INSERT INTO project_item_dependencies (item_id, depends_on_id) VALUES (?, ?); -- name: RemoveDependency :exec DELETE FROM project_item_dependencies WHERE item_id = ? AND depends_on_id = ?; --- name: GetBlockers :many +-- name: ListDependencyItems :many SELECT pi.* FROM project_items pi INNER JOIN project_item_dependencies d ON pi.id = d.depends_on_id -WHERE d.item_id = ? AND pi.completed = 0; - --- name: GetDependencyIDs :many -SELECT depends_on_id FROM project_item_dependencies WHERE item_id = ?; +WHERE d.item_id = ? +ORDER BY pi.number, pi.id; -- name: GetAllDependencies :many -SELECT * FROM project_item_dependencies; +SELECT * FROM project_item_dependencies ORDER BY item_id, depends_on_id; -- Both directions: deleting an item drops the rows where it is the dependent -- and the rows where it is the blocker. -- name: GetDependenciesInvolvingItem :many -SELECT * FROM project_item_dependencies WHERE item_id = ? OR depends_on_id = ?; +SELECT * FROM project_item_dependencies +WHERE item_id = ? OR depends_on_id = ? +ORDER BY item_id, depends_on_id; -- name: SearchItems :many SELECT * FROM project_items -WHERE (title LIKE '%' || ? || '%' OR notes LIKE '%' || ? || '%') - AND archived = 0 -ORDER BY created_at DESC; +WHERE title LIKE '%' || ? || '%' OR notes LIKE '%' || ? || '%' +ORDER BY created_at DESC, id ASC; -- name: ListBlockedItems :many SELECT DISTINCT pi.* @@ -175,14 +147,8 @@ INNER JOIN project_item_dependencies d ON pi.id = d.item_id INNER JOIN project_items blocker ON d.depends_on_id = blocker.id WHERE blocker.completed = 0 AND pi.completed = 0 - AND pi.archived = 0; - --- name: ListArchivedItems :many -SELECT pi.*, m.position AS membership_position -FROM project_items pi -INNER JOIN project_item_memberships m ON pi.id = m.item_id -WHERE m.project_id = ? AND pi.archived = 1 -ORDER BY pi.updated_at DESC; + AND pi.archived = 0 +ORDER BY pi.created_at DESC, pi.id ASC; -- name: ListTasksByItem :many SELECT * FROM project_item_tasks @@ -194,13 +160,14 @@ SELECT * FROM project_item_tasks WHERE id = ?; -- name: CreateTask :one INSERT INTO project_item_tasks (id, item_id, title, position) -VALUES (?, ?, ?, (SELECT COALESCE(MAX(t.position), 0) + 1 FROM project_item_tasks t WHERE t.item_id = ?)) +VALUES (?, ?, ?, ?) RETURNING *; -- name: UpdateTask :one UPDATE project_item_tasks SET title = ?, completed = ?, + completed_at = ?, position = ? WHERE id = ? RETURNING *; @@ -278,6 +245,42 @@ ON CONFLICT(entity_type) DO UPDATE SET last_pull_at = CASE WHEN excluded.last_pull_at > sync_state.last_pull_at THEN excluded.last_pull_at ELSE sync_state.last_pull_at END, last_push_at = CASE WHEN excluded.last_push_at > sync_state.last_push_at THEN excluded.last_push_at ELSE sync_state.last_push_at END; +-- Undo: a restore carries each column a pull leaves alone. + +-- name: RestoreProject :exec +INSERT INTO projects (id, name, description, kind, status, status_reason, closed_at, position, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + kind = excluded.kind, + status = excluded.status, + status_reason = excluded.status_reason, + closed_at = excluded.closed_at, + position = excluded.position; + +-- name: RestoreItem :exec +INSERT INTO project_items (id, number, title, notes, repo, completed, completed_at, archived, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(id) DO UPDATE SET + number = excluded.number, + title = excluded.title, + notes = excluded.notes, + repo = excluded.repo, + completed = excluded.completed, + completed_at = excluded.completed_at, + archived = excluded.archived, + updated_at = excluded.updated_at; + +-- name: RestoreTask :exec +INSERT INTO project_item_tasks (id, item_id, title, completed, completed_at, position, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(id) DO UPDATE SET + title = excluded.title, + completed = excluded.completed, + completed_at = excluded.completed_at, + position = excluded.position; + -- Sync: pull reconciliation (upserts) -- name: UpsertProject :exec @@ -334,10 +337,10 @@ SELECT * FROM projects ORDER BY position, name; SELECT * FROM project_items ORDER BY created_at DESC; -- name: ListAllMemberships :many -SELECT * FROM project_item_memberships; +SELECT * FROM project_item_memberships ORDER BY project_id, item_id; -- name: ListAllTasks :many -SELECT * FROM project_item_tasks ORDER BY item_id, position; +SELECT * FROM project_item_tasks ORDER BY item_id, position, created_at; -- Undo diff --git a/store/sqlite/schema.sql b/store/sqlite/schema.sql index d1c4e3b..737789b 100644 --- a/store/sqlite/schema.sql +++ b/store/sqlite/schema.sql @@ -1,5 +1,15 @@ -- Schema definition for sqlc +-- Lookup tables, so adding a kind or a status is an insert. lookup_rows.sql holds +-- their rows. +CREATE TABLE project_kinds ( + name TEXT PRIMARY KEY +); + +CREATE TABLE project_statuses ( + name TEXT PRIMARY KEY +); + CREATE TABLE projects ( id TEXT PRIMARY KEY, -- Unique among ACTIVE projects only, via the partial index in indexes.sql. @@ -8,11 +18,13 @@ CREATE TABLE projects ( -- that shared one. name TEXT NOT NULL, description TEXT, - -- active | done | dropped. A project is a finite effort, so completing it - -- is what hides it — there is no separate archived flag the way items have - -- one. `dropped` requires a reason, which is what stops it reading as - -- deferred. No CHECK against a lookup table: the API validates the value - -- and this database mirrors what it is told. + -- A name in project_kinds, checked by the rule that writes it. + kind TEXT NOT NULL DEFAULT 'life', + -- active | someday | completed | dropped. A project is a finite effort, so + -- completing it is what hides it — there is no separate archived flag the way + -- items have one. `dropped` requires a reason, which is what stops it reading + -- as deferred. A name in project_statuses, checked by the rule that writes it + -- rather than by a CHECK, so a pull mirrors what a server tells it. status TEXT NOT NULL DEFAULT 'active', status_reason TEXT, closed_at TEXT, @@ -22,10 +34,10 @@ CREATE TABLE projects ( CREATE TABLE project_items ( id TEXT PRIMARY KEY, - -- The handle: short enough to type, unlike the UUID key. Assigned by the - -- ichrisbirch API when sync is on, and locally when it is off and this - -- database is the only authority. Null in the window between creating an - -- item offline and the push that earns it a number. + -- The handle: short enough to type, unlike the UUID key. Assigned by the API + -- when sync is on, and locally when it is off and this database is the only + -- authority. Null in the window between creating an item offline and the push + -- that earns it a number. number INTEGER, title TEXT NOT NULL, notes TEXT, @@ -33,6 +45,9 @@ CREATE TABLE project_items ( -- all — home projects, things to sell, errands. repo TEXT, completed INTEGER NOT NULL DEFAULT 0, + -- Null where the completion time is unknown: never finished, finished before + -- this column existed, or finished on a server a pull mirrors. + completed_at TEXT, archived INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) @@ -57,6 +72,7 @@ CREATE TABLE project_item_tasks ( item_id TEXT NOT NULL REFERENCES project_items(id) ON DELETE CASCADE, title TEXT NOT NULL, completed INTEGER NOT NULL DEFAULT 0, + completed_at TEXT, position INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); diff --git a/store/sqlite/store.go b/store/sqlite/store.go index 5680960..dc01986 100644 --- a/store/sqlite/store.go +++ b/store/sqlite/store.go @@ -58,37 +58,53 @@ func rowError(err error) error { return err } -// duplicateNameError names a unique violation for the writes store.Store promises -// ErrDuplicateName on. A project's only unique index beyond its id is the one on -// an active project's name, and the driver answers an id collision with a -// different code. Only active projects hold a name, so a status change fires this -// only on the way back to active. -func duplicateNameError(err error) error { +// duplicateError names a unique violation for the writes store.Store promises a +// sentinel on. A project's only unique index beyond its id is the one on an active +// project's name, and the driver answers an id collision with a different code. +// Only active projects hold a name, so a status change fires this only on the way +// back to active. +func duplicateError(err error) error { var driverErr *modernc.Error - if errors.As(err, &driverErr) && driverErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE { - return model.ErrDuplicateName + if errors.As(err, &driverErr) { + switch driverErr.Code() { + case sqlite3.SQLITE_CONSTRAINT_UNIQUE: + return model.ErrDuplicateName + case sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY: + return model.ErrDuplicateID + } } return rowError(err) } -func (o operations) ListProjectsWithItemCount(ctx context.Context, status string) ([]model.ProjectWithItemCount, error) { - rows, err := o.q.ListProjectsWithItemCount(ctx, status) +func (o operations) ListProjects(ctx context.Context) ([]model.Project, error) { + rows, err := o.q.ListAllProjectsRaw(ctx) if err != nil { return nil, err } - out := make([]model.ProjectWithItemCount, len(rows)) + return toModelProjects(rows), nil +} + +func (o operations) ListProjectItemFacts(ctx context.Context) ([]model.ProjectItemFact, error) { + rows, err := o.q.ListProjectItemFacts(ctx) + if err != nil { + return nil, err + } + out := make([]model.ProjectItemFact, len(rows)) for i, r := range rows { - out[i] = toModelProjectWithItemCount(r) + out[i] = model.ProjectItemFact{ProjectID: r.ProjectID, Completed: r.Completed != 0, Archived: r.Archived != 0} + if r.Repo.Valid { + out[i].Repo = &r.Repo.String + } } return out, nil } -func (o operations) GetProjectWithItemCount(ctx context.Context, id string) (model.ProjectWithItemCount, error) { - row, err := o.q.GetProjectWithItemCount(ctx, id) - if err != nil { - return model.ProjectWithItemCount{}, rowError(err) - } - return toModelProjectWithItemCountFromGet(row), nil +func (o operations) ListProjectKinds(ctx context.Context) ([]string, error) { + return o.q.ListProjectKinds(ctx) +} + +func (o operations) ListProjectStatuses(ctx context.Context) ([]string, error) { + return o.q.ListProjectStatuses(ctx) } func (o operations) GetProject(ctx context.Context, id string) (model.Project, error) { @@ -101,38 +117,34 @@ func (o operations) GetProject(ctx context.Context, id string) (model.Project, e func (o operations) CreateProject(ctx context.Context, project model.Project) (model.Project, error) { row, err := o.q.CreateProject(ctx, generated.CreateProjectParams{ - ID: project.ID, - Name: project.Name, - Description: toNullString(project.Description), + ID: project.ID, + Name: project.Name, + Description: toNullString(project.Description), + Kind: project.Kind, + Status: project.Status, + StatusReason: toNullString(project.StatusReason), + ClosedAt: toNullTime(project.ClosedAt), + Position: int64(project.Position), }) if err != nil { - return model.Project{}, duplicateNameError(err) + return model.Project{}, duplicateError(err) } return toModelProject(row), nil } func (o operations) UpdateProject(ctx context.Context, project model.Project) (model.Project, error) { row, err := o.q.UpdateProject(ctx, generated.UpdateProjectParams{ - Name: project.Name, - Description: toNullString(project.Description), - Position: int64(project.Position), - ID: project.ID, - }) - if err != nil { - return model.Project{}, duplicateNameError(err) - } - return toModelProject(row), nil -} - -func (o operations) SetProjectStatus(ctx context.Context, project model.Project) (model.Project, error) { - row, err := o.q.SetProjectStatus(ctx, generated.SetProjectStatusParams{ + Name: project.Name, + Description: toNullString(project.Description), + Kind: project.Kind, Status: project.Status, StatusReason: toNullString(project.StatusReason), ClosedAt: toNullTime(project.ClosedAt), + Position: int64(project.Position), ID: project.ID, }) if err != nil { - return model.Project{}, duplicateNameError(err) + return model.Project{}, duplicateError(err) } return toModelProject(row), nil } @@ -149,10 +161,11 @@ func (o operations) DeleteProject(ctx context.Context, id string) error { } func (o operations) RestoreProject(ctx context.Context, project model.Project) error { - return duplicateNameError(o.q.UpsertProject(ctx, generated.UpsertProjectParams{ + return duplicateError(o.q.RestoreProject(ctx, generated.RestoreProjectParams{ ID: project.ID, Name: project.Name, Description: toNullString(project.Description), + Kind: project.Kind, Status: project.Status, StatusReason: toNullString(project.StatusReason), ClosedAt: toNullTime(project.ClosedAt), @@ -169,8 +182,8 @@ func (o operations) ListAllItems(ctx context.Context) ([]model.ProjectItem, erro return toModelProjectItems(rows), nil } -func (o operations) ListItemsByProject(ctx context.Context, projectID string) ([]model.ProjectItemInProject, error) { - rows, err := o.q.ListItemsByProject(ctx, projectID) +func (o operations) ListItemsInProject(ctx context.Context, projectID string) ([]model.ProjectItemInProject, error) { + rows, err := o.q.ListItemsInProject(ctx, projectID) if err != nil { return nil, err } @@ -181,18 +194,6 @@ func (o operations) ListItemsByProject(ctx context.Context, projectID string) ([ return out, nil } -func (o operations) ListArchivedItems(ctx context.Context, projectID string) ([]model.ProjectItemInProject, error) { - rows, err := o.q.ListArchivedItems(ctx, projectID) - if err != nil { - return nil, err - } - out := make([]model.ProjectItemInProject, len(rows)) - for i, r := range rows { - out[i] = toModelProjectItemInProjectFromArchived(r) - } - return out, nil -} - func (o operations) ListBlockedItems(ctx context.Context) ([]model.ProjectItem, error) { rows, err := o.q.ListBlockedItems(ctx) if err != nil { @@ -218,6 +219,14 @@ func (o operations) GetItem(ctx context.Context, id string) (model.ProjectItem, return toModelProjectItem(row), nil } +func (o operations) GetItemByNumber(ctx context.Context, number int) (model.ProjectItem, error) { + row, err := o.q.GetItemByNumber(ctx, sql.NullInt64{Int64: int64(number), Valid: true}) + if err != nil { + return model.ProjectItem{}, rowError(err) + } + return toModelProjectItem(row), nil +} + func (o operations) NextItemNumber(ctx context.Context) (int, error) { next, err := o.q.NextItemNumber(ctx) if err != nil { @@ -235,19 +244,20 @@ func (o operations) CreateItem(ctx context.Context, item model.ProjectItem) (mod Repo: toNullString(item.Repo), }) if err != nil { - return model.ProjectItem{}, rowError(err) + return model.ProjectItem{}, duplicateError(err) } return toModelProjectItem(row), nil } func (o operations) UpdateItem(ctx context.Context, item model.ProjectItem) (model.ProjectItem, error) { row, err := o.q.UpdateItem(ctx, generated.UpdateItemParams{ - Title: item.Title, - Notes: toNullString(item.Notes), - Repo: toNullString(item.Repo), - Completed: boolToInt64(item.Completed), - Archived: boolToInt64(item.Archived), - ID: item.ID, + Title: item.Title, + Notes: toNullString(item.Notes), + Repo: toNullString(item.Repo), + Completed: boolToInt64(item.Completed), + CompletedAt: toNullTime(item.CompletedAt), + Archived: boolToInt64(item.Archived), + ID: item.ID, }) if err != nil { return model.ProjectItem{}, rowError(err) @@ -260,16 +270,17 @@ func (o operations) DeleteItem(ctx context.Context, id string) error { } func (o operations) RestoreItem(ctx context.Context, item model.ProjectItem) error { - return o.q.UpsertItem(ctx, generated.UpsertItemParams{ - ID: item.ID, - Number: toNullInt64(item.Number), - Title: item.Title, - Notes: toNullString(item.Notes), - Repo: toNullString(item.Repo), - Completed: boolToInt64(item.Completed), - Archived: boolToInt64(item.Archived), - CreatedAt: formatTime(item.CreatedAt), - UpdatedAt: formatTime(item.UpdatedAt), + return o.q.RestoreItem(ctx, generated.RestoreItemParams{ + ID: item.ID, + Number: toNullInt64(item.Number), + Title: item.Title, + Notes: toNullString(item.Notes), + Repo: toNullString(item.Repo), + Completed: boolToInt64(item.Completed), + CompletedAt: toNullTime(item.CompletedAt), + Archived: boolToInt64(item.Archived), + CreatedAt: formatTime(item.CreatedAt), + UpdatedAt: formatTime(item.UpdatedAt), }) } @@ -313,11 +324,11 @@ func (o operations) GetItemProjects(ctx context.Context, itemID string) ([]model return toModelProjects(rows), nil } -func (o operations) AddItemToProject(ctx context.Context, itemID, projectID string) error { +func (o operations) AddItemToProject(ctx context.Context, membership model.Membership) error { return o.q.AddItemToProject(ctx, generated.AddItemToProjectParams{ - ItemID: itemID, - ProjectID: projectID, - ProjectID_2: projectID, + ItemID: membership.ItemID, + ProjectID: membership.ProjectID, + Position: int64(membership.Position), }) } @@ -360,12 +371,8 @@ func (o operations) ListDependenciesInvolvingItem(ctx context.Context, itemID st return toModelDependencies(rows), nil } -func (o operations) GetDependencyIDs(ctx context.Context, itemID string) ([]string, error) { - return o.q.GetDependencyIDs(ctx, itemID) -} - -func (o operations) GetBlockers(ctx context.Context, itemID string) ([]model.ProjectItem, error) { - rows, err := o.q.GetBlockers(ctx, itemID) +func (o operations) ListDependencyItems(ctx context.Context, itemID string) ([]model.ProjectItem, error) { + rows, err := o.q.ListDependencyItems(ctx, itemID) if err != nil { return nil, err } @@ -384,6 +391,18 @@ func (o operations) RestoreDependency(ctx context.Context, edge model.Dependency return o.q.UpsertDependency(ctx, generated.UpsertDependencyParams{ItemID: edge.ItemID, DependsOnID: edge.DependsOnID}) } +func (o operations) ListAllTasks(ctx context.Context) ([]model.ProjectItemTask, error) { + rows, err := o.q.ListAllTasks(ctx) + if err != nil { + return nil, err + } + out := make([]model.ProjectItemTask, len(rows)) + for i, r := range rows { + out[i] = toModelProjectItemTask(r) + } + return out, nil +} + func (o operations) ListTasksByItem(ctx context.Context, itemID string) ([]model.ProjectItemTask, error) { rows, err := o.q.ListTasksByItem(ctx, itemID) if err != nil { @@ -409,20 +428,21 @@ func (o operations) CreateTask(ctx context.Context, task model.ProjectItemTask) ID: task.ID, ItemID: task.ItemID, Title: task.Title, - ItemID_2: task.ItemID, + Position: int64(task.Position), }) if err != nil { - return model.ProjectItemTask{}, rowError(err) + return model.ProjectItemTask{}, duplicateError(err) } return toModelProjectItemTask(row), nil } func (o operations) UpdateTask(ctx context.Context, task model.ProjectItemTask) (model.ProjectItemTask, error) { row, err := o.q.UpdateTask(ctx, generated.UpdateTaskParams{ - Title: task.Title, - Completed: boolToInt64(task.Completed), - Position: int64(task.Position), - ID: task.ID, + Title: task.Title, + Completed: boolToInt64(task.Completed), + CompletedAt: toNullTime(task.CompletedAt), + Position: int64(task.Position), + ID: task.ID, }) if err != nil { return model.ProjectItemTask{}, rowError(err) @@ -435,13 +455,14 @@ func (o operations) DeleteTask(ctx context.Context, id string) error { } func (o operations) RestoreTask(ctx context.Context, task model.ProjectItemTask) error { - return o.q.UpsertTask(ctx, generated.UpsertTaskParams{ - ID: task.ID, - ItemID: task.ItemID, - Title: task.Title, - Completed: boolToInt64(task.Completed), - Position: int64(task.Position), - CreatedAt: formatTime(task.CreatedAt), + return o.q.RestoreTask(ctx, generated.RestoreTaskParams{ + ID: task.ID, + ItemID: task.ItemID, + Title: task.Title, + Completed: boolToInt64(task.Completed), + CompletedAt: toNullTime(task.CompletedAt), + Position: int64(task.Position), + CreatedAt: formatTime(task.CreatedAt), }) } diff --git a/store/store.go b/store/store.go index a7bef1d..8fd4948 100644 --- a/store/store.go +++ b/store/store.go @@ -27,27 +27,49 @@ type Store interface { InTx(ctx context.Context, fn func(tx Operations) error) error } -// Operations is every read and write the service's rules make. +// Writer names who a transaction writes for. A store that keeps an audit trail +// records it beside each change, and one that keeps none ignores it. +type Writer struct { + Actor string + RequestID string + JobRunID string +} + +type writerKey struct{} + +// WithWriter returns a context whose transactions write as writer. +func WithWriter(ctx context.Context, writer Writer) context.Context { + return context.WithValue(ctx, writerKey{}, writer) +} + +// WriterFrom answers the writer a context carries, or the zero Writer. +func WriterFrom(ctx context.Context) Writer { + writer, _ := ctx.Value(writerKey{}).(Writer) + return writer +} + +// Operations is every read and write the service's rules make. An id here is the +// row's own id, because resolving what a person typed is a rule. // // A read of one row answers model.ErrNotFound when the row is absent, and so does // a write that returns the row it changed. A list answers a non-nil empty slice -// when nothing matches, apart from GetDependencyIDs. +// when nothing matches. type Operations interface { - // ListProjectsWithItemCount takes a status name or model.StatusAll. Counts - // leave out archived items. Active projects come first, then someday, then - // closed, each newest close first, then by position and name. - ListProjectsWithItemCount(ctx context.Context, status string) ([]model.ProjectWithItemCount, error) - GetProjectWithItemCount(ctx context.Context, id string) (model.ProjectWithItemCount, error) + // ListProjects orders by position and then name. + ListProjects(ctx context.Context) ([]model.Project, error) + // ListProjectItemFacts answers one row per membership, in no promised order. + ListProjectItemFacts(ctx context.Context) ([]model.ProjectItemFact, error) + // ListProjectKinds orders by name, and so does ListProjectStatuses. + ListProjectKinds(ctx context.Context) ([]string, error) + ListProjectStatuses(ctx context.Context) ([]string, error) GetProject(ctx context.Context, id string) (model.Project, error) - // CreateProject writes ID, Name and Description, and places the project after - // every other. A name an active project holds answers model.ErrDuplicateName. + // CreateProject writes every field apart from CreatedAt. A name an active + // project holds answers model.ErrDuplicateName, and an id already taken answers + // model.ErrDuplicateID. CreateProject(ctx context.Context, project model.Project) (model.Project, error) - // UpdateProject writes Name, Description and Position by ID. A name an active + // UpdateProject writes every field apart from CreatedAt, by ID. A name an active // project holds answers model.ErrDuplicateName. UpdateProject(ctx context.Context, project model.Project) (model.Project, error) - // SetProjectStatus writes Status, StatusReason and ClosedAt by ID, as given. - // Reopening into a name an active project holds answers model.ErrDuplicateName. - SetProjectStatus(ctx context.Context, project model.Project) (model.Project, error) UpdateProjectPosition(ctx context.Context, id string, position int) error // DeleteProject cascades to the project's memberships. DeleteProject(ctx context.Context, id string) error @@ -58,25 +80,26 @@ type Operations interface { // ListAllItems includes archived items, newest first. ListAllItems(ctx context.Context) ([]model.ProjectItem, error) - // ListItemsByProject leaves out archived items, ordered by membership position - // and then creation time. - ListItemsByProject(ctx context.Context, projectID string) ([]model.ProjectItemInProject, error) - // ListArchivedItems orders by most recently updated. - ListArchivedItems(ctx context.Context, projectID string) ([]model.ProjectItemInProject, error) - // ListBlockedItems is every open, unarchived item with an incomplete blocker. + // ListItemsInProject includes archived items, ordered by membership position + // and then creation time. ProjectCount is how many projects hold each. + ListItemsInProject(ctx context.Context, projectID string) ([]model.ProjectItemInProject, error) + // ListBlockedItems is every open, unarchived item with an incomplete blocker, + // newest first. ListBlockedItems(ctx context.Context) ([]model.ProjectItem, error) // SearchItems matches a substring of the title or notes, ignoring ASCII case, - // over unarchived items, newest first. + // over archived items too, newest first. SearchItems(ctx context.Context, query string) ([]model.ProjectItem, error) GetItem(ctx context.Context, id string) (model.ProjectItem, error) + GetItemByNumber(ctx context.Context, number int) (model.ProjectItem, error) // NextItemNumber allocates the number for an item about to be created in this // transaction. Only a service told it assigns numbers calls it. NextItemNumber(ctx context.Context) (int, error) // CreateItem writes ID, Number, Title, Notes and Repo. A nil Number stays - // unset for whatever assigns it later. + // unset for whatever assigns it later. An id already taken answers + // model.ErrDuplicateID. CreateItem(ctx context.Context, item model.ProjectItem) (model.ProjectItem, error) - // UpdateItem writes Title, Notes, Repo, Completed and Archived by ID, and - // stamps UpdatedAt. + // UpdateItem writes Title, Notes, Repo, Completed, CompletedAt and Archived by + // ID, and stamps UpdatedAt. UpdateItem(ctx context.Context, item model.ProjectItem) (model.ProjectItem, error) // DeleteItem cascades to the item's memberships, tasks, and dependencies in // both directions. @@ -84,39 +107,43 @@ type Operations interface { // RestoreItem inserts the whole row, or overwrites it apart from CreatedAt. RestoreItem(ctx context.Context, item model.ProjectItem) error + // ListAllMemberships orders by project and then item. ListAllMemberships(ctx context.Context) ([]model.Membership, error) + // ListItemMemberships orders by project. ListItemMemberships(ctx context.Context, itemID string) ([]model.Membership, error) + // ListProjectMemberships orders by position and then item. ListProjectMemberships(ctx context.Context, projectID string) ([]model.Membership, error) GetMembership(ctx context.Context, itemID, projectID string) (model.Membership, error) - // GetItemProjects orders by project name. + // GetItemProjects orders by project position, creation time and id. GetItemProjects(ctx context.Context, itemID string) ([]model.Project, error) - // AddItemToProject places the item after every other in that project. - AddItemToProject(ctx context.Context, itemID, projectID string) error + AddItemToProject(ctx context.Context, membership model.Membership) error RemoveItemFromProject(ctx context.Context, itemID, projectID string) error UpdateItemPosition(ctx context.Context, membership model.Membership) error // RestoreMembership inserts the row, and leaves an existing pairing alone. RestoreMembership(ctx context.Context, membership model.Membership) error + // ListAllDependencies orders by item and then what it depends on. ListAllDependencies(ctx context.Context) ([]model.Dependency, error) // ListDependenciesInvolvingItem covers both directions: the item as dependent // and the item as blocker. ListDependenciesInvolvingItem(ctx context.Context, itemID string) ([]model.Dependency, error) - // GetDependencyIDs may answer nil when the item has no blockers. - GetDependencyIDs(ctx context.Context, itemID string) ([]string, error) - // GetBlockers answers the item's incomplete blockers. - GetBlockers(ctx context.Context, itemID string) ([]model.ProjectItem, error) + // ListDependencyItems answers what the item depends on, finished or not, by + // number. + ListDependencyItems(ctx context.Context, itemID string) ([]model.ProjectItem, error) AddDependency(ctx context.Context, edge model.Dependency) error RemoveDependency(ctx context.Context, edge model.Dependency) error // RestoreDependency inserts the edge, and leaves an existing one alone. RestoreDependency(ctx context.Context, edge model.Dependency) error + // ListAllTasks orders by item, position and then creation time. + ListAllTasks(ctx context.Context) ([]model.ProjectItemTask, error) // ListTasksByItem orders by position and then creation time. ListTasksByItem(ctx context.Context, itemID string) ([]model.ProjectItemTask, error) GetTask(ctx context.Context, id string) (model.ProjectItemTask, error) - // CreateTask writes ID, ItemID and Title, and places the task after every - // other on that item. + // CreateTask writes ID, ItemID, Title and Position. An id already taken answers + // model.ErrDuplicateID. CreateTask(ctx context.Context, task model.ProjectItemTask) (model.ProjectItemTask, error) - // UpdateTask writes Title, Completed and Position by ID. + // UpdateTask writes Title, Completed, CompletedAt and Position by ID. UpdateTask(ctx context.Context, task model.ProjectItemTask) (model.ProjectItemTask, error) DeleteTask(ctx context.Context, id string) error // RestoreTask inserts the whole row, or overwrites it apart from ItemID and diff --git a/sync/engine_test.go b/sync/engine_test.go index 59f0248..62bd589 100644 --- a/sync/engine_test.go +++ b/sync/engine_test.go @@ -273,16 +273,16 @@ func TestPull_Reconciles(t *testing.T) { }) case "/api/v1/project-items": _ = json.NewEncoder(w).Encode([]model.ProjectItem{ - {ID: "item-1", Title: "Pulled Item", CreatedAt: time.Now(), UpdatedAt: time.Now()}, + {ID: "01900000-0000-7000-8000-00000000a001", Title: "Pulled Item", CreatedAt: time.Now(), UpdatedAt: time.Now()}, }) - case "/api/v1/project-items/item-1": + case "/api/v1/project-items/01900000-0000-7000-8000-00000000a001": _ = json.NewEncoder(w).Encode(model.ProjectItemDetail{ - ProjectItem: model.ProjectItem{ID: "item-1", Title: "Pulled Item", CreatedAt: time.Now(), UpdatedAt: time.Now()}, + ProjectItem: model.ProjectItem{ID: "01900000-0000-7000-8000-00000000a001", Title: "Pulled Item", CreatedAt: time.Now(), UpdatedAt: time.Now()}, Projects: []model.Project{{ID: "proj-1", Name: "Pulled"}}, }) - case "/api/v1/project-items/item-1/tasks": + case "/api/v1/project-items/01900000-0000-7000-8000-00000000a001/tasks": _ = json.NewEncoder(w).Encode([]model.ProjectItemTask{ - {ID: "task-1", ItemID: "item-1", Title: "Pulled Task", CreatedAt: time.Now()}, + {ID: "task-1", ItemID: "01900000-0000-7000-8000-00000000a001", Title: "Pulled Task", CreatedAt: time.Now()}, }) default: w.WriteHeader(http.StatusOK) @@ -317,7 +317,7 @@ func TestPull_Reconciles(t *testing.T) { t.Errorf("expected 1 project 'Pulled', got %v", projects) } - item, err := local.GetItem("item-1") + item, err := local.GetItem("01900000-0000-7000-8000-00000000a001") if err != nil { t.Fatalf("GetItem: %v", err) } @@ -325,7 +325,7 @@ func TestPull_Reconciles(t *testing.T) { t.Errorf("got title %q, want 'Pulled Item'", item.Title) } - tasks, err := local.ListTasks("item-1") + tasks, err := local.ListTasks("01900000-0000-7000-8000-00000000a001") if err != nil { t.Fatalf("ListTasks: %v", err) } @@ -510,9 +510,9 @@ func TestPull_KeepsWorkDoneWhileItWasRunning(t *testing.T) { }) case "/api/v1/project-items": _ = json.NewEncoder(w).Encode([]model.ProjectItem{ - {ID: "item-1", Title: "Server Item", CreatedAt: time.Now(), UpdatedAt: time.Now()}, + {ID: "01900000-0000-7000-8000-00000000a001", Title: "Server Item", CreatedAt: time.Now(), UpdatedAt: time.Now()}, }) - case "/api/v1/project-items/item-1": + case "/api/v1/project-items/01900000-0000-7000-8000-00000000a001": // Mid-pull: the user adds an item the server response cannot know about. once.Do(func() { created, err := sb.CreateItem(model.CreateProjectItem{ @@ -526,7 +526,7 @@ func TestPull_KeepsWorkDoneWhileItWasRunning(t *testing.T) { raced <- created.ID }) _ = json.NewEncoder(w).Encode(model.ProjectItemDetail{ - ProjectItem: model.ProjectItem{ID: "item-1", Title: "Server Item", CreatedAt: time.Now(), UpdatedAt: time.Now()}, + ProjectItem: model.ProjectItem{ID: "01900000-0000-7000-8000-00000000a001", Title: "Server Item", CreatedAt: time.Now(), UpdatedAt: time.Now()}, Projects: []model.Project{{ID: project.ID, Name: "Server"}}, }) default: @@ -627,7 +627,7 @@ func pullFixture(embedded bool) http.HandlerFunc { } } task := map[string]any{ - "id": "task-1", "item_id": "item-2", "title": "Embedded Task", + "id": "task-1", "item_id": "01900000-0000-7000-8000-00000000a002", "title": "Embedded Task", "completed": false, "position": 0, "created_at": now.Format(time.RFC3339Nano), } @@ -637,22 +637,22 @@ func pullFixture(embedded bool) http.HandlerFunc { case "/api/v1/projects": _ = json.NewEncoder(w).Encode([]model.Project{{ID: "proj-1", Name: "Remote", Position: 0, CreatedAt: now}}) case "/api/v1/project-items": - blocker := base("item-1", "Blocker") - blocked := base("item-2", "Blocked") + blocker := base("01900000-0000-7000-8000-00000000a001", "Blocker") + blocked := base("01900000-0000-7000-8000-00000000a002", "Blocked") if embedded { blocker["dependency_ids"] = []string{} blocker["tasks"] = []map[string]any{} - blocked["dependency_ids"] = []string{"item-1"} + blocked["dependency_ids"] = []string{"01900000-0000-7000-8000-00000000a001"} blocked["tasks"] = []map[string]any{task} } _ = json.NewEncoder(w).Encode([]map[string]any{blocker, blocked}) - case "/api/v1/project-items/item-1": - _ = json.NewEncoder(w).Encode(base("item-1", "Blocker")) - case "/api/v1/project-items/item-2": - detail := base("item-2", "Blocked") - detail["dependency_ids"] = []string{"item-1"} + case "/api/v1/project-items/01900000-0000-7000-8000-00000000a001": + _ = json.NewEncoder(w).Encode(base("01900000-0000-7000-8000-00000000a001", "Blocker")) + case "/api/v1/project-items/01900000-0000-7000-8000-00000000a002": + detail := base("01900000-0000-7000-8000-00000000a002", "Blocked") + detail["dependency_ids"] = []string{"01900000-0000-7000-8000-00000000a001"} _ = json.NewEncoder(w).Encode(detail) - case "/api/v1/project-items/item-2/tasks": + case "/api/v1/project-items/01900000-0000-7000-8000-00000000a002/tasks": _ = json.NewEncoder(w).Encode([]map[string]any{task}) default: _ = json.NewEncoder(w).Encode([]map[string]any{}) @@ -689,23 +689,23 @@ func TestPull_UsesEmbeddedDetailAndStopsAtTwoRequests(t *testing.T) { t.Errorf("expected 2 requests, got %d: %v", len(got), got) } - tasks, err := local.ListTasks("item-2") + tasks, err := local.ListTasks("01900000-0000-7000-8000-00000000a002") if err != nil { t.Fatalf("ListTasks: %v", err) } if len(tasks) != 1 || tasks[0].Title != "Embedded Task" { t.Errorf("embedded task did not reconcile: %v", tasks) } - blockers, err := local.GetBlockers("item-2") + blockers, err := local.GetBlockers("01900000-0000-7000-8000-00000000a002") if err != nil { t.Fatalf("GetBlockers: %v", err) } - if len(blockers) != 1 || blockers[0].ID != "item-1" { + if len(blockers) != 1 || blockers[0].ID != "01900000-0000-7000-8000-00000000a001" { t.Errorf("embedded dependency did not reconcile: %v", blockers) } } -// Deploy order between this tool and ichrisbirch must not matter. Against a server +// Deploy order between this tool and its server must not matter. Against a server // that omits the fields, treating absent as empty would delete every task and // dependency locally, so the pull has to fall back to the per-item endpoints. func TestPull_FallsBackWhenTheServerOmitsEmbeddedFields(t *testing.T) { @@ -715,18 +715,18 @@ func TestPull_FallsBackWhenTheServerOmitsEmbeddedFields(t *testing.T) { t.Errorf("expected per-item fallback requests, got only %d", got) } - tasks, err := local.ListTasks("item-2") + tasks, err := local.ListTasks("01900000-0000-7000-8000-00000000a002") if err != nil { t.Fatalf("ListTasks: %v", err) } if len(tasks) != 1 || tasks[0].Title != "Embedded Task" { t.Errorf("fallback lost the item's tasks: %v", tasks) } - blockers, err := local.GetBlockers("item-2") + blockers, err := local.GetBlockers("01900000-0000-7000-8000-00000000a002") if err != nil { t.Fatalf("GetBlockers: %v", err) } - if len(blockers) != 1 || blockers[0].ID != "item-1" { + if len(blockers) != 1 || blockers[0].ID != "01900000-0000-7000-8000-00000000a001" { t.Errorf("fallback lost the item's dependencies: %v", blockers) } } @@ -873,14 +873,14 @@ func TestPull_DropsLinksToEntitiesTheServerDidNotSend(t *testing.T) { }) case "/api/v1/project-items": _ = json.NewEncoder(w).Encode([]model.ProjectItemDetail{{ - ProjectItem: model.ProjectItem{ID: "item-1", Title: "Kept", CreatedAt: time.Now(), UpdatedAt: time.Now()}, + ProjectItem: model.ProjectItem{ID: "01900000-0000-7000-8000-00000000a001", Title: "Kept", CreatedAt: time.Now(), UpdatedAt: time.Now()}, Projects: []model.Project{ {ID: "proj-1", Name: "Real"}, {ID: "proj-missing", Name: "Never sent by /projects/"}, }, DependencyIDs: []string{"item-missing"}, Tasks: []model.ProjectItemTask{ - {ID: "task-1", ItemID: "item-1", Title: "Kept task", CreatedAt: time.Now()}, + {ID: "task-1", ItemID: "01900000-0000-7000-8000-00000000a001", Title: "Kept task", CreatedAt: time.Now()}, }, }}) default: @@ -904,7 +904,7 @@ func TestPull_DropsLinksToEntitiesTheServerDidNotSend(t *testing.T) { } local := service.New(sqlite.New(database)) - projects, err := local.GetItemProjects("item-1") + projects, err := local.GetItemProjects("01900000-0000-7000-8000-00000000a001") if err != nil { t.Fatalf("GetItemProjects: %v", err) } @@ -920,7 +920,7 @@ func TestPull_DropsLinksToEntitiesTheServerDidNotSend(t *testing.T) { t.Errorf("the pull left %d dangling rows behind", violations) } - tasks, err := local.ListTasks("item-1") + tasks, err := local.ListTasks("01900000-0000-7000-8000-00000000a001") if err != nil { t.Fatalf("ListTasks: %v", err) } @@ -935,7 +935,7 @@ func TestPull_DropsLinksToEntitiesTheServerDidNotSend(t *testing.T) { // pass order-independent — otherwise any renumbering upstream fails the entire // pull and the tool never syncs again. func TestPull_SurvivesItemsSwappingNumbers(t *testing.T) { - numbers := map[string]int{"item-a": 1, "item-b": 2} + numbers := map[string]int{"01900000-0000-7000-8000-00000000a00a": 1, "01900000-0000-7000-8000-00000000a00b": 2} handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.URL.Path != "/api/v1/project-items" { @@ -944,7 +944,7 @@ func TestPull_SurvivesItemsSwappingNumbers(t *testing.T) { return } out := make([]model.ProjectItemDetail, 0, 2) - for _, id := range []string{"item-a", "item-b"} { + for _, id := range []string{"01900000-0000-7000-8000-00000000a00a", "01900000-0000-7000-8000-00000000a00b"} { n := numbers[id] out = append(out, model.ProjectItemDetail{ ProjectItem: model.ProjectItem{ @@ -972,18 +972,18 @@ func TestPull_SurvivesItemsSwappingNumbers(t *testing.T) { t.Fatalf("first pull: %v", err) } - numbers["item-a"], numbers["item-b"] = 2, 1 + numbers["01900000-0000-7000-8000-00000000a00a"], numbers["01900000-0000-7000-8000-00000000a00b"] = 2, 1 if err := engine.Pull(t.Context()); err != nil { t.Fatalf("a swap upstream failed the pull: %v", err) } local := service.New(sqlite.New(database)) - item, err := local.GetItem("item-a") + item, err := local.GetItem("01900000-0000-7000-8000-00000000a00a") if err != nil { t.Fatalf("GetItem: %v", err) } if item.Number == nil || *item.Number != 2 { - t.Errorf("item-a number = %v, want 2", item.Number) + t.Errorf("01900000-0000-7000-8000-00000000a00a number = %v, want 2", item.Number) } } diff --git a/tui/app.go b/tui/app.go index 71d98fa..d517d22 100644 --- a/tui/app.go +++ b/tui/app.go @@ -2725,13 +2725,13 @@ func (m *App) projectPaneWidth() int { // Account for "All" entry totalItems, totalDone := 0, 0 for _, p := range m.projects { - totalItems += p.ItemCount + totalItems += p.UnarchivedCount() totalDone += p.CompletedCount } maxName := lipgloss.Width("> All ") + projectCountsWidth(totalItems-totalDone, totalDone) for _, p := range m.projects { // "> name ○open ✓done" — 2 prefix + name + space + counts - w := lipgloss.Width("> "+m.projectRowLabel(p)+" ") + projectCountsWidth(p.OpenCount(), p.CompletedCount) + w := lipgloss.Width("> "+m.projectRowLabel(p)+" ") + projectCountsWidth(p.OpenCount, p.CompletedCount) if w > maxName { maxName = w } @@ -2800,7 +2800,7 @@ func (m *App) renderProjectPane(width, height int) string { // "All" entry totalItems, totalDone := 0, 0 for _, p := range m.projects { - totalItems += p.ItemCount + totalItems += p.UnarchivedCount() totalDone += p.CompletedCount } isCursor := m.showingAll @@ -2845,7 +2845,7 @@ func (m *App) renderProjectPane(width, height int) string { case isSelected: style = projectMultiSelectedStyle } - lines = append(lines, row(prefix, name, p.OpenCount(), p.CompletedCount, style)) + lines = append(lines, row(prefix, name, p.OpenCount, p.CompletedCount, style)) } } diff --git a/tui/app_test.go b/tui/app_test.go index 9fd2fee..e6d6c29 100644 --- a/tui/app_test.go +++ b/tui/app_test.go @@ -322,11 +322,11 @@ func TestSyncTickReconcilesWithTheServer(t *testing.T) { }) case "/api/v1/project-items": _ = json.NewEncoder(w).Encode([]model.ProjectItem{ - {ID: "item-1", Title: "Added Elsewhere", CreatedAt: time.Now(), UpdatedAt: time.Now()}, + {ID: "01900000-0000-7000-8000-00000000a001", Title: "Added Elsewhere", CreatedAt: time.Now(), UpdatedAt: time.Now()}, }) - case "/api/v1/project-items/item-1": + case "/api/v1/project-items/01900000-0000-7000-8000-00000000a001": _ = json.NewEncoder(w).Encode(model.ProjectItemDetail{ - ProjectItem: model.ProjectItem{ID: "item-1", Title: "Added Elsewhere", CreatedAt: time.Now(), UpdatedAt: time.Now()}, + ProjectItem: model.ProjectItem{ID: "01900000-0000-7000-8000-00000000a001", Title: "Added Elsewhere", CreatedAt: time.Now(), UpdatedAt: time.Now()}, Projects: []model.Project{{ID: "proj-1", Name: "Remote"}}, }) default: @@ -367,7 +367,7 @@ func TestSyncTickReconcilesWithTheServer(t *testing.T) { // An item that only ever existed on the server has to land in the TUI // without the user asking for it. - if _, err := app.backend.GetItem("item-1"); err != nil { + if _, err := app.backend.GetItem("01900000-0000-7000-8000-00000000a001"); err != nil { t.Fatalf("background pull did not bring the remote item local: %v", err) } found := false @@ -680,8 +680,8 @@ func TestProjectPaneShowsOpenAndDoneCounts(t *testing.T) { if projects[0].ItemCount != 3 || projects[0].CompletedCount != 1 { t.Fatalf("counts = %d items / %d done, want 3/1", projects[0].ItemCount, projects[0].CompletedCount) } - if got := projects[0].OpenCount(); got != 2 { - t.Errorf("OpenCount() = %d, want 2", got) + if got := projects[0].OpenCount; got != 2 { + t.Errorf("OpenCount = %d, want 2", got) } send(t, app, fetchProjectsCmd(app.backend, app.projectStatusFilter())()) diff --git a/tui/reader.go b/tui/reader.go index 9f389e7..3851dd0 100644 --- a/tui/reader.go +++ b/tui/reader.go @@ -310,7 +310,7 @@ func (m *App) projectReaderRows(width int) []string { add(readerTitleStyle, p.Name) add(dimStyle, fmt.Sprintf("Project %s · %s", shortID(p.ID), p.Status)) - rows = append(rows, projectCounts(p.OpenCount(), p.CompletedCount)+dimStyle.Render(" open · done")) + rows = append(rows, projectCounts(p.OpenCount, p.CompletedCount)+dimStyle.Render(" open · done")) add(dimStyle, "Created: "+p.CreatedAt.Format("Jan 2, 2006")) if p.Status != model.StatusActive { // A shelved project has not closed, so it has no closing line. From e7bb8ff25f8e09f5d7860c5c0716dd81324a8f3d Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sat, 7 Nov 2026 14:24:07 -0500 Subject: [PATCH 02/12] feat: serve projects over HTTP on Postgres projects-api answers the routes existing clients already call under /api/v1, in the answer shapes they already read, so moving a client is a change of address. api/wire holds those shapes apart from model, because a client depends on the contract and never on the server's types. api/schemas holds a JSON Schema per shape, and a test checks a real answer against each. A handler decodes, calls service and encodes. A partial update is read field by field, because only that tells a field left out from one sent as null. POST and GET /api/v1/undo are new, so every operation the TUI performs has a route. A person's client reaches every route and a service client reaches only what projects.items.read lists, by registered pattern. /health answers for the process and /ready for Postgres and the identity provider. TERM drains requests in flight and exits zero, and the logs are JSON on stdout. Agent-Name: claude-code-unattended Agent-Session: b6774e69-f156-4d1a-9713-9886fbbc39f7 Agent-Run: 01a1b74b-03f4-747a-9881-2a1e47809809 Worklog: 2401.1 --- api/authentication/authentication.go | 58 +++ api/main.go | 134 +++++++ api/requestlog/requestlog.go | 125 ++++++ api/routes/answers.go | 128 +++++++ api/routes/project_item_tasks.go | 96 +++++ api/routes/project_items.go | 212 +++++++++++ api/routes/projects.go | 110 ++++++ api/routes/request_input.go | 355 ++++++++++++++++++ api/routes/routes.go | 148 ++++++++ api/routes/routes_test.go | 293 +++++++++++++++ api/schemas/error.schema.json | 56 +++ api/schemas/probe.schema.json | 24 ++ api/schemas/project-item-detail.schema.json | 226 +++++++++++ .../project-item-in-project.schema.json | 87 +++++ api/schemas/project-item-task.schema.json | 47 +++ api/schemas/project-item.schema.json | 227 +++++++++++ .../project-with-item-count.schema.json | 82 ++++ api/schemas/project.schema.json | 62 +++ api/schemas/undo-result.schema.json | 28 ++ api/schemas/undo-state.schema.json | 16 + api/settings/settings.go | 93 +++++ api/wire/wire.go | 310 +++++++++++++++ go.mod | 3 + go.sum | 6 + model/errors.go | 4 +- service/service.go | 1 + store/postgres/store_test.go | 72 ++++ 27 files changed, 3002 insertions(+), 1 deletion(-) create mode 100644 api/authentication/authentication.go create mode 100644 api/main.go create mode 100644 api/requestlog/requestlog.go create mode 100644 api/routes/answers.go create mode 100644 api/routes/project_item_tasks.go create mode 100644 api/routes/project_items.go create mode 100644 api/routes/projects.go create mode 100644 api/routes/request_input.go create mode 100644 api/routes/routes.go create mode 100644 api/routes/routes_test.go create mode 100644 api/schemas/error.schema.json create mode 100644 api/schemas/probe.schema.json create mode 100644 api/schemas/project-item-detail.schema.json create mode 100644 api/schemas/project-item-in-project.schema.json create mode 100644 api/schemas/project-item-task.schema.json create mode 100644 api/schemas/project-item.schema.json create mode 100644 api/schemas/project-with-item-count.schema.json create mode 100644 api/schemas/project.schema.json create mode 100644 api/schemas/undo-result.schema.json create mode 100644 api/schemas/undo-state.schema.json create mode 100644 api/settings/settings.go create mode 100644 api/wire/wire.go create mode 100644 store/postgres/store_test.go diff --git a/api/authentication/authentication.go b/api/authentication/authentication.go new file mode 100644 index 0000000..d1aee5f --- /dev/null +++ b/api/authentication/authentication.go @@ -0,0 +1,58 @@ +// Package authentication assembles who may reach the API, in one place a browser +// login can replace. A person's client acts as that person, and a service client +// reaches only what its scopes list. +package authentication + +import ( + "log/slog" + "net/http" + + "github.com/datapointchris/goapiauth" + "github.com/datapointchris/goapiauth/clientid" + + "github.com/datapointchris/projects/api/requestlog" + "github.com/datapointchris/projects/store" +) + +// applicationName prefixes the client ids this API accepts. +const applicationName = "projects" + +// Config is the verifier's configuration for an issuer. +func Config(issuer string) goapiauth.Config { + return goapiauth.Config{ + Issuer: issuer, + PersonPrefix: clientid.PersonPrefix(applicationName), + ServicePrefix: clientid.ServicePrefix(applicationName), + } +} + +// Metadata is the RFC 9728 document naming the issuer, which a client reads before +// it holds a token to learn where to log in. +func Metadata(mux *http.ServeMux, publicURL, issuer string) { + mux.Handle("GET "+goapiauth.ProtectedResourcePath, goapiauth.ProtectedResourceMetadata{Resource: publicURL, Issuer: issuer}) +} + +// Protect puts a bearer token in front of routes, and limits a service client to +// what scopes lists for it. +func Protect(routes *http.ServeMux, scopes map[string][]string, verifier goapiauth.TokenVerifier, log *slog.Logger, public ...string) http.Handler { + limited := goapiauth.LimitServices(routes, scopes, log) + return goapiauth.RequireBearer(verifier, log, public...)(bindWriter(limited)) +} + +// bindWriter names the caller on the request's context, so the audit trail records +// who each write was for. A person is recorded by subject and a service by client +// id, because a service token's subject is only the client's own id or nothing. +func bindWriter(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + writer := store.Writer{RequestID: requestlog.RequestID(ctx)} + if identity, ok := goapiauth.IdentityFrom(ctx); ok { + writer.Actor = identity.Subject + if identity.Service { + writer.Actor = identity.ClientID + } + requestlog.NoteCaller(ctx, writer.Actor, identity.ClientID, identity.Service) + } + next.ServeHTTP(w, r.WithContext(store.WithWriter(ctx, writer))) + }) +} diff --git a/api/main.go b/api/main.go new file mode 100644 index 0000000..eeecd58 --- /dev/null +++ b/api/main.go @@ -0,0 +1,134 @@ +// Command projects-api serves projects over HTTP on Postgres. With the argument +// migrate it brings the database's schema up to date and exits instead. +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/datapointchris/goapiauth" + + "github.com/datapointchris/projects/api/authentication" + "github.com/datapointchris/projects/api/requestlog" + "github.com/datapointchris/projects/api/routes" + "github.com/datapointchris/projects/api/settings" + "github.com/datapointchris/projects/service" + "github.com/datapointchris/projects/store/postgres" +) + +// version is set at build time. +var version = "dev" + +// drainTimeout is how long a request in flight gets after TERM. The Deployment's +// grace period has to cover it and the preStop sleep. +const drainTimeout = 20 * time.Second + +func main() { + log := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + err := run(ctx, os.Args[1:], log) + stop() + if err != nil { + log.Error("projects-api stopped", "err", err, "version", version) + os.Exit(1) + } +} + +func run(ctx context.Context, args []string, log *slog.Logger) error { + switch { + case len(args) == 0: + return serve(ctx, log) + case len(args) == 1 && args[0] == "migrate": + return migrate(ctx, log) + default: + return fmt.Errorf("unknown arguments %q: run with none to serve, or with migrate", args) + } +} + +func migrate(ctx context.Context, log *slog.Logger) error { + database, err := settings.LoadPostgres() + if err != nil { + return err + } + if err := postgres.Migrate(ctx, database.URL()); err != nil { + return err + } + log.InfoContext(ctx, "migrations applied", "version", version) + return nil +} + +func serve(ctx context.Context, log *slog.Logger) error { + loaded, err := settings.LoadServer() + if err != nil { + return err + } + pool, err := postgres.Connect(ctx, loaded.URL()) + if err != nil { + return err + } + defer pool.Close() + postgresStore := postgres.New(pool) + + provider, err := goapiauth.NewConnecting(authentication.Config(loaded.OIDCIssuer)) + if err != nil { + return fmt.Errorf("reading the identity provider's settings: %w", err) + } + go provider.Run(ctx, log) + + svc := service.New(postgresStore, service.AssigningItemNumbers()) + mux := routes.New(svc, log, + routes.ReadinessCheck{Name: "postgres", Check: postgresStore.Ping}, + routes.ReadinessCheck{Name: "identity provider", Check: func(context.Context) error { return provider.Ready() }}, + ) + if loaded.PublicURL != "" { + authentication.Metadata(mux, loaded.PublicURL, loaded.OIDCIssuer) + } else { + log.WarnContext(ctx, "PUBLIC_URL is unset, so no RFC 9728 metadata is served and a client cannot discover the issuer") + } + protected := authentication.Protect(mux, routes.ServiceScopes, provider, log, routes.PublicPaths...) + + server := &http.Server{ + Addr: net.JoinHostPort("", loaded.ListenPort), + Handler: requestlog.Middleware(log, protected), + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + return listenUntilDone(ctx, server, log) +} + +// listenUntilDone serves until the signal, then lets requests in flight finish. A +// drained server exits zero, so a rollout reads as healthy. +func listenUntilDone(ctx context.Context, server *http.Server, log *slog.Logger) error { + listening := make(chan error, 1) + go func() { + log.InfoContext(ctx, "listening", "addr", server.Addr, "version", version) + listening <- server.ListenAndServe() + }() + + select { + case err := <-listening: + return err + case <-ctx.Done(): + } + + log.Info("draining", "timeout", drainTimeout.String()) + drainContext, cancel := context.WithTimeout(context.Background(), drainTimeout) + defer cancel() + if err := server.Shutdown(drainContext); err != nil { + return fmt.Errorf("draining: %w", err) + } + if err := <-listening; !errors.Is(err, http.ErrServerClosed) { + return err + } + log.Info("stopped") + return nil +} diff --git a/api/requestlog/requestlog.go b/api/requestlog/requestlog.go new file mode 100644 index 0000000..6c63fde --- /dev/null +++ b/api/requestlog/requestlog.go @@ -0,0 +1,125 @@ +// Package requestlog writes one JSON line per request and gives each request its +// id. It wraps everything else, so a refusal from authentication is logged too. +package requestlog + +import ( + "context" + "log/slog" + "net/http" + "time" + + "github.com/google/uuid" +) + +// HeaderRequestID carries a request's id in and back out, so a client's own id +// traces through this API's logs and its audit trail. +const HeaderRequestID = "X-Request-ID" + +// record is what inner layers learn about a request that the final line reports. +type record struct { + id string + actor string + clientID string + service bool + refusal string +} + +type recordKey struct{} + +func recordFrom(ctx context.Context) *record { + found, _ := ctx.Value(recordKey{}).(*record) + return found +} + +// RequestID answers the id of the request ctx belongs to. +func RequestID(ctx context.Context) string { + if found := recordFrom(ctx); found != nil { + return found.id + } + return "" +} + +// NoteCaller adds who is calling to the request's line. +func NoteCaller(ctx context.Context, actor, clientID string, service bool) { + if found := recordFrom(ctx); found != nil { + found.actor, found.clientID, found.service = actor, clientID, service + } +} + +// NoteRefusal adds why a request was refused to its line. +func NoteRefusal(ctx context.Context, message string) { + if found := recordFrom(ctx); found != nil { + found.refusal = message + } +} + +// statusRecorder remembers what a handler answered, because http.ResponseWriter +// cannot be asked. +type statusRecorder struct { + http.ResponseWriter + status int + bytes int +} + +func (s *statusRecorder) WriteHeader(status int) { + if s.status == 0 { + s.status = status + } + s.ResponseWriter.WriteHeader(status) +} + +func (s *statusRecorder) Write(body []byte) (int, error) { + if s.status == 0 { + s.status = http.StatusOK + } + written, err := s.ResponseWriter.Write(body) + s.bytes += written + return written, err +} + +// Middleware logs every request but the probes, which a cluster sends every few +// seconds and which say nothing about use. +func Middleware(log *slog.Logger, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.Header.Get(HeaderRequestID) + if id == "" { + id = uuid.NewString() + } + w.Header().Set(HeaderRequestID, id) + + current := &record{id: id} + recorder := &statusRecorder{ResponseWriter: w} + started := time.Now() + next.ServeHTTP(recorder, r.WithContext(context.WithValue(r.Context(), recordKey{}, current))) + + if r.URL.Path == "/health" || r.URL.Path == "/ready" { + return + } + log.LogAttrs(r.Context(), levelOf(recorder.status), "request", + slog.String("method", r.Method), + slog.String("path", r.URL.EscapedPath()), + slog.String("query", r.URL.RawQuery), + slog.Int("status", recorder.status), + slog.Int("bytes", recorder.bytes), + slog.Float64("duration_ms", float64(time.Since(started).Microseconds())/1000), + slog.String("request_id", id), + slog.String("actor", current.actor), + slog.String("client_id", current.clientID), + slog.Bool("service", current.service), + slog.String("refusal", current.refusal), + slog.String("user_agent", r.UserAgent()), + slog.String("remote_addr", r.RemoteAddr), + ) + }) +} + +func levelOf(status int) slog.Level { + switch { + case status >= http.StatusInternalServerError: + return slog.LevelError + case status >= http.StatusBadRequest: + return slog.LevelWarn + default: + return slog.LevelInfo + } +} diff --git a/api/routes/answers.go b/api/routes/answers.go new file mode 100644 index 0000000..3e80757 --- /dev/null +++ b/api/routes/answers.go @@ -0,0 +1,128 @@ +package routes + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "slices" + "strings" + + "github.com/datapointchris/projects/api/requestlog" + "github.com/datapointchris/projects/api/wire" + "github.com/datapointchris/projects/model" +) + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + encoder := json.NewEncoder(w) + encoder.SetEscapeHTML(false) + _ = encoder.Encode(body) +} + +// statusPhrase is a status's standard phrase, which RFC 9110 words as Unprocessable +// Content where Go's text still says Entity. +func statusPhrase(status int) string { + if status == http.StatusUnprocessableEntity { + return "Unprocessable Content" + } + return http.StatusText(status) +} + +func writeRefusal(w http.ResponseWriter, r *http.Request, status int, message string, problems []wire.ValidationError) { + requestlog.NoteRefusal(r.Context(), message) + writeJSON(w, status, wire.Error{Error: statusPhrase(status), Message: message, Errors: problems}) +} + +// validationMessage is each refused field as `field: reason`, the field dotted below +// the part of the request it came in, in the one string a client prints. +func validationMessage(problems []wire.ValidationError) string { + parts := make([]string, 0, len(problems)) + for _, problem := range problems { + segments := make([]string, 0, len(problem.Loc)) + for i, segment := range problem.Loc { + if i == 0 && slices.Contains([]any{"body", "query", "path"}, segment) { + continue + } + segments = append(segments, fmt.Sprint(segment)) + } + if len(segments) == 0 { + parts = append(parts, problem.Msg) + continue + } + parts = append(parts, strings.Join(segments, ".")+": "+problem.Msg) + } + return strings.Join(parts, "; ") +} + +// refuseInvalid answers a 422 when any input was refused, and reports whether it did. +func refuseInvalid(w http.ResponseWriter, r *http.Request, problems ...[]wire.ValidationError) bool { + all := slices.Concat(problems...) + if len(all) == 0 { + return false + } + writeRefusal(w, r, http.StatusUnprocessableEntity, validationMessage(all), all) + return true +} + +// statusOfRefusal is what each rule's refusal answers. +var statusOfRefusal = []struct { + kind error + status int +}{ + {model.ErrNotFound, http.StatusNotFound}, + + {model.ErrDuplicateName, http.StatusConflict}, + {model.ErrDuplicateID, http.StatusConflict}, + {model.ErrAmbiguousName, http.StatusConflict}, + {model.ErrAlreadyInProject, http.StatusConflict}, + {model.ErrDuplicateDependency, http.StatusConflict}, + {model.ErrCyclicDependency, http.StatusConflict}, + {model.ErrLastProject, http.StatusConflict}, + {model.ErrIncompleteOrphans, http.StatusConflict}, + {model.ErrNothingToUndo, http.StatusConflict}, + + {model.ErrUnknownKind, http.StatusUnprocessableEntity}, + {model.ErrUnknownStatus, http.StatusUnprocessableEntity}, + {model.ErrUnknownItemStatus, http.StatusUnprocessableEntity}, + {model.ErrInvalidReference, http.StatusUnprocessableEntity}, + {model.ErrReservedName, http.StatusUnprocessableEntity}, + {model.ErrDropReasonRequired, http.StatusUnprocessableEntity}, + {model.ErrSelfDependency, http.StatusUnprocessableEntity}, + {model.ErrInvertedWindow, http.StatusUnprocessableEntity}, + {model.ErrNoProjects, http.StatusUnprocessableEntity}, + {model.ErrRepoNamedProject, http.StatusUnprocessableEntity}, + + {model.ErrIncompleteTasks, http.StatusBadRequest}, +} + +// fail answers what a rule returned. A refusal says its own words, and one naming a +// field marks it the way a validation error does. Anything else is a fault of this +// server, whose detail stays in the log. +func (t *table) fail(w http.ResponseWriter, r *http.Request, err error) { + for _, known := range statusOfRefusal { + if !errors.Is(err, known.kind) { + continue + } + var refusal *model.Refusal + if errors.As(err, &refusal) && refusal.Field != "" { + problem := wire.ValidationError{ + Type: "value_error", Loc: []any{"body", refusal.Field}, Msg: "Value error, " + refusal.Message, + Input: refusal.Input, Ctx: map[string]any{"error": map[string]any{}}, + } + refuseInvalid(w, r, []wire.ValidationError{problem}) + return + } + writeRefusal(w, r, known.status, err.Error(), nil) + return + } + t.log.LogAttrs(r.Context(), slog.LevelError, "request failed", + slog.String("err", err.Error()), + slog.String("method", r.Method), + slog.String("path", r.URL.EscapedPath()), + slog.String("request_id", requestlog.RequestID(r.Context())), + ) + writeRefusal(w, r, http.StatusInternalServerError, fmt.Sprintf("Internal server error: %T", err), nil) +} diff --git a/api/routes/project_item_tasks.go b/api/routes/project_item_tasks.go new file mode 100644 index 0000000..0582082 --- /dev/null +++ b/api/routes/project_item_tasks.go @@ -0,0 +1,96 @@ +package routes + +import ( + "net/http" + + "github.com/google/uuid" + + "github.com/datapointchris/projects/api/wire" + "github.com/datapointchris/projects/model" +) + +func (t *table) listTasks(w http.ResponseWriter, r *http.Request) { + tasks, err := t.scoped(r).ListTasks(r.PathValue("item_id")) + if err != nil { + t.fail(w, r, err) + return + } + writeJSON(w, http.StatusOK, wire.Map(tasks, wire.NewProjectItemTask)) +} + +func (t *table) createTask(w http.ResponseWriter, r *http.Request) { + body := readRequestBody(r) + input := model.CreateProjectItemTask{ + ID: body.optionalUUID("id"), + Title: body.requiredString("title"), + Position: body.intOr("position", 0), + } + if refuseInvalid(w, r, body.problems) { + return + } + created, err := t.scoped(r).CreateTask(r.PathValue("item_id"), input) + if err != nil { + t.fail(w, r, err) + return + } + writeJSON(w, http.StatusCreated, wire.NewProjectItemTask(*created)) +} + +// pathTaskID refuses a task id that is no UUID. A task has no other handle, so +// anything else cannot name one. +func pathTaskID(r *http.Request) (string, []wire.ValidationError) { + id := r.PathValue("task_id") + if _, err := uuid.Parse(id); err != nil { + return "", []wire.ValidationError{{Type: "uuid_parsing", Loc: []any{"path", "task_id"}, Msg: messageNotAUUID, Input: id}} + } + return id, nil +} + +func (t *table) updateTask(w http.ResponseWriter, r *http.Request) { + body := readRequestBody(r) + input := model.UpdateProjectItemTask{ + Title: body.partialString("title"), + Completed: body.partialBool("completed"), + Position: body.partialInt("position"), + } + taskID, problems := pathTaskID(r) + if refuseInvalid(w, r, problems, body.problems) { + return + } + updated, err := t.scoped(r).UpdateTask(r.PathValue("item_id"), taskID, input) + if err != nil { + t.fail(w, r, err) + return + } + writeJSON(w, http.StatusOK, wire.NewProjectItemTask(*updated)) +} + +func (t *table) deleteTask(w http.ResponseWriter, r *http.Request) { + taskID, problems := pathTaskID(r) + if refuseInvalid(w, r, problems) { + return + } + if err := t.scoped(r).DeleteTask(r.PathValue("item_id"), taskID); err != nil { + t.fail(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (t *table) undo(w http.ResponseWriter, r *http.Request) { + result, err := t.scoped(r).Undo() + if err != nil { + t.fail(w, r, err) + return + } + writeJSON(w, http.StatusOK, wire.NewUndoResult(*result)) +} + +func (t *table) undoState(w http.ResponseWriter, r *http.Request) { + canUndo, err := t.scoped(r).CanUndo() + if err != nil { + t.fail(w, r, err) + return + } + writeJSON(w, http.StatusOK, wire.UndoState{CanUndo: canUndo}) +} diff --git a/api/routes/project_items.go b/api/routes/project_items.go new file mode 100644 index 0000000..e6b051e --- /dev/null +++ b/api/routes/project_items.go @@ -0,0 +1,212 @@ +package routes + +import ( + "net/http" + + "github.com/datapointchris/projects/api/wire" + "github.com/datapointchris/projects/model" +) + +func (t *table) listItems(w http.ResponseWriter, r *http.Request) { + query := readRequestQuery(r) + filter := model.ItemFilter{ + Status: query.stringOr("status", model.ItemStatusOpen), + Repo: query.optional("repo"), + Since: query.instant("since"), + Until: query.instant("until"), + Limit: query.limit(), + } + if refuseInvalid(w, r, query.problems) { + return + } + items, err := t.scoped(r).ListItems(filter) + if err != nil { + t.fail(w, r, err) + return + } + writeJSON(w, http.StatusOK, wire.Map(items, wire.NewProjectItem)) +} + +func (t *table) listBlockedItems(w http.ResponseWriter, r *http.Request) { + items, err := t.scoped(r).ListBlockedItems(readRequestQuery(r).optional("repo")) + if err != nil { + t.fail(w, r, err) + return + } + writeJSON(w, http.StatusOK, wire.Map(items, wire.NewProjectItem)) +} + +func (t *table) searchItems(w http.ResponseWriter, r *http.Request) { + query := readRequestQuery(r) + words := query.required("q") + if refuseInvalid(w, r, query.problems) { + return + } + items, err := t.scoped(r).SearchItems(words, query.optional("repo")) + if err != nil { + t.fail(w, r, err) + return + } + writeJSON(w, http.StatusOK, wire.Map(items, wire.NewProjectItem)) +} + +func (t *table) createItem(w http.ResponseWriter, r *http.Request) { + body := readRequestBody(r) + input := model.CreateProjectItem{ + ID: body.optionalUUID("id"), + Title: body.requiredString("title"), + Notes: body.nullableString("notes"), + Repo: body.nullableString("repo"), + ProjectIDs: body.requiredStringList("project_ids"), + } + if refuseInvalid(w, r, body.problems) { + return + } + created, err := t.scoped(r).CreateItem(input) + if err != nil { + t.fail(w, r, err) + return + } + writeJSON(w, http.StatusCreated, wire.NewProjectItemDetail(*created)) +} + +func (t *table) readItem(w http.ResponseWriter, r *http.Request) { + item, err := t.scoped(r).GetItem(r.PathValue("id")) + if err != nil { + t.fail(w, r, err) + return + } + writeJSON(w, http.StatusOK, wire.NewProjectItemDetail(*item)) +} + +func (t *table) updateItem(w http.ResponseWriter, r *http.Request) { + body := readRequestBody(r) + notes, clearNotes := body.clearableString("notes") + repo, clearRepo := body.clearableString("repo") + input := model.UpdateProjectItem{ + Title: body.partialString("title"), + Notes: notes, + ClearNotes: clearNotes, + Repo: repo, + ClearRepo: clearRepo, + Completed: body.partialBool("completed"), + Archived: body.partialBool("archived"), + } + if refuseInvalid(w, r, body.problems) { + return + } + svc := t.scoped(r) + updated, err := svc.UpdateItem(r.PathValue("id"), input) + if err != nil { + t.fail(w, r, err) + return + } + filed, err := svc.GetItem(updated.ID) + if err != nil { + t.fail(w, r, err) + return + } + writeJSON(w, http.StatusOK, wire.NewProjectItem(*filed)) +} + +func (t *table) deleteItem(w http.ResponseWriter, r *http.Request) { + if err := t.scoped(r).DeleteItem(r.PathValue("id")); err != nil { + t.fail(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (t *table) reorderItem(w http.ResponseWriter, r *http.Request) { + body := readRequestBody(r) + projectRef := body.requiredString("project_id") + position := body.requiredInt("position") + if refuseInvalid(w, r, body.problems) { + return + } + svc := t.scoped(r) + itemRef := r.PathValue("id") + if err := svc.ReorderItem(itemRef, projectRef, position); err != nil { + t.fail(w, r, err) + return + } + item, err := svc.GetItemInProject(itemRef, projectRef) + if err != nil { + t.fail(w, r, err) + return + } + writeJSON(w, http.StatusOK, wire.NewProjectItemInProject(*item)) +} + +func (t *table) listItemProjects(w http.ResponseWriter, r *http.Request) { + projects, err := t.scoped(r).GetItemProjects(r.PathValue("id")) + if err != nil { + t.fail(w, r, err) + return + } + writeJSON(w, http.StatusOK, wire.Map(projects, wire.NewProject)) +} + +func (t *table) addItemToProject(w http.ResponseWriter, r *http.Request) { + body := readRequestBody(r) + projectRef := body.requiredString("project_id") + if refuseInvalid(w, r, body.problems) { + return + } + svc := t.scoped(r) + if err := svc.AddToProject(r.PathValue("id"), projectRef); err != nil { + t.fail(w, r, err) + return + } + project, err := svc.ResolveProject(projectRef) + if err != nil { + t.fail(w, r, err) + return + } + writeJSON(w, http.StatusCreated, wire.NewProject(*project)) +} + +func (t *table) removeItemFromProject(w http.ResponseWriter, r *http.Request) { + if err := t.scoped(r).RemoveFromProject(r.PathValue("id"), r.PathValue("project_id")); err != nil { + t.fail(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (t *table) addDependency(w http.ResponseWriter, r *http.Request) { + body := readRequestBody(r) + dependsOn := body.requiredItemReference("depends_on_id") + if refuseInvalid(w, r, body.problems) { + return + } + svc := t.scoped(r) + itemRef := r.PathValue("id") + if err := svc.AddDependency(itemRef, dependsOn); err != nil { + t.fail(w, r, err) + return + } + item, err := svc.GetItem(itemRef) + if err != nil { + t.fail(w, r, err) + return + } + writeJSON(w, http.StatusCreated, wire.NewProjectItemDetail(*item)) +} + +func (t *table) removeDependency(w http.ResponseWriter, r *http.Request) { + if err := t.scoped(r).RemoveDependency(r.PathValue("id"), r.PathValue("dep_id")); err != nil { + t.fail(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (t *table) listBlockers(w http.ResponseWriter, r *http.Request) { + items, err := t.scoped(r).ListBlockerItems(r.PathValue("id")) + if err != nil { + t.fail(w, r, err) + return + } + writeJSON(w, http.StatusOK, wire.Map(items, wire.NewProjectItem)) +} diff --git a/api/routes/projects.go b/api/routes/projects.go new file mode 100644 index 0000000..0fda851 --- /dev/null +++ b/api/routes/projects.go @@ -0,0 +1,110 @@ +package routes + +import ( + "net/http" + + "github.com/datapointchris/projects/api/wire" + "github.com/datapointchris/projects/model" +) + +func (t *table) listProjects(w http.ResponseWriter, r *http.Request) { + query := readRequestQuery(r) + filter := model.ProjectFilter{ + Status: query.stringOr("status", model.StatusActive), + Repo: query.optional("repo"), + Limit: query.limit(), + } + if refuseInvalid(w, r, query.problems) { + return + } + projects, err := t.scoped(r).ListProjects(filter) + if err != nil { + t.fail(w, r, err) + return + } + writeJSON(w, http.StatusOK, wire.Map(projects, wire.NewProjectWithItemCount)) +} + +func (t *table) createProject(w http.ResponseWriter, r *http.Request) { + body := readRequestBody(r) + position := body.intOr("position", 0) + input := model.CreateProject{ + ID: body.optionalUUID("id"), + Name: body.requiredString("name"), + Description: body.nullableString("description"), + Kind: body.stringUnlessLeftOut("kind"), + Status: body.stringUnlessLeftOut("status"), + StatusReason: body.nullableString("status_reason"), + Position: &position, + } + if refuseInvalid(w, r, body.problems) { + return + } + created, err := t.scoped(r).CreateProject(input) + if err != nil { + t.fail(w, r, err) + return + } + writeJSON(w, http.StatusCreated, wire.NewProject(*created)) +} + +func (t *table) readProject(w http.ResponseWriter, r *http.Request) { + project, err := t.scoped(r).GetProject(r.PathValue("id")) + if err != nil { + t.fail(w, r, err) + return + } + writeJSON(w, http.StatusOK, wire.NewProjectWithItemCount(*project)) +} + +func (t *table) updateProject(w http.ResponseWriter, r *http.Request) { + body := readRequestBody(r) + description, clearDescription := body.clearableString("description") + reason, clearReason := body.clearableString("status_reason") + input := model.UpdateProject{ + Name: body.partialString("name"), + Description: description, + ClearDescription: clearDescription, + Kind: body.partialString("kind"), + Status: body.partialString("status"), + StatusReason: reason, + ClearStatusReason: clearReason, + Position: body.partialInt("position"), + } + if refuseInvalid(w, r, body.problems) { + return + } + updated, err := t.scoped(r).UpdateProject(r.PathValue("id"), input) + if err != nil { + t.fail(w, r, err) + return + } + writeJSON(w, http.StatusOK, wire.NewProject(*updated)) +} + +func (t *table) deleteProject(w http.ResponseWriter, r *http.Request) { + if err := t.scoped(r).DeleteProject(r.PathValue("id")); err != nil { + t.fail(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (t *table) listProjectItems(w http.ResponseWriter, r *http.Request) { + query := readRequestQuery(r) + filter := model.ItemFilter{ + Status: query.stringOr("status", model.ItemStatusOpen), + Since: query.instant("since"), + Until: query.instant("until"), + Limit: query.limit(), + } + if refuseInvalid(w, r, query.problems) { + return + } + items, err := t.scoped(r).ListProjectItems(r.PathValue("id"), filter) + if err != nil { + t.fail(w, r, err) + return + } + writeJSON(w, http.StatusOK, wire.Map(items, wire.NewProjectItemInProject)) +} diff --git a/api/routes/request_input.go b/api/routes/request_input.go new file mode 100644 index 0000000..47cdf8e --- /dev/null +++ b/api/routes/request_input.go @@ -0,0 +1,355 @@ +package routes + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/google/uuid" + + "github.com/datapointchris/projects/api/wire" +) + +const ( + messageFieldRequired = "Field required" + messageNotAString = "Input should be a valid string" + messageNotAnInteger = "Input should be a valid integer" + messageNotABoolean = "Input should be a valid boolean" + messageNotAList = "Input should be a valid list" + messageNotAUUID = "Input should be a valid UUID" + messageNotAnObject = "Input should be a valid dictionary or object to extract fields from" + messageUnparsedInt = "Input should be a valid integer, unable to parse string as an integer" + messageNegativeLimit = "Input should be greater than or equal to 0" + messageNoOffset = "Input should have timezone info" + messageNotAnInstant = "Input should be a valid datetime or date" + messageCannotBeNull = "Value error, cannot be null; omit the field to leave it unchanged" +) + +// requestBody reads a JSON object field by field. A partial update has to tell a +// field left out from a field sent as null, which no struct's zero value can, so a +// handler asks for each field in the terms its contract states it. Every refusal +// collects in problems, so one answer names each refused field at once. +type requestBody struct { + fields map[string]json.RawMessage + whole any + problems []wire.ValidationError +} + +func readRequestBody(r *http.Request) *requestBody { + body := &requestBody{} + raw, err := io.ReadAll(r.Body) + if err != nil { + body.refuse("json_invalid", "JSON decode error", nil, "body") + return body + } + if len(bytes.TrimSpace(raw)) == 0 { + body.refuse("missing", messageFieldRequired, nil, "body") + return body + } + if err := json.Unmarshal(raw, &body.whole); err != nil { + var position int64 + var syntax *json.SyntaxError + if errors.As(err, &syntax) { + position = syntax.Offset + } + body.problems = append(body.problems, wire.ValidationError{ + Type: "json_invalid", Loc: []any{"body", position}, Msg: "JSON decode error", + Input: map[string]any{}, Ctx: map[string]any{"error": err.Error()}, + }) + return body + } + if _, isObject := body.whole.(map[string]any); !isObject { + body.refuse("model_attributes_type", messageNotAnObject, body.whole, "body") + return body + } + if err := json.Unmarshal(raw, &body.fields); err != nil { + body.refuse("model_attributes_type", messageNotAnObject, body.whole, "body") + } + return body +} + +func (b *requestBody) refuse(kind, message string, input any, loc ...any) { + b.problems = append(b.problems, wire.ValidationError{Type: kind, Loc: loc, Msg: message, Input: input}) +} + +func (b *requestBody) refuseField(field, kind, message string, raw json.RawMessage) { + var input any + _ = json.Unmarshal(raw, &input) + b.refuse(kind, message, input, "body", field) +} + +// refuseMissing says nothing about a body that could not be read at all, which is +// refused whole already. +func (b *requestBody) refuseMissing(field string) { + if b.fields == nil { + return + } + b.refuse("missing", messageFieldRequired, b.whole, "body", field) +} + +func (b *requestBody) refuseNull(field string) { + b.problems = append(b.problems, wire.ValidationError{ + Type: "value_error", Loc: []any{"body", field}, Msg: messageCannotBeNull, + Ctx: map[string]any{"error": map[string]any{}}, + }) +} + +func isJSONNull(raw json.RawMessage) bool { + return bytes.Equal(bytes.TrimSpace(raw), []byte("null")) +} + +// decodeField holds a value of T or refuses the field. A JSON null is never a T +// here, because each caller decides what null means before asking. +func decodeField[T any](b *requestBody, field, kind, message string, raw json.RawMessage) *T { + var value T + if isJSONNull(raw) || json.Unmarshal(raw, &value) != nil { + b.refuseField(field, kind, message, raw) + return nil + } + return &value +} + +func stringField(b *requestBody, field string, raw json.RawMessage) *string { + return decodeField[string](b, field, "string_type", messageNotAString, raw) +} + +// requiredString refuses a field left out or null. +func (b *requestBody) requiredString(field string) string { + raw, sent := b.fields[field] + if !sent { + b.refuseMissing(field) + return "" + } + if value := stringField(b, field, raw); value != nil { + return *value + } + return "" +} + +// stringUnlessLeftOut answers nil for a field left out, and refuses null. +func (b *requestBody) stringUnlessLeftOut(field string) *string { + raw, sent := b.fields[field] + if !sent { + return nil + } + return stringField(b, field, raw) +} + +// nullableString answers nil for a field left out or null. +func (b *requestBody) nullableString(field string) *string { + value, _ := b.clearableString(field) + return value +} + +// clearableString tells a field left out from one sent as null, which clears it. +func (b *requestBody) clearableString(field string) (value *string, cleared bool) { + raw, sent := b.fields[field] + if !sent { + return nil, false + } + if isJSONNull(raw) { + return nil, true + } + return stringField(b, field, raw), false +} + +// partial answers nil for a field left out, and refuses null. It reads a partial +// update's field over a column that cannot hold null. +func partial[T any](b *requestBody, field, kind, message string) *T { + raw, sent := b.fields[field] + if !sent { + return nil + } + if isJSONNull(raw) { + b.refuseNull(field) + return nil + } + return decodeField[T](b, field, kind, message, raw) +} + +func (b *requestBody) partialString(field string) *string { + return partial[string](b, field, "string_type", messageNotAString) +} + +func (b *requestBody) partialBool(field string) *bool { + return partial[bool](b, field, "bool_type", messageNotABoolean) +} + +func (b *requestBody) partialInt(field string) *int { + return partial[int](b, field, "int_type", messageNotAnInteger) +} + +// intOr answers fallback for a field left out, and refuses null. +func (b *requestBody) intOr(field string, fallback int) int { + raw, sent := b.fields[field] + if !sent { + return fallback + } + if value := decodeField[int](b, field, "int_type", messageNotAnInteger, raw); value != nil { + return *value + } + return fallback +} + +func (b *requestBody) requiredInt(field string) int { + raw, sent := b.fields[field] + if !sent { + b.refuseMissing(field) + return 0 + } + if value := decodeField[int](b, field, "int_type", messageNotAnInteger, raw); value != nil { + return *value + } + return 0 +} + +// optionalUUID is an id a caller chose, which may be left out or null. +func (b *requestBody) optionalUUID(field string) *string { + raw, sent := b.fields[field] + if !sent || isJSONNull(raw) { + return nil + } + value := decodeField[string](b, field, "uuid_type", "UUID input should be a string, bytes or UUID object", raw) + if value == nil { + return nil + } + if _, err := uuid.Parse(*value); err != nil { + b.refuseField(field, "uuid_parsing", messageNotAUUID, raw) + return nil + } + return value +} + +// requiredItemReference is an item's UUID or number. The number may come as a JSON +// number or as text, because a CLI forwards what a person typed. +func (b *requestBody) requiredItemReference(field string) string { + raw, sent := b.fields[field] + if !sent { + b.refuseMissing(field) + return "" + } + var number int + if !isJSONNull(raw) && json.Unmarshal(raw, &number) == nil { + return strconv.Itoa(number) + } + if value := stringField(b, field, raw); value != nil { + return *value + } + return "" +} + +// requiredStringList refuses a field left out, null, or holding anything but strings. +func (b *requestBody) requiredStringList(field string) []string { + raw, sent := b.fields[field] + if !sent { + b.refuseMissing(field) + return nil + } + var elements []json.RawMessage + if isJSONNull(raw) || json.Unmarshal(raw, &elements) != nil { + b.refuseField(field, "list_type", messageNotAList, raw) + return nil + } + values := make([]string, 0, len(elements)) + for i, element := range elements { + var value string + if isJSONNull(element) || json.Unmarshal(element, &value) != nil { + var input any + _ = json.Unmarshal(element, &input) + b.refuse("string_type", messageNotAString, input, "body", field, i) + continue + } + values = append(values, value) + } + return values +} + +// requestQuery reads query parameters the same way, collecting each refusal. +type requestQuery struct { + values url.Values + problems []wire.ValidationError +} + +func readRequestQuery(r *http.Request) *requestQuery { + return &requestQuery{values: r.URL.Query()} +} + +func (q *requestQuery) refuse(kind, field, message, input string, details map[string]any) { + q.problems = append(q.problems, wire.ValidationError{ + Type: kind, Loc: []any{"query", field}, Msg: message, Input: input, Ctx: details, + }) +} + +// optional answers nil for a parameter left out. An empty one is an answer of its +// own, which the repo filter reads as the untagged items. +func (q *requestQuery) optional(field string) *string { + if !q.values.Has(field) { + return nil + } + value := q.values.Get(field) + return &value +} + +func (q *requestQuery) stringOr(field, fallback string) string { + if value := q.optional(field); value != nil { + return *value + } + return fallback +} + +func (q *requestQuery) required(field string) string { + if !q.values.Has(field) { + q.problems = append(q.problems, wire.ValidationError{ + Type: "missing", Loc: []any{"query", field}, Msg: messageFieldRequired, + }) + return "" + } + return q.values.Get(field) +} + +// limit answers nil for no cap. Zero is a cap, and returns no rows. +func (q *requestQuery) limit() *int { + text := q.optional("limit") + if text == nil { + return nil + } + limit, err := strconv.Atoi(*text) + if err != nil { + q.refuse("int_parsing", "limit", messageUnparsedInt, *text, nil) + return nil + } + if limit < 0 { + q.refuse("greater_than_equal", "limit", messageNegativeLimit, *text, map[string]any{"ge": 0}) + return nil + } + return &limit +} + +// layoutsWithoutOffset parse as a time but name no instant, so they are refused +// for that rather than as unreadable. +var layoutsWithoutOffset = []string{"2006-01-02T15:04:05.999999999", "2006-01-02 15:04:05.999999999", "2006-01-02"} + +// instant reads an RFC 3339 instant. One with no offset is refused rather than +// guessed at, because it names no instant until a zone is assumed. +func (q *requestQuery) instant(field string) *time.Time { + text := q.optional(field) + if text == nil { + return nil + } + if parsed, err := time.Parse(time.RFC3339Nano, *text); err == nil { + return &parsed + } + for _, layout := range layoutsWithoutOffset { + if _, err := time.Parse(layout, *text); err == nil { + q.refuse("timezone_aware", field, messageNoOffset, *text, nil) + return nil + } + } + q.refuse("datetime_from_date_parsing", field, messageNotAnInstant, *text, nil) + return nil +} diff --git a/api/routes/routes.go b/api/routes/routes.go new file mode 100644 index 0000000..3e1cc93 --- /dev/null +++ b/api/routes/routes.go @@ -0,0 +1,148 @@ +// Package routes is the API's HTTP surface. A handler decodes, calls service and +// encodes, because a rule in a handler would have to land again for the other store. +package routes + +import ( + "context" + "log/slog" + "net/http" + "slices" + "strings" + + "github.com/datapointchris/projects/api/wire" + "github.com/datapointchris/projects/service" +) + +// prefix is where every route but the probes and the login metadata lives. +const prefix = "/api/v1" + +// PublicPaths answer with no credential, because a cluster's probes hold none. +var PublicPaths = []string{"/health", "/ready"} + +// ServiceScopes is which routes a service client's scope reaches, as registered +// patterns. A person's client reaches every route, and a service reaches only these. +var ServiceScopes = map[string][]string{ + "projects.items.read": { + "GET " + prefix + "/project-items", + "GET " + prefix + "/project-items/blocked", + "GET " + prefix + "/project-items/search", + "GET " + prefix + "/project-items/{id}", + "GET " + prefix + "/project-items/{id}/blockers", + "GET " + prefix + "/project-items/{item_id}/tasks", + "GET " + prefix + "/projects/{id}/items", + }, +} + +// ReadinessCheck answers nil when the named dependency can serve a request. +type ReadinessCheck struct { + Name string + Check func(ctx context.Context) error +} + +// table is what the handlers share. +type table struct { + service *service.Service + log *slog.Logger + ready []ReadinessCheck + mux *http.ServeMux +} + +// scoped is the service for one request, so its cancellation and its writer reach +// the store. +func (t *table) scoped(r *http.Request) *service.Service { + return t.service.WithContext(r.Context()) +} + +// New registers every route. A wildcard takes an escaped slash, so a project name +// holding one is reached by escaping it. +func New(svc *service.Service, log *slog.Logger, ready ...ReadinessCheck) *http.ServeMux { + t := &table{service: svc, log: log, ready: ready, mux: http.NewServeMux()} + mux := t.mux + + mux.HandleFunc("GET /health", t.health) + mux.HandleFunc("GET /ready", t.readiness) + mux.HandleFunc("/", t.unrouted) + + mux.HandleFunc("GET "+prefix+"/projects", t.listProjects) + mux.HandleFunc("POST "+prefix+"/projects", t.createProject) + mux.HandleFunc("GET "+prefix+"/projects/{id}/items", t.listProjectItems) + mux.HandleFunc("GET "+prefix+"/projects/{id}", t.readProject) + mux.HandleFunc("PATCH "+prefix+"/projects/{id}", t.updateProject) + mux.HandleFunc("DELETE "+prefix+"/projects/{id}", t.deleteProject) + + mux.HandleFunc("GET "+prefix+"/project-items", t.listItems) + mux.HandleFunc("POST "+prefix+"/project-items", t.createItem) + mux.HandleFunc("GET "+prefix+"/project-items/blocked", t.listBlockedItems) + mux.HandleFunc("GET "+prefix+"/project-items/search", t.searchItems) + mux.HandleFunc("GET "+prefix+"/project-items/{id}", t.readItem) + mux.HandleFunc("PATCH "+prefix+"/project-items/{id}", t.updateItem) + mux.HandleFunc("DELETE "+prefix+"/project-items/{id}", t.deleteItem) + mux.HandleFunc("PATCH "+prefix+"/project-items/{id}/reorder", t.reorderItem) + mux.HandleFunc("GET "+prefix+"/project-items/{id}/projects", t.listItemProjects) + mux.HandleFunc("POST "+prefix+"/project-items/{id}/projects", t.addItemToProject) + mux.HandleFunc("DELETE "+prefix+"/project-items/{id}/projects/{project_id}", t.removeItemFromProject) + mux.HandleFunc("POST "+prefix+"/project-items/{id}/dependencies", t.addDependency) + mux.HandleFunc("DELETE "+prefix+"/project-items/{id}/dependencies/{dep_id}", t.removeDependency) + mux.HandleFunc("GET "+prefix+"/project-items/{id}/blockers", t.listBlockers) + + mux.HandleFunc("GET "+prefix+"/project-items/{item_id}/tasks", t.listTasks) + mux.HandleFunc("POST "+prefix+"/project-items/{item_id}/tasks", t.createTask) + mux.HandleFunc("PATCH "+prefix+"/project-items/{item_id}/tasks/{task_id}", t.updateTask) + mux.HandleFunc("DELETE "+prefix+"/project-items/{item_id}/tasks/{task_id}", t.deleteTask) + + mux.HandleFunc("POST "+prefix+"/undo", t.undo) + mux.HandleFunc("GET "+prefix+"/undo", t.undoState) + + return mux +} + +func (t *table) health(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, wire.Probe{Status: "ok"}) +} + +// readiness answers 503 while a dependency does not, which takes a pod out of its +// Service without restarting it. +func (t *table) readiness(w http.ResponseWriter, r *http.Request) { + for _, dependency := range t.ready { + if err := dependency.Check(r.Context()); err != nil { + t.log.WarnContext(r.Context(), "not ready", "dependency", dependency.Name, "err", err) + writeJSON(w, http.StatusServiceUnavailable, wire.Probe{Status: "unavailable", Reason: dependency.Name}) + return + } + } + writeJSON(w, http.StatusOK, wire.Probe{Status: "ready"}) +} + +// unrouted answers every path no route holds, and every method a route's path does +// not take. The catch-all takes both away from the mux's plain-text answers, so a +// client reads the same body it reads for any other refusal. +func (t *table) unrouted(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if strings.HasPrefix(path, "/api/") && path != prefix && !strings.HasPrefix(path, prefix+"/") { + writeRefusal(w, r, http.StatusGone, "this API serves "+prefix+"; update the client calling it", nil) + return + } + allowed := t.methodsTakenAt(r) + if len(allowed) == 0 { + writeRefusal(w, r, http.StatusNotFound, "no route answers "+path, nil) + return + } + allow := strings.Join(allowed, ", ") + w.Header().Set("Allow", allow) + writeRefusal(w, r, http.StatusMethodNotAllowed, path+" takes "+allow+", not "+r.Method, nil) +} + +// methodsTakenAt asks the mux which methods reach a real route at this path. Every +// other answer is the catch-all's own pattern. +func (t *table) methodsTakenAt(r *http.Request) []string { + var allowed []string + probe := r.Clone(r.Context()) + for _, method := range []string{http.MethodDelete, http.MethodGet, http.MethodPatch, http.MethodPost, http.MethodPut} { + probe.Method = method + if _, pattern := t.mux.Handler(probe); pattern != "/" { + allowed = append(allowed, method) + } + } + slices.Sort(allowed) + return allowed +} diff --git a/api/routes/routes_test.go b/api/routes/routes_test.go new file mode 100644 index 0000000..e0637dd --- /dev/null +++ b/api/routes/routes_test.go @@ -0,0 +1,293 @@ +package routes_test + +import ( + "bytes" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/datapointchris/goapiauth/authtest" + "github.com/santhosh-tekuri/jsonschema/v6" + + "github.com/datapointchris/projects/api/authentication" + "github.com/datapointchris/projects/api/requestlog" + "github.com/datapointchris/projects/api/routes" + "github.com/datapointchris/projects/service" + "github.com/datapointchris/projects/store/sqlite" +) + +// api is the routes behind the authentication production wraps them in, with a +// person's token and a service's. Any rule's store answers here, and the service's +// own suite proves the two stores agree. +type api struct { + handler http.Handler + person string + service string +} + +func newAPI(t *testing.T) api { + t.Helper() + database, err := sqlite.Open(":memory:") + if err != nil { + t.Fatalf("opening database: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + + log := slog.New(slog.DiscardHandler) + provider := authtest.NewProvider(t) + config := authentication.Config(provider.URL) + verifier := provider.Verifier(t, config.PersonPrefix, config.ServicePrefix) + mux := routes.New(service.New(sqlite.New(database), service.AssigningItemNumbers()), log) + authentication.Metadata(mux, "https://projects.example.com", provider.URL) + protected := authentication.Protect(mux, routes.ServiceScopes, verifier, log, routes.PublicPaths...) + + return api{ + handler: requestlog.Middleware(log, protected), + person: provider.Person(t, config.PersonPrefix+"test", "a-person"), + service: provider.Sign(t, "at+jwt", map[string]any{"client_id": config.ServicePrefix + "reader", "scp": []string{"projects.items.read"}}), + } +} + +func (a api) send(t *testing.T, token, method, path string, body any) (int, []byte) { + t.Helper() + var reader bytes.Buffer + if body != nil { + if err := json.NewEncoder(&reader).Encode(body); err != nil { + t.Fatalf("encoding body: %v", err) + } + } + request := httptest.NewRequest(method, path, &reader) + if token != "" { + request.Header.Set("Authorization", "Bearer "+token) + } + recorder := httptest.NewRecorder() + a.handler.ServeHTTP(recorder, request) + return recorder.Code, recorder.Body.Bytes() +} + +// created sends a write the test depends on and answers the id it created. +func (a api) created(t *testing.T, path string, body any) string { + t.Helper() + status, answer := a.send(t, a.person, http.MethodPost, path, body) + if status != http.StatusCreated { + t.Fatalf("POST %s answered %d: %s", path, status, answer) + } + var row struct { + ID string `json:"id"` + } + if err := json.Unmarshal(answer, &row); err != nil { + t.Fatalf("reading %s: %v", answer, err) + } + return row.ID +} + +func schema(t *testing.T, name string) *jsonschema.Schema { + t.Helper() + path := filepath.Join("..", "schemas", name+".schema.json") + file, err := os.Open(path) + if err != nil { + t.Fatalf("opening schema: %v", err) + } + defer func() { _ = file.Close() }() + document, err := jsonschema.UnmarshalJSON(file) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + compiler := jsonschema.NewCompiler() + compiler.AssertFormat() + if err := compiler.AddResource(name, document); err != nil { + t.Fatalf("adding %s: %v", path, err) + } + compiled, err := compiler.Compile(name) + if err != nil { + t.Fatalf("compiling %s: %v", path, err) + } + return compiled +} + +// Every shape the API answers with is a real answer checked against its file, so +// the schemas cannot drift from the code without this failing. +func TestEveryAnswerMatchesItsSchema(t *testing.T) { + a := newAPI(t) + const v1 = "/api/v1" + project := a.created(t, v1+"/projects", map[string]any{"name": "first"}) + other := a.created(t, v1+"/projects", map[string]any{"name": "second", "status": "dropped", "status_reason": "why"}) + item := a.created(t, v1+"/project-items", map[string]any{"title": "an item", "repo": "a-repo", "project_ids": []string{project}}) + blocker := a.created(t, v1+"/project-items", map[string]any{"title": "a blocker", "project_ids": []string{project}}) + task := a.created(t, v1+"/project-items/"+item+"/tasks", map[string]any{"title": "a task"}) + itemPath := v1 + "/project-items/" + item + + for _, c := range []struct { + method, path string + body any + status int + schema string + isList bool + }{ + {http.MethodGet, "/health", nil, 200, "probe", false}, + {http.MethodGet, "/ready", nil, 200, "probe", false}, + {http.MethodGet, v1 + "/projects", nil, 200, "project-with-item-count", true}, + {http.MethodGet, v1 + "/projects/" + project, nil, 200, "project-with-item-count", false}, + {http.MethodPatch, v1 + "/projects/" + other, map[string]any{"position": 3}, 200, "project", false}, + {http.MethodGet, v1 + "/projects/" + project + "/items", nil, 200, "project-item-in-project", true}, + {http.MethodPost, itemPath + "/dependencies", map[string]any{"depends_on_id": blocker}, 201, "project-item-detail", false}, + {http.MethodGet, itemPath, nil, 200, "project-item-detail", false}, + {http.MethodGet, v1 + "/project-items", nil, 200, "project-item", true}, + {http.MethodGet, v1 + "/project-items/blocked", nil, 200, "project-item", true}, + {http.MethodGet, v1 + "/project-items/search?q=item", nil, 200, "project-item", true}, + {http.MethodGet, itemPath + "/blockers", nil, 200, "project-item", true}, + {http.MethodPatch, itemPath, map[string]any{"notes": "a note"}, 200, "project-item", false}, + {http.MethodPatch, itemPath + "/reorder", map[string]any{"project_id": project, "position": 1}, 200, "project-item-in-project", false}, + {http.MethodGet, itemPath + "/projects", nil, 200, "project", true}, + {http.MethodPost, itemPath + "/projects", map[string]any{"project_id": other}, 201, "project", false}, + {http.MethodGet, itemPath + "/tasks", nil, 200, "project-item-task", true}, + {http.MethodPatch, itemPath + "/tasks/" + task, map[string]any{"completed": true}, 200, "project-item-task", false}, + {http.MethodGet, v1 + "/undo", nil, 200, "undo-state", false}, + {http.MethodPost, v1 + "/undo", nil, 200, "undo-result", false}, + + {http.MethodGet, v1 + "/projects/absent", nil, 404, "error", false}, + {http.MethodPost, v1 + "/projects", map[string]any{"position": "first"}, 422, "error", false}, + {http.MethodPatch, itemPath, map[string]any{"title": nil}, 422, "error", false}, + {http.MethodGet, v1 + "/project-items?limit=-1", nil, 422, "error", false}, + {http.MethodGet, v1 + "/nothing-here", nil, 404, "error", false}, + {http.MethodPut, v1 + "/projects", nil, 405, "error", false}, + {http.MethodGet, "/api/projects", nil, 410, "error", false}, + } { + t.Run(c.method+" "+c.path, func(t *testing.T) { + status, answer := a.send(t, a.person, c.method, c.path, c.body) + if status != c.status { + t.Fatalf("answered %d, want %d: %s", status, c.status, answer) + } + var document any + if err := json.Unmarshal(answer, &document); err != nil { + t.Fatalf("reading %s: %v", answer, err) + } + rows := []any{document} + if c.isList { + rows = document.([]any) + if len(rows) == 0 { + t.Fatalf("an empty list checks nothing against %s", c.schema) + } + } + compiled := schema(t, c.schema) + for _, row := range rows { + if err := compiled.Validate(row); err != nil { + t.Errorf("%v\nanswer: %s", err, answer) + } + } + }) + } +} + +func TestATokenReachesWhatItsClientMay(t *testing.T) { + a := newAPI(t) + project := a.created(t, "/api/v1/projects", map[string]any{"name": "first"}) + item := a.created(t, "/api/v1/project-items", map[string]any{"title": "an item", "project_ids": []string{project}}) + + reachable := []string{ + "/api/v1/project-items", + "/api/v1/project-items/blocked", + "/api/v1/project-items/search?q=item", + "/api/v1/project-items/" + item, + "/api/v1/project-items/" + item + "/blockers", + "/api/v1/project-items/" + item + "/tasks", + "/api/v1/projects/" + project + "/items", + } + for _, path := range reachable { + if status, answer := a.send(t, a.service, http.MethodGet, path, nil); status != http.StatusOK { + t.Errorf("service GET %s answered %d: %s", path, status, answer) + } + } + for _, c := range []struct{ method, path string }{ + {http.MethodGet, "/api/v1/projects"}, + {http.MethodGet, "/api/v1/projects/" + project}, + {http.MethodPatch, "/api/v1/project-items/" + item}, + {http.MethodGet, "/api/v1/nothing-here"}, + } { + if status, answer := a.send(t, a.service, c.method, c.path, nil); status != http.StatusForbidden { + t.Errorf("service %s %s answered %d: %s", c.method, c.path, status, answer) + } + } + + if status, _ := a.send(t, a.person, http.MethodGet, "/api/v1/projects", nil); status != http.StatusOK { + t.Errorf("a person's token answered %d", status) + } + if status, _ := a.send(t, "", http.MethodGet, "/api/v1/projects", nil); status != http.StatusUnauthorized { + t.Errorf("no token answered %d", status) + } + if status, _ := a.send(t, "", http.MethodGet, "/.well-known/oauth-protected-resource", nil); status != http.StatusOK { + t.Errorf("the login metadata with no token answered %d", status) + } +} + +// A project name may hold a slash, and every client escapes what it puts in a path. +func TestAProjectNameHoldingASlashIsReachedEscaped(t *testing.T) { + a := newAPI(t) + const name = "web/api rewrite" + project := a.created(t, "/api/v1/projects", map[string]any{"name": name}) + a.created(t, "/api/v1/project-items", map[string]any{"title": "an item", "project_ids": []string{name}}) + + escaped := url.PathEscape(name) + status, answer := a.send(t, a.person, http.MethodGet, "/api/v1/projects/"+escaped, nil) + if status != http.StatusOK || !bytes.Contains(answer, []byte(project)) { + t.Errorf("reading it answered %d: %s", status, answer) + } + status, answer = a.send(t, a.person, http.MethodGet, "/api/v1/projects/"+escaped+"/items", nil) + if status != http.StatusOK || !bytes.Contains(answer, []byte("an item")) { + t.Errorf("reading its items answered %d: %s", status, answer) + } +} + +// Null clears a field and leaving it out keeps it, and a field that cannot hold +// null refuses it by name, so a partial update never guesses. +func TestAPartialUpdateTellsNullFromLeftOut(t *testing.T) { + a := newAPI(t) + project := a.created(t, "/api/v1/projects", map[string]any{"name": "first"}) + item := a.created(t, "/api/v1/project-items", map[string]any{"title": "an item", "notes": "a note", "repo": "a-repo", "project_ids": []string{project}}) + path := "/api/v1/project-items/" + item + + patched := func(body any) (notes, repo *string) { + t.Helper() + status, answer := a.send(t, a.person, http.MethodPatch, path, body) + if status != http.StatusOK { + t.Fatalf("PATCH %v answered %d: %s", body, status, answer) + } + var row struct { + Notes *string `json:"notes"` + Repo *string `json:"repo"` + } + if err := json.Unmarshal(answer, &row); err != nil { + t.Fatalf("reading %s: %v", answer, err) + } + return row.Notes, row.Repo + } + if notes, repo := patched(map[string]any{"title": "renamed"}); notes == nil || repo == nil { + t.Errorf("leaving the fields out changed them: notes %v, repo %v", notes, repo) + } + if notes, repo := patched(map[string]any{"notes": nil}); notes != nil || repo == nil { + t.Errorf("null on notes alone: notes %v, repo %v", notes, repo) + } + + status, answer := a.send(t, a.person, http.MethodPatch, path, map[string]any{"title": nil}) + var refused struct { + Message string `json:"message"` + Errors []struct { + Loc []any `json:"loc"` + } `json:"errors"` + } + if err := json.Unmarshal(answer, &refused); err != nil { + t.Fatalf("reading %s: %v", answer, err) + } + const want = "title: Value error, cannot be null; omit the field to leave it unchanged" + if status != http.StatusUnprocessableEntity || refused.Message != want || len(refused.Errors) != 1 { + t.Errorf("null on a title answered %d: %s", status, answer) + } + if refused.Errors[0].Loc[1] != "title" { + t.Errorf("the refusal marks %v", refused.Errors[0].Loc) + } +} diff --git a/api/schemas/error.schema.json b/api/schemas/error.schema.json new file mode 100644 index 0000000..81ab432 --- /dev/null +++ b/api/schemas/error.schema.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:projects:error:v1", + "title": "error", + "type": "object", + "description": "Every refusal. message always says why in one string. errors lists each refused input on a validation 422.", + "required": [ + "error", + "message" + ], + "properties": { + "error": { + "type": "string", + "description": "The status's standard phrase." + }, + "message": { + "type": "string" + }, + "code": { + "type": "string", + "description": "A stable name for a refusal from authentication." + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "required": [ + "type", + "loc", + "msg" + ], + "properties": { + "type": { + "type": "string" + }, + "loc": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "msg": { + "type": "string" + }, + "input": {}, + "ctx": { + "type": "object" + } + } + } + } + } +} diff --git a/api/schemas/probe.schema.json b/api/schemas/probe.schema.json new file mode 100644 index 0000000..8606faf --- /dev/null +++ b/api/schemas/probe.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:projects:probe:v1", + "title": "probe", + "type": "object", + "description": "What /health and /ready answer.", + "additionalProperties": false, + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "ok", + "ready", + "unavailable" + ] + }, + "reason": { + "type": "string" + } + } +} diff --git a/api/schemas/project-item-detail.schema.json b/api/schemas/project-item-detail.schema.json new file mode 100644 index 0000000..ef97b35 --- /dev/null +++ b/api/schemas/project-item-detail.schema.json @@ -0,0 +1,226 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:projects:project-item-detail:v1", + "title": "project-item-detail", + "type": "object", + "description": "One item read alone, naming what it waits on.", + "additionalProperties": false, + "required": [ + "id", + "number", + "title", + "notes", + "repo", + "completed", + "completed_at", + "archived", + "status", + "created_at", + "updated_at", + "projects", + "memberships", + "dependency_ids", + "depends_on" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "number": { + "type": [ + "integer", + "null" + ], + "description": "The item's handle. Null only on a store whose server has not assigned one yet." + }, + "title": { + "type": "string" + }, + "notes": { + "type": [ + "string", + "null" + ] + }, + "repo": { + "type": [ + "string", + "null" + ] + }, + "completed": { + "type": "boolean" + }, + "completed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "archived": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "open", + "completed", + "archived" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "projects": { + "type": "array", + "items": { + "$ref": "#/$defs/project" + } + }, + "memberships": { + "type": "array", + "items": { + "$ref": "#/$defs/membership" + } + }, + "dependency_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "depends_on": { + "type": "array", + "items": { + "$ref": "#/$defs/summary" + } + } + }, + "$defs": { + "project": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "description", + "kind", + "status", + "status_reason", + "closed_at", + "position", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "type": "string", + "description": "One of the names in the project_kinds table." + }, + "status": { + "type": "string", + "description": "One of the names in the project_statuses table." + }, + "status_reason": { + "type": [ + "string", + "null" + ] + }, + "closed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "position": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "membership": { + "type": "object", + "additionalProperties": false, + "required": [ + "project_id", + "position" + ], + "properties": { + "project_id": { + "type": "string", + "format": "uuid" + }, + "position": { + "type": "integer" + } + } + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "number", + "title", + "completed", + "archived", + "status" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "number": { + "type": [ + "integer", + "null" + ], + "description": "The item's handle. Null only on a store whose server has not assigned one yet." + }, + "title": { + "type": "string" + }, + "completed": { + "type": "boolean" + }, + "archived": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "open", + "completed", + "archived" + ] + } + } + } + } +} diff --git a/api/schemas/project-item-in-project.schema.json b/api/schemas/project-item-in-project.schema.json new file mode 100644 index 0000000..c2e42bf --- /dev/null +++ b/api/schemas/project-item-in-project.schema.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:projects:project-item-in-project:v1", + "title": "project-item-in-project", + "type": "object", + "description": "An item as one project holds it.", + "additionalProperties": false, + "required": [ + "id", + "number", + "title", + "notes", + "repo", + "completed", + "completed_at", + "archived", + "status", + "created_at", + "updated_at", + "position", + "project_count" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "number": { + "type": [ + "integer", + "null" + ], + "description": "The item's handle. Null only on a store whose server has not assigned one yet." + }, + "title": { + "type": "string" + }, + "notes": { + "type": [ + "string", + "null" + ] + }, + "repo": { + "type": [ + "string", + "null" + ] + }, + "completed": { + "type": "boolean" + }, + "completed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "archived": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "open", + "completed", + "archived" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "position": { + "type": "integer" + }, + "project_count": { + "type": "integer", + "description": "How many projects hold the item." + } + } +} diff --git a/api/schemas/project-item-task.schema.json b/api/schemas/project-item-task.schema.json new file mode 100644 index 0000000..2dced59 --- /dev/null +++ b/api/schemas/project-item-task.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:projects:project-item-task:v1", + "title": "project-item-task", + "type": "object", + "description": "One task on an item.", + "additionalProperties": false, + "required": [ + "id", + "item_id", + "title", + "completed", + "completed_at", + "position", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "item_id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "completed": { + "type": "boolean" + }, + "completed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "position": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } +} diff --git a/api/schemas/project-item.schema.json b/api/schemas/project-item.schema.json new file mode 100644 index 0000000..2d678c1 --- /dev/null +++ b/api/schemas/project-item.schema.json @@ -0,0 +1,227 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:projects:project-item:v1", + "title": "project-item", + "type": "object", + "description": "An item with what is filed against it, as a list returns it.", + "additionalProperties": false, + "required": [ + "id", + "number", + "title", + "notes", + "repo", + "completed", + "completed_at", + "archived", + "status", + "created_at", + "updated_at", + "projects", + "memberships", + "dependency_ids", + "tasks" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "number": { + "type": [ + "integer", + "null" + ], + "description": "The item's handle. Null only on a store whose server has not assigned one yet." + }, + "title": { + "type": "string" + }, + "notes": { + "type": [ + "string", + "null" + ] + }, + "repo": { + "type": [ + "string", + "null" + ] + }, + "completed": { + "type": "boolean" + }, + "completed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "archived": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "open", + "completed", + "archived" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "projects": { + "type": "array", + "items": { + "$ref": "#/$defs/project" + } + }, + "memberships": { + "type": "array", + "items": { + "$ref": "#/$defs/membership" + } + }, + "dependency_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "tasks": { + "type": "array", + "items": { + "$ref": "#/$defs/task" + } + } + }, + "$defs": { + "project": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "description", + "kind", + "status", + "status_reason", + "closed_at", + "position", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "type": "string", + "description": "One of the names in the project_kinds table." + }, + "status": { + "type": "string", + "description": "One of the names in the project_statuses table." + }, + "status_reason": { + "type": [ + "string", + "null" + ] + }, + "closed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "position": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "membership": { + "type": "object", + "additionalProperties": false, + "required": [ + "project_id", + "position" + ], + "properties": { + "project_id": { + "type": "string", + "format": "uuid" + }, + "position": { + "type": "integer" + } + } + }, + "task": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "item_id", + "title", + "completed", + "completed_at", + "position", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "item_id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "completed": { + "type": "boolean" + }, + "completed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "position": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + } +} diff --git a/api/schemas/project-with-item-count.schema.json b/api/schemas/project-with-item-count.schema.json new file mode 100644 index 0000000..eb4107c --- /dev/null +++ b/api/schemas/project-with-item-count.schema.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:projects:project-with-item-count:v1", + "title": "project-with-item-count", + "type": "object", + "description": "A project and what its items add up to.", + "additionalProperties": false, + "required": [ + "id", + "name", + "description", + "kind", + "status", + "status_reason", + "closed_at", + "position", + "created_at", + "item_count", + "open_count", + "completed_count", + "repos" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "type": "string", + "description": "One of the names in the project_kinds table." + }, + "status": { + "type": "string", + "description": "One of the names in the project_statuses table." + }, + "status_reason": { + "type": [ + "string", + "null" + ] + }, + "closed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "position": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "item_count": { + "type": "integer", + "description": "Every membership, archived items included." + }, + "open_count": { + "type": "integer" + }, + "completed_count": { + "type": "integer" + }, + "repos": { + "type": "array", + "items": { + "type": "string" + } + } + } +} diff --git a/api/schemas/project.schema.json b/api/schemas/project.schema.json new file mode 100644 index 0000000..a5f5157 --- /dev/null +++ b/api/schemas/project.schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:projects:project:v1", + "title": "project", + "type": "object", + "description": "One project.", + "additionalProperties": false, + "required": [ + "id", + "name", + "description", + "kind", + "status", + "status_reason", + "closed_at", + "position", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "type": "string", + "description": "One of the names in the project_kinds table." + }, + "status": { + "type": "string", + "description": "One of the names in the project_statuses table." + }, + "status_reason": { + "type": [ + "string", + "null" + ] + }, + "closed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "position": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } +} diff --git a/api/schemas/undo-result.schema.json b/api/schemas/undo-result.schema.json new file mode 100644 index 0000000..61c8136 --- /dev/null +++ b/api/schemas/undo-result.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:projects:undo-result:v1", + "title": "undo-result", + "type": "object", + "description": "What an undo reversed.", + "additionalProperties": false, + "required": [ + "description", + "action", + "entity_type", + "entity_id" + ], + "properties": { + "description": { + "type": "string" + }, + "action": { + "type": "string" + }, + "entity_type": { + "type": "string" + }, + "entity_id": { + "type": "string" + } + } +} diff --git a/api/schemas/undo-state.schema.json b/api/schemas/undo-state.schema.json new file mode 100644 index 0000000..5ff5be1 --- /dev/null +++ b/api/schemas/undo-state.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:projects:undo-state:v1", + "title": "undo-state", + "type": "object", + "description": "Whether anything is left to undo.", + "additionalProperties": false, + "required": [ + "can_undo" + ], + "properties": { + "can_undo": { + "type": "boolean" + } + } +} diff --git a/api/settings/settings.go b/api/settings/settings.go new file mode 100644 index 0000000..bafd997 --- /dev/null +++ b/api/settings/settings.go @@ -0,0 +1,93 @@ +// Package settings reads the API's configuration from the environment, which is the +// only place a container gets it from. +package settings + +import ( + "fmt" + "net" + "net/url" + "os" + "strings" +) + +const ( + defaultListenPort = "8000" + defaultPostgresPort = "5432" +) + +// Postgres is where the database is. +type Postgres struct { + Host string + Port string + Username string + Password string + Database string +} + +// URL is the connection string pgx and goose read. +func (p Postgres) URL() string { + return (&url.URL{ + Scheme: "postgres", + User: url.UserPassword(p.Username, p.Password), + Host: net.JoinHostPort(p.Host, p.Port), + Path: "/" + p.Database, + }).String() +} + +// Server is everything serving needs. +type Server struct { + Postgres + OIDCIssuer string + // PublicURL is the address clients reach the API at. Set, the API serves RFC + // 9728 metadata naming the issuer, so a client can find where to log in. + PublicURL string + ListenPort string +} + +// LoadPostgres reads the database's settings, the only ones the migration job needs. +func LoadPostgres() (Postgres, error) { + var missing []string + require := func(name string) string { + value := os.Getenv(name) + if value == "" { + missing = append(missing, name) + } + return value + } + loaded := Postgres{ + Host: require("POSTGRES_HOST"), + Port: orDefault("POSTGRES_PORT", defaultPostgresPort), + Username: require("POSTGRES_USERNAME"), + Password: require("POSTGRES_PASSWORD"), + Database: require("POSTGRES_DB"), + } + if len(missing) > 0 { + return Postgres{}, fmt.Errorf("settings not set: %s", strings.Join(missing, ", ")) + } + return loaded, nil +} + +// LoadServer reads every setting serving needs. +func LoadServer() (Server, error) { + postgres, err := LoadPostgres() + if err != nil { + return Server{}, err + } + issuer := os.Getenv("OIDC_ISSUER") + if issuer == "" { + return Server{}, fmt.Errorf("settings not set: OIDC_ISSUER") + } + return Server{ + Postgres: postgres, + OIDCIssuer: issuer, + PublicURL: os.Getenv("PUBLIC_URL"), + ListenPort: orDefault("PORT", defaultListenPort), + }, nil +} + +func orDefault(name, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} diff --git a/api/wire/wire.go b/api/wire/wire.go new file mode 100644 index 0000000..e973ce4 --- /dev/null +++ b/api/wire/wire.go @@ -0,0 +1,310 @@ +// Package wire is what the API answers with. It is apart from model because a +// client depends on this contract and never on the server's types: every field is +// always sent, an empty one as null, so a reader indexing by name finds it, and a +// change to model cannot change an answer by accident. Each struct has its JSON +// Schema under api/schemas. +package wire + +import ( + "fmt" + "time" + + "github.com/datapointchris/projects/model" +) + +// Time prints an instant the way clients have always read it: UTC with a Z, six +// fractional digits when there is a fraction, and none when there is not. +type Time struct{ time.Time } + +func (t Time) MarshalJSON() ([]byte, error) { + utc := t.UTC() + layout := "2006-01-02T15:04:05Z" + if utc.Nanosecond()/1000 != 0 { + layout = "2006-01-02T15:04:05.000000Z" + } + return fmt.Appendf(nil, "%q", utc.Format(layout)), nil +} + +func optionalTime(t *time.Time) *Time { + if t == nil { + return nil + } + return &Time{*t} +} + +// Project is one project. +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + Kind string `json:"kind"` + Status string `json:"status"` + StatusReason *string `json:"status_reason"` + ClosedAt *Time `json:"closed_at"` + Position int `json:"position"` + CreatedAt Time `json:"created_at"` +} + +// NewProject is the wire form of a project. +func NewProject(p model.Project) Project { + return Project{ + ID: p.ID, + Name: p.Name, + Description: p.Description, + Kind: p.Kind, + Status: p.Status, + StatusReason: p.StatusReason, + ClosedAt: optionalTime(p.ClosedAt), + Position: p.Position, + CreatedAt: Time{p.CreatedAt}, + } +} + +// ProjectWithItemCount is a project and what its items add up to. The counts +// partition ItemCount: archived beats completed, so an archived item is what +// ItemCount holds beyond OpenCount and CompletedCount. +type ProjectWithItemCount struct { + Project + ItemCount int `json:"item_count"` + OpenCount int `json:"open_count"` + CompletedCount int `json:"completed_count"` + Repos []string `json:"repos"` +} + +// NewProjectWithItemCount is the wire form of a counted project. +func NewProjectWithItemCount(p model.ProjectWithItemCount) ProjectWithItemCount { + return ProjectWithItemCount{ + Project: NewProject(p.Project), + ItemCount: p.ItemCount, + OpenCount: p.OpenCount, + CompletedCount: p.CompletedCount, + Repos: nonNil(p.Repos), + } +} + +// ProjectItemMembership is where an item sits in one of its projects. +type ProjectItemMembership struct { + ProjectID string `json:"project_id"` + Position int `json:"position"` +} + +// ProjectItemTask is one task on an item. +type ProjectItemTask struct { + ID string `json:"id"` + ItemID string `json:"item_id"` + Title string `json:"title"` + Completed bool `json:"completed"` + CompletedAt *Time `json:"completed_at"` + Position int `json:"position"` + CreatedAt Time `json:"created_at"` +} + +// NewProjectItemTask is the wire form of a task. +func NewProjectItemTask(t model.ProjectItemTask) ProjectItemTask { + return ProjectItemTask{ + ID: t.ID, + ItemID: t.ItemID, + Title: t.Title, + Completed: t.Completed, + CompletedAt: optionalTime(t.CompletedAt), + Position: t.Position, + CreatedAt: Time{t.CreatedAt}, + } +} + +// ProjectItem is an item with what is filed against it, the shape a list returns, +// so a client reconciling the whole list needs no request per item. +type ProjectItem struct { + ID string `json:"id"` + Number *int `json:"number"` + Title string `json:"title"` + Notes *string `json:"notes"` + Repo *string `json:"repo"` + Completed bool `json:"completed"` + CompletedAt *Time `json:"completed_at"` + Archived bool `json:"archived"` + Status string `json:"status"` + CreatedAt Time `json:"created_at"` + UpdatedAt Time `json:"updated_at"` + Projects []Project `json:"projects"` + Memberships []ProjectItemMembership `json:"memberships"` + DependencyIDs []string `json:"dependency_ids"` + Tasks []ProjectItemTask `json:"tasks"` +} + +// NewProjectItem is the wire form of an item with what is filed against it. +func NewProjectItem(d model.ProjectItemDetail) ProjectItem { + return ProjectItem{ + ID: d.ID, + Number: d.Number, + Title: d.Title, + Notes: d.Notes, + Repo: d.Repo, + Completed: d.Completed, + CompletedAt: optionalTime(d.CompletedAt), + Archived: d.Archived, + Status: d.Status(), + CreatedAt: Time{d.CreatedAt}, + UpdatedAt: Time{d.UpdatedAt}, + Projects: Map(d.Projects, NewProject), + Memberships: Map(d.Memberships, newProjectItemMembership), + DependencyIDs: nonNil(d.DependencyIDs), + Tasks: Map(d.Tasks, NewProjectItemTask), + } +} + +func newProjectItemMembership(m model.Membership) ProjectItemMembership { + return ProjectItemMembership{ProjectID: m.ProjectID, Position: m.Position} +} + +// ProjectItemSummary is enough of another item to print it beside this one without +// a request of its own. +type ProjectItemSummary struct { + ID string `json:"id"` + Number *int `json:"number"` + Title string `json:"title"` + Completed bool `json:"completed"` + Archived bool `json:"archived"` + Status string `json:"status"` +} + +func newProjectItemSummary(i model.ProjectItem) ProjectItemSummary { + return ProjectItemSummary{ID: i.ID, Number: i.Number, Title: i.Title, Completed: i.Completed, Archived: i.Archived, Status: i.Status()} +} + +// ProjectItemDetail is one item read alone. It names what the item waits on, and +// its tasks are their own route. +type ProjectItemDetail struct { + ID string `json:"id"` + Number *int `json:"number"` + Title string `json:"title"` + Notes *string `json:"notes"` + Repo *string `json:"repo"` + Completed bool `json:"completed"` + CompletedAt *Time `json:"completed_at"` + Archived bool `json:"archived"` + Status string `json:"status"` + CreatedAt Time `json:"created_at"` + UpdatedAt Time `json:"updated_at"` + Projects []Project `json:"projects"` + Memberships []ProjectItemMembership `json:"memberships"` + DependencyIDs []string `json:"dependency_ids"` + DependsOn []ProjectItemSummary `json:"depends_on"` +} + +// NewProjectItemDetail is the wire form of one item read alone. +func NewProjectItemDetail(d model.ProjectItemDetail) ProjectItemDetail { + return ProjectItemDetail{ + ID: d.ID, + Number: d.Number, + Title: d.Title, + Notes: d.Notes, + Repo: d.Repo, + Completed: d.Completed, + CompletedAt: optionalTime(d.CompletedAt), + Archived: d.Archived, + Status: d.Status(), + CreatedAt: Time{d.CreatedAt}, + UpdatedAt: Time{d.UpdatedAt}, + Projects: Map(d.Projects, NewProject), + Memberships: Map(d.Memberships, newProjectItemMembership), + DependencyIDs: nonNil(d.DependencyIDs), + DependsOn: Map(d.DependsOn, newProjectItemSummary), + } +} + +// ProjectItemInProject is an item as one project holds it. ProjectCount is how many +// projects hold it, which is what a client needs to refuse removing it from its +// last one before asking. +type ProjectItemInProject struct { + ID string `json:"id"` + Number *int `json:"number"` + Title string `json:"title"` + Notes *string `json:"notes"` + Repo *string `json:"repo"` + Completed bool `json:"completed"` + CompletedAt *Time `json:"completed_at"` + Archived bool `json:"archived"` + Status string `json:"status"` + CreatedAt Time `json:"created_at"` + UpdatedAt Time `json:"updated_at"` + Position int `json:"position"` + ProjectCount int `json:"project_count"` +} + +// NewProjectItemInProject is the wire form of an item as one project holds it. +func NewProjectItemInProject(i model.ProjectItemInProject) ProjectItemInProject { + return ProjectItemInProject{ + ID: i.ID, + Number: i.Number, + Title: i.Title, + Notes: i.Notes, + Repo: i.Repo, + Completed: i.Completed, + CompletedAt: optionalTime(i.CompletedAt), + Archived: i.Archived, + Status: i.Status(), + CreatedAt: Time{i.CreatedAt}, + UpdatedAt: Time{i.UpdatedAt}, + Position: i.Position, + ProjectCount: i.ProjectCount, + } +} + +// UndoResult says what an undo reversed. +type UndoResult struct { + Description string `json:"description"` + Action string `json:"action"` + EntityType string `json:"entity_type"` + EntityID string `json:"entity_id"` +} + +// NewUndoResult is the wire form of an undo. +func NewUndoResult(r model.UndoResult) UndoResult { + return UndoResult{Description: r.Description, Action: r.Action, EntityType: r.EntityType, EntityID: r.EntityID} +} + +// UndoState says whether anything is left to undo. +type UndoState struct { + CanUndo bool `json:"can_undo"` +} + +// ValidationError is one refused input, in the shape clients mark fields from. +type ValidationError struct { + Type string `json:"type"` + Loc []any `json:"loc"` + Msg string `json:"msg"` + Input any `json:"input"` + Ctx map[string]any `json:"ctx,omitempty"` +} + +// Error is every refusal. Message always says why in one string. Errors lists each +// refused input on a validation 422. +type Error struct { + Error string `json:"error"` + Message string `json:"message"` + Errors []ValidationError `json:"errors,omitempty"` +} + +// Probe answers /health and /ready. Reason says what is not ready, where something +// is not. +type Probe struct { + Status string `json:"status"` + Reason string `json:"reason,omitempty"` +} + +// Map converts each row, and answers an empty list rather than null for none. +func Map[From, To any](rows []From, convert func(From) To) []To { + out := make([]To, len(rows)) + for i, row := range rows { + out[i] = convert(row) + } + return out +} + +func nonNil(rows []string) []string { + if rows == nil { + return []string{} + } + return rows +} diff --git a/go.mod b/go.mod index 08ceb47..fe68cb8 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( charm.land/glamour/v2 v2.0.1 charm.land/lipgloss/v2 v2.0.6 github.com/charmbracelet/x/ansi v0.11.9 + github.com/datapointchris/goapiauth v0.2.0 github.com/datapointchris/goclikit v0.5.3 github.com/datapointchris/goselfupdate v0.11.0 github.com/datapointchris/tuidrive v0.1.0 @@ -17,6 +18,7 @@ require ( github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.11.0 github.com/pressly/goose/v3 v3.26.0 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/testcontainers/testcontainers-go v0.44.0 @@ -72,6 +74,7 @@ require ( github.com/felixge/httpsnoop v1.1.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-fonts/dejavu v0.3.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.6 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect diff --git a/go.sum b/go.sum index a70948b..b83663c 100644 --- a/go.sum +++ b/go.sum @@ -81,6 +81,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws= github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I= +github.com/datapointchris/goapiauth v0.2.0 h1:Bqo+F5X70YnmRgWNIpqohFT5brScxt82888iqPS3a/U= +github.com/datapointchris/goapiauth v0.2.0/go.mod h1:R5j+QfvEqu0mTwap4nz13fq5RRuXwKGUnAH6VvfhLmQ= github.com/datapointchris/goclikit v0.5.3 h1:6ec9nuswLXeIqZLa0YVEQeqsCnrjkqF/3gDtCBSowNQ= github.com/datapointchris/goclikit v0.5.3/go.mod h1:hEbplQyvrWq5m5UepXVNKfv8jvvlmQshNMrWdoHBb6Y= github.com/datapointchris/goselfupdate v0.11.0 h1:aUJ27Cm9ZNGMfC7+kKChBsnm8offBE99wwUa10lFo2w= @@ -112,6 +114,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-fonts/dejavu v0.3.4 h1:Qqyx9IOs5CQFxyWTdvddeWzrX0VNwUAvbmAzL0fpjbc= github.com/go-fonts/dejavu v0.3.4/go.mod h1:D1z0DglIz+lmpeNYMYlxW4r22IhcdOYnt+R3PShU/Kg= +github.com/go-jose/go-jose/v4 v4.1.6 h1:sNRkftxFJxwsILiQukqp5z83QBSmntVwmgRY7AqBDO4= +github.com/go-jose/go-jose/v4 v4.1.6/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -238,6 +242,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= diff --git a/model/errors.go b/model/errors.go index e76573b..cb20066 100644 --- a/model/errors.go +++ b/model/errors.go @@ -35,11 +35,13 @@ var ( // Refusal is a rule saying no, with the sentence a person reads. Kind is the // sentinel a caller matches, so errors.Is sees through it to one of the errors -// above. Field names the input field at fault, where one input is. +// above. Field names the input field at fault, where one input is, and Input holds +// what it was sent. type Refusal struct { Kind error Message string Field string + Input any } func (r *Refusal) Error() string { return r.Message } diff --git a/service/service.go b/service/service.go index 4ee6bf2..9cec637 100644 --- a/service/service.go +++ b/service/service.go @@ -134,6 +134,7 @@ func refuseReservedName(name string) error { return &model.Refusal{ Kind: model.ErrReservedName, Field: "name", + Input: name, Message: fmt.Sprintf("cannot end in %s, which /projects//items reads as an item list", quoteName(itemListSegment)), } } diff --git a/store/postgres/store_test.go b/store/postgres/store_test.go new file mode 100644 index 0000000..7e0fe30 --- /dev/null +++ b/store/postgres/store_test.go @@ -0,0 +1,72 @@ +package postgres_test + +import ( + "context" + "testing" + + "github.com/datapointchris/projects/model" + "github.com/datapointchris/projects/service" + "github.com/datapointchris/projects/store" + "github.com/datapointchris/projects/store/postgres" + "github.com/datapointchris/projects/store/postgres/postgrestest" +) + +// A write's audit row names who wrote it, which only the transaction-local settings +// InTx binds can carry to the trigger, and a cascade is recorded though no statement +// named its rows. +func TestEveryWriteIsAuditedWithItsWriter(t *testing.T) { + pool := postgrestest.Pool(t) + ctx := store.WithWriter(context.Background(), store.Writer{Actor: "a-person", RequestID: "a-request", JobRunID: "a-run"}) + svc := service.New(postgres.New(pool), service.AssigningItemNumbers()).WithContext(ctx) + + project, err := svc.CreateProject(model.CreateProject{Name: "audited"}) + if err != nil { + t.Fatalf("creating project: %v", err) + } + item, err := svc.CreateItem(model.CreateProjectItem{Title: "an item", ProjectIDs: []string{project.ID}}) + if err != nil { + t.Fatalf("creating item: %v", err) + } + if err := svc.DeleteItem(item.ID); err != nil { + t.Fatalf("deleting item: %v", err) + } + + rows, err := pool.Query(ctx, ` + SELECT table_name, operation, actor, request_id, job_run_id + FROM audit_trail + WHERE table_name <> 'project_kinds' AND table_name <> 'project_statuses' + ORDER BY id`) + if err != nil { + t.Fatalf("reading the trail: %v", err) + } + defer rows.Close() + + seen := map[string]bool{} + for rows.Next() { + var table, operation, actor, requestID, jobRunID string + if err := rows.Scan(&table, &operation, &actor, &requestID, &jobRunID); err != nil { + t.Fatalf("scanning: %v", err) + } + if actor != "a-person" || requestID != "a-request" || jobRunID != "a-run" { + t.Errorf("%s %s recorded writer %q, request %q, run %q", operation, table, actor, requestID, jobRunID) + } + seen[table+" "+operation] = true + } + if err := rows.Err(); err != nil { + t.Fatalf("reading the trail: %v", err) + } + for _, want := range []string{ + "projects insert", + "project_items insert", + "project_item_memberships insert", + "project_items delete", + "project_item_memberships delete", + } { + if !seen[want] { + t.Errorf("the trail holds no %q row: %v", want, seen) + } + } + if seen["undo_log insert"] { + t.Error("undo_log is audited, which copies each write's whole row twice") + } +} From 589c21248f147990c5276011ba7f4bd5e8a8dd95 Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sat, 7 Nov 2026 14:37:02 -0500 Subject: [PATCH 03/12] feat: publish the API's image and manifests with each release A cluster runs the API from what a release publishes, so a release now holds an image and a kustomize artifact at the version go-semantic-release cut. One version names the TUI and the API, because a rule change in service moves both. Both artifacts are signed keylessly, and a deployment verifies them against this workflow's identity. deploy/migrate runs the schema migration as a Job before deploy/app rolls the pods. Migrating at startup would let two pods race, and a bad migration would crash-loop what is serving. CI renders and checks each manifest path as the release pins it, so a base that fails is never pushed. The README and the repo's instructions describe the API, and stop naming a sibling product where its name says nothing a reader needs. Agent-Name: claude-code-unattended Agent-Session: b6774e69-f156-4d1a-9713-9886fbbc39f7 Agent-Run: 01a1b74b-03f4-747a-9881-2a1e47809809 Worklog: 2401.1 --- .dockerignore | 14 +++ .github/workflows/release.yml | 163 ++++++++++++++++++++++++++++++ .github/workflows/validate.yml | 48 +++++++++ CLAUDE.md | 69 +++++++++++-- Dockerfile | 35 +++++++ README.md | 98 ++++++++++++++++-- deploy/app/api.yaml | 123 ++++++++++++++++++++++ deploy/app/kustomization.yaml | 19 ++++ deploy/migrate/kustomization.yaml | 16 +++ deploy/migrate/migration.yaml | 78 ++++++++++++++ release/install-tools | 85 ++++++++++++++++ release/pin-manifests | 72 +++++++++++++ 12 files changed, 801 insertions(+), 19 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 deploy/app/api.yaml create mode 100644 deploy/app/kustomization.yaml create mode 100644 deploy/migrate/kustomization.yaml create mode 100644 deploy/migrate/migration.yaml create mode 100755 release/install-tools create mode 100755 release/pin-manifests diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a170d42 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +* +!go.mod +!go.sum +!api/ +!backend/ +!config/ +!graph/ +!model/ +!repos/ +!service/ +!store/ +**/*_test.go +store/sqlite/ +store/postgres/postgrestest/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 27e183b..8d3e36b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,3 +32,166 @@ jobs: # the previous version and exits 0, so the installer reports the machine # converged while leaving the old binary in place. prime-module-proxy: true + + api-publish: + name: Publish the API image and its manifests + needs: release + # The version is empty on a push that cut no release. + if: needs.release.outputs.version != '' + runs-on: ubuntu-26.04 + # A stalled download fails the job here rather than at Actions' six-hour default. + timeout-minutes: 30 + permissions: + # The release's notes gain what a deployment pins. + contents: write + packages: write + # The OIDC token cosign trades for a signing certificate naming this workflow. + id-token: write + env: + VERSION: ${{ needs.release.outputs.version }} + IMAGE: ghcr.io/${{ github.repository }} + MANIFESTS: ghcr.io/${{ github.repository }}/manifests + # A named bash runs with pipefail. + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v7 + + # SHA-pinned because they are third-party, the major tag moves, and the + # login action is handed a token with packages: write. + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # A version already on ghcr is never rebuilt, so one tag names one image, + # and :latest moves only to the newest version. Only "not found" builds: + # any other error fails the step, so a transient one never republishes a + # version under a new digest. + - name: Decide what to publish + id: publish + run: | + newest=$(git ls-remote --tags --refs origin 'refs/tags/v*' | sed 's|.*refs/tags/v||' | sort -V | tail -n 1) + [ -n "$newest" ] || { echo "origin lists no v* tag, though this run tagged v$VERSION" >&2; exit 1; } + if [ "$newest" = "$VERSION" ]; then + echo "latest=true" >>"$GITHUB_OUTPUT" + else + echo "latest=false" >>"$GITHUB_OUTPUT" + fi + if answer=$(docker buildx imagetools inspect "$IMAGE:$VERSION" 2>&1 >/dev/null); then + echo "::notice::$IMAGE:$VERSION is already published" + echo "build=false" >>"$GITHUB_OUTPUT" + elif grep -q ': not found$' <<<"$answer"; then + echo "build=true" >>"$GITHUB_OUTPUT" + else + echo "$answer" >&2 + exit 1 + fi + + # The version for a deployment to pin, :sha- for a person to + # trace, and :latest for `docker run`. A manifest names the digest. + - name: Resolve the tags + if: steps.publish.outputs.build == 'true' + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ env.IMAGE }} + flavor: latest=false + tags: | + type=raw,value=${{ env.VERSION }} + type=sha,format=short + type=raw,value=latest,enable=${{ steps.publish.outputs.latest == 'true' }} + + # The provenance and SBOM attestations ride in the image's index, so the + # digest a manifest pins carries them. + - name: Build and push + if: steps.publish.outputs.build == 'true' + id: build + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: PROJECTS_VERSION=${{ env.VERSION }} + provenance: mode=max + sbom: true + cache-from: type=gha + cache-to: type=gha,mode=max + + # A re-run that built nothing reads the published version's digest. + - name: Name the image by its digest + id: image + env: + BUILT: ${{ steps.build.outputs.digest }} + run: | + pinned=$BUILT + if [ -z "$pinned" ]; then + pinned=$(docker buildx imagetools inspect "$IMAGE:$VERSION" --format '{{json .Manifest}}' | jq -r .digest) + fi + [[ $pinned == sha256:* ]] || { echo "no digest for $IMAGE:$VERSION: '$pinned'" >&2; exit 1; } + echo "ref=$IMAGE@$pinned" >>"$GITHUB_OUTPUT" + echo "Image \`$IMAGE@$pinned\`" >>"$GITHUB_STEP_SUMMARY" + + - name: Install flux, kustomize and cosign + run: | + release/install-tools "$RUNNER_TEMP/bin" flux kustomize cosign + echo "$RUNNER_TEMP/bin" >>"$GITHUB_PATH" + + # Pushed as manifests:. --reproducible makes a re-run send the + # same bytes for the same digest. The source and revision travel in the + # artifact, and Flux reports them beside what it applied. + - name: Push the manifests + id: manifests + env: + IMAGE_REF: ${{ steps.image.outputs.ref }} + run: | + work=$RUNNER_TEMP/manifests + release/pin-manifests --image "$IMAGE_REF" --version "$VERSION" --out "$work" + pushed=$(flux push artifact "oci://$MANIFESTS:$VERSION" --path="$work" \ + --source="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" --revision="$VERSION@sha1:$GITHUB_SHA" \ + --reproducible --output=json | jq -r .digest) + [[ $pushed == sha256:* ]] || { echo "flux push named no digest: '$pushed'" >&2; exit 1; } + echo "ref=$MANIFESTS@$pushed" >>"$GITHUB_OUTPUT" + echo "Manifests \`$MANIFESTS:$VERSION\` as \`$MANIFESTS@$pushed\`" >>"$GITHUB_STEP_SUMMARY" + + # Keyless, so the signing certificate names this workflow on this ref and + # no key exists to keep. A deployment verifies the artifact against that + # identity before applying it. The verify proves the pair the release + # notes print is the one a consumer can check. A re-run signs again, and + # either signature verifies. + - name: Sign the image and the manifests + env: + IMAGE_REF: ${{ steps.image.outputs.ref }} + MANIFESTS_REF: ${{ steps.manifests.outputs.ref }} + run: | + cosign sign --yes "$IMAGE_REF" + cosign sign --yes "$MANIFESTS_REF" + cosign verify --certificate-identity "$GITHUB_SERVER_URL/$GITHUB_WORKFLOW_REF" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + "$IMAGE_REF" "$MANIFESTS_REF" >/dev/null + + # A re-run finds the section already written, and leaves it. + - name: Name what was published in the release notes + env: + GH_TOKEN: ${{ github.token }} + IMAGE_REF: ${{ steps.image.outputs.ref }} + MANIFESTS_REF: ${{ steps.manifests.outputs.ref }} + run: | + notes=$RUNNER_TEMP/notes.md + gh release view "v$VERSION" --json body --jq .body >"$notes" + if ! grep -q '^## Published$' "$notes"; then + { + echo + echo '## Published' + echo + echo "- Image: \`$IMAGE_REF\`" + echo "- Manifests: \`$MANIFESTS_REF\`, with \`migrate/\` to apply before \`app/\`" + } >>"$notes" + gh release edit "v$VERSION" --notes-file "$notes" + fi diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index a8ef19d..82b3e1e 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -315,3 +315,51 @@ jobs: uvx --python 3.13 pre-commit run "$hook" "${scope[@]}" --show-diff-on-failure || status=1 done exit "$status" + +# > custom:after:all - Kubernetes manifests +# +# The release publishes deploy/ as the base a cluster applies, and calls this +# workflow as its gate, so a base that fails here is never pushed. + + deploy-manifests: + name: Kubernetes manifests + runs-on: ubuntu-26.04 + # A named bash runs with pipefail. + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v7 + + - name: Install kustomize, kubeconform and kube-linter + run: | + release/install-tools "$RUNNER_TEMP/bin" kustomize kubeconform kube-linter + echo "$RUNNER_TEMP/bin" >>"$GITHUB_PATH" + + # release/pin-manifests pins and labels every path as the release does, + # with the image named by a digest of zeros. kube-linter refuses an image + # with no tag or digest, which the committed base carries until the + # release pins it. + # + # kubeconform -strict also refuses a field the schema does not know. Its + # schemas are read at a fixed commit of kubeconform's own schema + # repository, for the Kubernetes release named. Given that commit's root, + # kubeconform appends the path of each kind's strict schema itself. + - name: Render and check each path + env: + KUBERNETES_VERSION: 1.36.5 + SCHEMAS: https://raw.githubusercontent.com/yannh/kubernetes-json-schema/f7d855e49c34b039c9ef2196444910275e31e09e + run: | + zeros=$(printf '0%.0s' {1..64}) + release/pin-manifests --image "ghcr.io/$GITHUB_REPOSITORY@sha256:$zeros" --version 0.0.0 \ + --out "$RUNNER_TEMP/manifests" >"$RUNNER_TEMP/paths" + status=0 + while IFS= read -r path; do + echo "::group::${path#"$RUNNER_TEMP"/manifests/}" + kustomize build "$path" >"$RUNNER_TEMP/rendered.yaml" + kubeconform -strict -summary -kubernetes-version "$KUBERNETES_VERSION" \ + -schema-location "$SCHEMAS" "$RUNNER_TEMP/rendered.yaml" || status=1 + kube-linter lint "$RUNNER_TEMP/rendered.yaml" || status=1 + echo "::endgroup::" + done <"$RUNNER_TEMP/paths" + exit "$status" diff --git a/CLAUDE.md b/CLAUDE.md index 8cc0b7c..f104bcd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,7 @@ # projects — Claude Code instructions The main package is at the module root — `go build .`, `go install github.com/datapointchris/projects@latest`. +`api/main.go` is the HTTP API, `projects-api`, which `go install` of the root does not build. ## How to run and test @@ -97,13 +98,16 @@ under a temporary directory, whatever it says, because `projects` with none open Ports and adapters at the storage seam, so a second store runs the same rules and a new rule lands once. ```text -tui, main.go - │ backend.Backend, which sync.SyncBackend also satisfies by wrapping the service - ▼ +tui, main.go api/routes + │ backend.Backend, which │ *service.Service, scoped to each request by WithContext + │ sync.SyncBackend also │ + │ satisfies by wrapping it │ + ▼ ▼ service.Service every rule │ store.Store model types only ▼ store/sqlite Open, migrations, queries.sql, generated +store/postgres the same, with goose migrations and an audit trigger ``` - **`store/sqlite` opens the database and holds no decision.** It owns the schema, the migrations, @@ -123,8 +127,8 @@ store/sqlite Open, migrations, queries.sql, generated there and its store's restore, and `TestEveryModelFieldSurvivesAnUndo` fails until both carry it. - **A store's statements carry what only a statement can do atomically, or what only it can sort.** `store/store.go` states each on its method: a new row's position, an item's `updated_at`, a restore's - handling of a row already there, and the order projects list in by status. Any second store has to - state them again. + handling of a row already there, and the order projects list in by status. Each store states them in + its own `queries.sql`. Two carve-outs are declared here rather than assumed: @@ -139,11 +143,11 @@ Two carve-outs are declared here rather than assumed: ## `projects` is a TUI; there is no item CLI `projects` is driven through the TUI. Agents and scripts reach the same projects and items through -ichrisbirch's own CLI against the API. The subcommands here are `sync`, `update`, `version` and -`completion`, and none of them reads or writes an item. A verb added here would be a second front -door onto rows ichrisbirch's CLI already owns, spelled independently of it. +the API's own clients. The subcommands here are `sync`, `update`, `version` and `completion`, and none +of them reads or writes an item. A verb added here would be a second front door onto rows those +clients already own, spelled independently of them. -An item shows its **number**, the handle ichrisbirch assigns and prints. The UUID is the sync key +An item shows its **number**, the handle the API assigns and answers with. The UUID is the sync key and stops there. The number is assigned by whichever database is the authority. With sync on that is the API, and @@ -276,7 +280,7 @@ below it and spares entities queued above it from the "deleted upstream" sweeps. an item typed mid-pull is deleted by the same pull and its queued create dropped with it. Do not restore `DeleteAllPendingSync` here. -**Absent is not empty, and that is what keeps deploy order between this repo and ichrisbirch +**Absent is not empty, and that is what keeps deploy order between this repo and its server irrelevant.** The item list embeds memberships, dependencies and tasks; when the server omits `dependency_ids`/`tasks`, the pull falls back to the per-item endpoints — decode into slices and branch on nil, never on empty, or the first pull against an older API deletes every local task and @@ -285,6 +289,51 @@ server did not return, so an active-only response would read "completed" as "del through the memberships. A server sending no project status is read as `active`. Do not drop either fallback just because the API has shipped. +## The API is `service` over Postgres, and a handler holds no rule + +`api/routes` decodes, calls `service` and encodes. A rule in a handler would have to land again for +SQLite. `service.Service` carries the request through `WithContext`, so a request's cancellation and +its `store.Writer` reach `InTx`. + +```bash +go test ./api/... # routes, schemas and scopes, on SQLite with a stand-in issuer +go test ./store/postgres/ # the audit trigger, on a real Postgres +PROJECTS_TEST_STORE=postgres go test ./service/ # every rule on Postgres +(cd store/postgres && go tool sqlc generate) # after editing queries.sql or a migration +``` + +`postgrestest` starts one `postgres:16-alpine` per test binary with testcontainers, and clones a template +per test. Set `TEST_DATABASE_URL` to use a running server. `-short` skips them, and the commit hook runs +`-short`, so CI is what runs them. + +- **`api/wire` is the contract, and `model` is not.** A client depends on the wire, so an answer changes + only by editing a `wire` struct and its file under `api/schemas`. `TestEveryAnswerMatchesItsSchema` + fails when the two differ. A new shape takes a file and a row in that test. +- **Every field is sent, an empty one as `null`, and times are UTC with a `Z`.** `wire.Time` prints six + fractional digits when there is a fraction, and none when there is not. Existing clients index these + by name. +- **A `PATCH` is read field by field**, by `requestBody`, because a struct cannot tell a field left out + from one sent as null. Left out keeps, null clears, and a column that cannot be empty refuses null. +- **A wildcard holds an escaped slash.** `{id}` matches one escaped segment, so a project name holding + `/` is reached as `%2F`. A catch-all would widen what `LimitServices` matches by pattern. +- **A scope lists registered patterns**, in `routes.ServiceScopes`. Renaming a route's wildcard takes it + out of the scope. +- **`InTx` takes `pg_advisory_xact_lock` first**, the analog of SQLite's immediate transaction, so what a + rule read still holds at commit. It costs one writer at a time, which one person's write rate never + feels. +- **Every write is audited by a row trigger**, into `audit_trail`, because a trigger also sees cascades. + `InTx` binds `projects.actor`, `projects.request_id` and `projects.job_run_id` with `set_config(..., + true)`, and the trigger reads them. A person is recorded by subject and a service by client id. + `undo_log` has no trigger: it writes on every write, and would copy each whole row the real tables + already audit. +- **Item numbers come from the `item_numbers` sequence**, through `AssigningItemNumbers`. A database + loaded from elsewhere needs the sequence set past its last number. +- **`.sqlfluffignore` keeps the SQLite hook off `store/postgres`**, and a second hook in + `.pre-commit-config.yaml` lints it in its own dialect, because the config holds one dialect. +- **A release publishes the image and manifests at the tag `go-semantic-release` cut**, in `release.yml`. + One version names both the TUI and the API, because a rule change in `service` moves both. A new + directory the binary imports joins `.dockerignore`'s allowlist, or the image build fails. + ## Never write the breaking-change trailer in a commit message Those two words anywhere in a message cut a major here, and a major on a Go module diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c7e18f4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 + +# The API's image. The TUI installs with `go install` and never reads this. +FROM golang:1.26.10-alpine AS builder + +WORKDIR /src + +# Dependencies first, in a layer of their own, so a source change reuses it. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +# The release names the version it builds. A build that names none reads as dev. +ARG PROJECTS_VERSION=dev +RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${PROJECTS_VERSION}" -o /projects-api ./api + + +FROM alpine:3.22 + +LABEL org.opencontainers.image.source="https://github.com/datapointchris/projects" \ + org.opencontainers.image.description="The projects API: projects, items, dependencies and tasks over HTTP on Postgres" \ + org.opencontainers.image.licenses="MIT" + +# The identity provider is reached over TLS, and log timestamps name a zone. +RUN apk add --no-cache ca-certificates tzdata + +# Owned by root, so the process cannot rewrite its own code, and the root filesystem can mount read-only. +COPY --from=builder /projects-api /usr/local/bin/projects-api + +# A numeric uid, so a cluster's runAsNonRoot can check it. +USER 10500:10500 +EXPOSE 8000 + +# Migrations run as their own step before a rollout: `projects-api migrate` in this image. +CMD ["projects-api"] diff --git a/README.md b/README.md index 527c6a2..510231d 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,12 @@ A terminal UI for organizing personal projects. It holds the projects, the items that make each one up, the dependencies between items, and the sub-tasks on each item. It reads and writes a local -SQLite database, so it works with no network at all. Sync to an -[ichrisbirch](https://github.com/datapointchris/ichrisbirch) API server is optional, and runs in the -background when it is on. +SQLite database, so it works with no network at all. Sync to an API server is optional, and runs in +the background when it is on. `projects` is the TUI and nothing more. Its subcommands are `sync`, `update`, `version` and `completion`, and none of them reads or writes an item. Scripts and agents reach the same projects -and items through ichrisbirch's own CLI, against the same API. +and items through the API. `projects-api`, in this repo, serves it on Postgres. ## Install @@ -103,8 +102,8 @@ The project pane opens on an `All` row, which shows every item grouped by projec project adds it to a selection. The item pane then shows the selected projects' items, grouped the same way. An item belongs to one project or to several. `A` adds an item to more than one, and `p` changes which projects hold it. Each item carries a title, notes, a checklist of sub-tasks, and the -items it waits on. The reader also names the repo the item is for. That tag is set through -ichrisbirch and arrives with sync. `projects` has no key that sets it. +items it waits on. The reader also names the repo the item is for. That tag is set through the API +and arrives with sync. `projects` has no key that sets it. `Enter` opens the reader, a full screen holding one item or one project. An item shows its fields, what blocks it, what it blocks, its tasks, and its notes rendered as markdown. A project shows its @@ -165,7 +164,7 @@ next round of them. ## An item's handle is its number, and its key is a UUID -An item shows its number, the same handle ichrisbirch prints. The number is not the primary key. +An item shows its number, the same handle the API answers with. The number is not the primary key. `projects` creates every id as a UUIDv7, so an item can be created offline and pushed without a round trip to claim one. @@ -275,8 +274,8 @@ Backend interface | +-- SyncBackend (wraps the service, optional) | - +-- Push: pending_sync queue -> HTTP to ichrisbirch - +-- Pull: HTTP from ichrisbirch -> upsert local SQLite + +-- Push: pending_sync queue -> HTTP to the API + +-- Pull: HTTP from the API -> upsert local SQLite ``` The data model is projects, items, the memberships joining them many to many, dependencies between @@ -286,3 +285,84 @@ items, and sub-tasks. the API the database is bound to. The update check keeps its last result under `$XDG_STATE_HOME/projects/`, which defaults to `~/.local/state/projects/`. The config file and the repo registry are read and never written. + +## The API — the same rules over HTTP, on Postgres + +`projects-api` serves projects, items, dependencies and tasks under `/api/v1`. It runs the rules the TUI +runs, over a Postgres store instead of SQLite, so a rule behaves the same in both. + +```text +projects-api + | + v +api/routes decode, call, encode + | + v +service.Service every rule, shared with the TUI + | + v +store/postgres pgx, goose migrations, a row trigger writing audit_trail +``` + +Every setting comes from the environment: + +| Variable | Default | | +| -------- | ------- | | +| `OIDC_ISSUER` | required | The provider whose RFC 9068 access tokens the API accepts | +| `PUBLIC_URL` | unset | The address clients reach the API at. Set, it serves RFC 9728 metadata naming the issuer | +| `POSTGRES_HOST` | required | | +| `POSTGRES_PORT` | `5432` | | +| `POSTGRES_USERNAME` | required | | +| `POSTGRES_PASSWORD` | required | | +| `POSTGRES_DB` | required | | +| `PORT` | `8000` | Where it listens | + +`projects-api migrate` brings the schema up to date and exits. It reads only the `POSTGRES_` settings. +With no argument, `projects-api` serves, and it never migrates on its own. Two pods starting together +would race, and a bad migration would crash-loop what is serving. + +```bash +docker run --rm --env-file settings.env ghcr.io/datapointchris/projects:latest projects-api migrate +docker run --rm --env-file settings.env -p 8000:8000 ghcr.io/datapointchris/projects:latest +``` + +`/health` answers for the process. `/ready` answers 503 while Postgres or the identity provider does +not. TERM stops new requests, lets the ones in flight finish for up to 20 seconds, and exits zero. Logs +are one JSON object per line on stdout, and each request's line names its caller and its +`X-Request-ID`. + +**A client's id says who it is.** A token from a client whose id starts `projects-cli-` acts as the person +who logged in, and reaches every route. One starting `projects-svc-` is a service. It reaches only what +its scopes list, and the one scope is `projects.items.read`: + +```text +GET /api/v1/project-items +GET /api/v1/project-items/blocked +GET /api/v1/project-items/search +GET /api/v1/project-items/{id} +GET /api/v1/project-items/{id}/blockers +GET /api/v1/project-items/{item_id}/tasks +GET /api/v1/projects/{id}/items +``` + +**`{id}` takes more than an id.** A project answers to its UUID or its name, and an item to its UUID or +its number. A name holding a slash is reached by escaping it, as `/api/v1/projects/web%2Fapi`. + +**Every field is always sent, an empty one as `null`.** In a `PATCH`, a field left out stays as it is, +and a field sent as `null` clears it. A field that cannot be empty refuses `null` by name. + +A refusal is `{"error": , "message": }`. A refused input adds `errors`, listing each +field with its `loc` and `msg`. [api/schemas](api/schemas) holds a JSON Schema for every answer, and +`api/wire` holds the Go structs. + +**Each release publishes the image and its manifests under one version.** `ghcr.io/datapointchris/projects` +is the image, and `ghcr.io/datapointchris/projects/manifests` is an OCI artifact holding `migrate/` and +`app/`. Apply `migrate/` and wait for its Job before applying `app/`. Both are signed keylessly through +Sigstore, and the release's notes name each by digest: + +```bash +cosign verify --certificate-identity https://github.com/datapointchris/projects/.github/workflows/release.yml@refs/heads/main \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com ghcr.io/datapointchris/projects:latest +``` + +`deploy/app/api.yaml` lists what a cluster supplies around those manifests. diff --git a/deploy/app/api.yaml b/deploy/app/api.yaml new file mode 100644 index 0000000..facabb4 --- /dev/null +++ b/deploy/app/api.yaml @@ -0,0 +1,123 @@ +# The projects API: a stateless Deployment behind a Service. +# +# The cluster supplies what this directory does not create: +# +# - A ConfigMap and a Secret, both named projects-settings, holding the settings +# README.md lists as environment variables. POSTGRES_PASSWORD goes in the +# Secret. The pod reads both, so any other setting may sit in either. +# - A route to the Service reaching /.well-known/oauth-protected-resource as +# well as /api/v1, since a client logging in reads the first to find the +# issuer. PUBLIC_URL names the address that route serves. +# - Each NetworkPolicy the pod reaches Postgres and the issuer through. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: projects-api + labels: + app.kubernetes.io/instance: projects-api + app.kubernetes.io/component: api +automountServiceAccountToken: false +--- +apiVersion: v1 +kind: Service +metadata: + name: projects-api + labels: + app.kubernetes.io/instance: projects-api + app.kubernetes.io/component: api +spec: + selector: + app.kubernetes.io/name: projects + app.kubernetes.io/instance: projects-api + ports: + - name: http + port: 8000 + targetPort: http +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: projects-api + labels: + app.kubernetes.io/instance: projects-api + app.kubernetes.io/component: api + annotations: + kubernetes.io/description: The projects HTTP API, serving /api/v1 to its clients over Postgres +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: projects + app.kubernetes.io/instance: projects-api + # The new pod is ready before the old one stops, so a release drops no requests. + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: + app.kubernetes.io/instance: projects-api + app.kubernetes.io/component: api + spec: + serviceAccountName: projects-api + automountServiceAccountToken: false + # Service links would put POSTGRES_PORT=tcp://:5432 in the environment + # wherever a Service named postgres shares the namespace, and the API reads + # POSTGRES_PORT as a number. + enableServiceLinks: false + # The image's numeric USER is the uid, so runAsNonRoot can check it. + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + # Covers the preStop sleep plus the API's 20 second drain, with room to spare. + # Raise it with drainTimeout in api/main.go. + terminationGracePeriodSeconds: 30 + containers: + - name: api + image: projects + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8000 + envFrom: + - configMapRef: + name: projects-settings + - secretRef: + name: projects-settings + # Endpoints drop a terminating pod a moment after TERM reaches it, so + # the sleep keeps it accepting until no proxy routes to it. + lifecycle: + preStop: + sleep: + seconds: 5 + # /ready answers 503 while Postgres or the identity provider does not, + # which takes the pod out of the Service without restarting it. + readinessProbe: + httpGet: + path: /ready + port: http + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: http + periodSeconds: 20 + failureThreshold: 3 + # A resident set of about 22 MiB after a couple of thousand requests over + # a few hundred rows. The room is for a list answered to several clients at + # once. The limit equals the request, so the pod never uses more memory + # than the scheduler set aside for it. + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] diff --git a/deploy/app/kustomization.yaml b/deploy/app/kustomization.yaml new file mode 100644 index 0000000..aad70c3 --- /dev/null +++ b/deploy/app/kustomization.yaml @@ -0,0 +1,19 @@ +# The projects API. A deployment applies this path only once migrate/ has +# finished against the same release, so the schema is never behind the code. +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - api.yaml +# What every object and pod here holds alike. The release adds +# app.kubernetes.io/version to its own copy. Each selector is written out in +# its object instead, because a workload's selector cannot change once applied. +labels: + - pairs: + app.kubernetes.io/name: projects + app.kubernetes.io/part-of: projects + app.kubernetes.io/managed-by: kustomize + includeTemplates: true +# The release sets newName and digest in its own copy of this directory before +# it pushes it, with `kustomize edit set image`. +images: + - name: projects diff --git a/deploy/migrate/kustomization.yaml b/deploy/migrate/kustomization.yaml new file mode 100644 index 0000000..7c57395 --- /dev/null +++ b/deploy/migrate/kustomization.yaml @@ -0,0 +1,16 @@ +# The schema migration, applied and finished before app/ rolls out the same +# release. Its own path, because one apply of both would start the API beside +# the Job rather than after it. +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - migration.yaml +# The same labels and image as app/, which the release sets the same way. +labels: + - pairs: + app.kubernetes.io/name: projects + app.kubernetes.io/part-of: projects + app.kubernetes.io/managed-by: kustomize + includeTemplates: true +images: + - name: projects diff --git a/deploy/migrate/migration.yaml b/deploy/migrate/migration.yaml new file mode 100644 index 0000000..e214986 --- /dev/null +++ b/deploy/migrate/migration.yaml @@ -0,0 +1,78 @@ +# `projects-api migrate` from the API's own image, against the Postgres the API +# uses. It reads the same projects-settings ConfigMap and Secret as app/. Each +# migration keeps the release before it working, so the old pods serve on the +# new schema while the new ones start. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: projects-migrate + labels: + app.kubernetes.io/instance: projects-migrate + app.kubernetes.io/component: migration +automountServiceAccountToken: false +--- +# A Job's pod template cannot change once created, so Flux's force annotation +# replaces the Job when a release changes its image, and that runs the next +# migration. It sets no ttlSecondsAfterFinished: Flux would recreate a Job the +# TTL deleted, and run it again. Applied without Flux, delete the finished Job +# before applying a new release. +apiVersion: batch/v1 +kind: Job +metadata: + name: projects-migrate + labels: + app.kubernetes.io/instance: projects-migrate + app.kubernetes.io/component: migration + annotations: + kustomize.toolkit.fluxcd.io/force: enabled + kubernetes.io/description: Migrates the projects Postgres schema to the release's newest before its API rolls out + ignore-check.kube-linter.io/job-ttl-seconds-after-finished: "Flux recreates a Job its TTL deleted, and would run the migration again" +spec: + # Retries cover a Postgres that is still starting. The command exits 1 for that + # and for a migration that fails, so no exit code is told apart. + backoffLimit: 4 + activeDeadlineSeconds: 600 + # A drain or a preemption spends no retry. + podFailurePolicy: + rules: + - action: Ignore + onPodConditions: + - type: DisruptionTarget + template: + metadata: + labels: + app.kubernetes.io/instance: projects-migrate + app.kubernetes.io/component: migration + spec: + restartPolicy: Never + serviceAccountName: projects-migrate + automountServiceAccountToken: false + # As in app/: a Service named postgres would otherwise set POSTGRES_PORT. + enableServiceLinks: false + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: migrate + image: projects + imagePullPolicy: IfNotPresent + command: [projects-api, migrate] + envFrom: + - configMapRef: + name: projects-settings + - secretRef: + name: projects-settings + # The schema's migrations hold DDL and no row moves. A data migration + # moving rows can take more, so the limit sits above the request. + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + memory: 128Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] diff --git a/release/install-tools b/release/install-tools new file mode 100755 index 0000000..f3b18d0 --- /dev/null +++ b/release/install-tools @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Installs the tools the release and the manifest check run, each at a pinned version and SHA-256. +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: install-tools ... + +Downloads each tool's linux/amd64 release into , checks it against +the SHA-256 pinned here, and leaves an executable named for the tool. The +tools are kustomize, kubeconform, kube-linter, flux and cosign. + +A bump edits a tool's version and its SHA-256 together. +EOF +} + +die() { + printf 'install-tools: %s\n' "$*" >&2 + exit 1 +} + +if [ $# -lt 2 ]; then + usage >&2 + exit 2 +fi +directory=$1 +shift + +# Sets version, sha256, url, and member: the file to take from a tarball, or +# empty where the asset is the binary itself. +pin() { + case $1 in + kustomize) + version=5.8.3 + sha256=cb9e31198d3f63b44848bd0afc4c8efc56e6cfef9c93a4a39a211940e6decd61 + url=https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv$version/kustomize_v${version}_linux_amd64.tar.gz + member=kustomize + ;; + kubeconform) + version=0.8.0 + sha256=9bc2bffbf71f261128533edaf912153948b7ff238f9a531ae6d34466ec287883 + url=https://github.com/yannh/kubeconform/releases/download/v$version/kubeconform-linux-amd64.tar.gz + member=kubeconform + ;; + # kube-linter publishes no checksum file. This is the digest its release's + # sigstore bundle signs, which Rekor's log records. + kube-linter) + version=0.8.3 + sha256=618d299a3e2839c8ca9d86fce0db617be0fba41f0fecbbbfb7fbf1c04299fae1 + url=https://github.com/stackrox/kube-linter/releases/download/v$version/kube-linter-linux + member= + ;; + flux) + version=2.9.6 + sha256=b4d22673e9246cbd628881f1a9ef3b090085dced291e42d804555cee8e8d42c5 + url=https://github.com/fluxcd/flux2/releases/download/v$version/flux_${version}_linux_amd64.tar.gz + member=flux + ;; + cosign) + version=3.1.4 + sha256=424e2c97d0b45fd7a6b608a87f090f35633f4c23ece5f092d48bba11aaf4bbfa + url=https://github.com/sigstore/cosign/releases/download/v$version/cosign-linux-amd64 + member= + ;; + *) die "no pin for $1" ;; + esac +} + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +mkdir -p "$directory" +for tool in "$@"; do + pin "$tool" + asset=$work/$tool.download + curl -fsSL --retry 3 -o "$asset" "$url" + printf '%s %s\n' "$sha256" "$asset" | sha256sum -c --quiet - \ + || die "$tool $version does not match its pinned SHA-256" + if [ -n "$member" ]; then + tar -xzf "$asset" -C "$work" "$member" + install -m 0755 "$work/$member" "$directory/$tool" + else + install -m 0755 "$asset" "$directory/$tool" + fi + echo "installed $tool $version" +done diff --git a/release/pin-manifests b/release/pin-manifests new file mode 100755 index 0000000..5c4580d --- /dev/null +++ b/release/pin-manifests @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Copies deploy/ with the image pinned and the version labeled in every kustomize path, and builds each. +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: pin-manifests --image --version --out + +Copies deploy/ into , which must be empty or absent. In each +kustomize path under it, names the image projects as --image, and labels every +object and pod template app.kubernetes.io/version: --version. Then builds each +path, so one that will not build fails here, and prints the paths, one a line. + +The release pushes as the manifests artifact. validate.yml checks +each path it prints. +EOF +} + +die() { + printf 'pin-manifests: %s\n' "$*" >&2 + exit 1 +} + +usage_error() { + printf 'pin-manifests: %s\n\n' "$1" >&2 + usage >&2 + exit 2 +} + +if [ $# -eq 0 ]; then + usage + exit 0 +fi + +image='' version='' out='' +while [ $# -gt 0 ]; do + case $1 in + --image | --version | --out) + [ $# -ge 2 ] || usage_error "$1 needs a value" + printf -v "${1#--}" '%s' "$2" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) usage_error "unknown argument $1" ;; + esac +done +for name in image version out; do + [ -n "${!name}" ] || usage_error "--$name is required" +done +[[ $image == *@sha256:* ]] || usage_error "--image names no digest: $image" + +deploy=$(cd "$(dirname "${BASH_SOURCE[0]}")/../deploy" && pwd) +mkdir -p "$out" +[ -z "$(find "$out" -mindepth 1 -print -quit)" ] || die "$out is not empty" +cp -R "$deploy/." "$out/" + +shopt -s nullglob +kustomizations=("$out"/*/kustomization.yaml) +[ ${#kustomizations[@]} -gt 0 ] || die "deploy/ holds no kustomize path" +for kustomization in "${kustomizations[@]}"; do + path=$(dirname "$kustomization") + ( + cd "$path" + kustomize edit set image "projects=$image" + kustomize edit add label --without-selector --include-templates "app.kubernetes.io/version:$version" + ) + kustomize build "$path" >/dev/null + echo "$path" +done From 09603253619695ae9a475a97ddc82adfa062d6d7 Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sat, 7 Nov 2026 14:45:44 -0500 Subject: [PATCH 04/12] fix: land a moved item where it is dropped, counting the archived rows A position counts every member of a project, and the TUI's list hides archived members. Sending the row's index in that list landed a drop one row too high for each archived member above it, and a move down one row did nothing. The drop now counts every member at or above the row it lands under. Agent-Name: claude-code-unattended Agent-Session: b6774e69-f156-4d1a-9713-9886fbbc39f7 Agent-Run: 01a1b74b-03f4-747a-9881-2a1e47809809 Worklog: 2401.1 --- tui/app.go | 30 +++++++++++++++++++++++++++--- tui/app_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/tui/app.go b/tui/app.go index d517d22..e7ffd94 100644 --- a/tui/app.go +++ b/tui/app.go @@ -3,6 +3,7 @@ package tui import ( "context" "fmt" + "slices" "strconv" "strings" "time" @@ -804,9 +805,28 @@ func searchNavigateCmd(b backend.Backend, itemID string) tea.Cmd { } } -func reorderItemCmd(b backend.Backend, itemID, projectID string, newPos int) tea.Cmd { +// reorderItemCmd lands an item just below above, or first with no row above it. A +// position counts every member of the project, so the archived rows this list hides +// count toward it. +func reorderItemCmd(b backend.Backend, itemID, projectID string, above *model.ProjectItemInProject) tea.Cmd { return func() tea.Msg { - if err := b.ReorderItem(itemID, projectID, newPos); err != nil { + position := 0 + if above != nil { + unarchived, err := b.ListItemsByProject(projectID) + if err != nil { + return errMsg{err} + } + archived, err := b.ListArchived(projectID) + if err != nil { + return errMsg{err} + } + for _, member := range slices.Concat(unarchived, archived) { + if member.ID != itemID && member.Position <= above.Position { + position++ + } + } + } + if err := b.ReorderItem(itemID, projectID, position); err != nil { return errMsg{err} } return itemReorderedMsg{} @@ -2025,7 +2045,11 @@ func (m *App) handleMoveKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.appMode = modeNormal item := m.items[m.moveItemPos] projectID := m.projects[m.projectCursor].ID - return m, reorderItemCmd(m.backend, item.ID, projectID, m.moveItemPos) + var above *model.ProjectItemInProject + if m.moveItemPos > 0 { + above = &m.items[m.moveItemPos-1] + } + return m, reorderItemCmd(m.backend, item.ID, projectID, above) case "esc": m.appMode = modeNormal diff --git a/tui/app_test.go b/tui/app_test.go index e6d6c29..8fda066 100644 --- a/tui/app_test.go +++ b/tui/app_test.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "net/http/httptest" + "slices" "strconv" "strings" goSync "sync" @@ -119,6 +120,37 @@ func pressKey(t *testing.T, app *App, key string) { send(t, app, runeKey([]rune(key)[0])) } +// A position counts every member of a project and this list hides the archived +// ones, so a drop has to count them or it lands that many rows too high. +func TestAMovedItemLandsWhereItIsDroppedBelowAnArchivedOne(t *testing.T) { + app := newTestApp(t, 80, 24, "archived", "bravo", "charlie", "delta") + pressKey(t, app, "x") + + visibleTitles := func() []string { + titles := make([]string, len(app.items)) + for i, item := range app.items { + titles[i] = item.Title + } + return titles + } + move := func(keys string, want ...string) { + t.Helper() + pressKey(t, app, "m") + for _, key := range keys { + pressKey(t, app, string(key)) + } + send(t, app, tea.KeyPressMsg{Code: tea.KeyEnter}) + if got := visibleTitles(); !slices.Equal(got, want) { + t.Errorf("after moving with %q the list reads %v, want %v", keys, got, want) + } + } + + move("j", "charlie", "bravo", "delta") + move("k", "bravo", "charlie", "delta") + move("G", "charlie", "delta", "bravo") + move("g", "bravo", "charlie", "delta") +} + // The add-task prompt is an overlay, so it shows in a window too short for the // list. An input that is not drawn still swallows every keystroke. func TestAddTaskPromptVisibleInShortWindow(t *testing.T) { From 9b69e86f838da95f3ddac16d3fcba8705a8cd8c0 Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sat, 7 Nov 2026 14:53:09 -0500 Subject: [PATCH 05/12] fix: raise the modules the Postgres test harness pulls past known flaws testcontainers brings in klauspost/compress, moby/go-archive, x/crypto and x/text at versions govulncheck flags, and the Vulnerabilities job fails on them. Agent-Name: claude-code-unattended Agent-Session: b6774e69-f156-4d1a-9713-9886fbbc39f7 Agent-Run: 01a1b74b-03f4-747a-9881-2a1e47809809 Worklog: 2401.1 --- go.mod | 17 +++++++---------- go.sum | 28 ++++++++++++++-------------- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/go.mod b/go.mod index fe68cb8..7a6ac42 100644 --- a/go.mod +++ b/go.mod @@ -27,10 +27,7 @@ require ( modernc.org/sqlite v1.54.0 ) -require ( - golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect - golang.org/x/mod v0.38.0 // indirect -) +require golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect require ( cel.dev/expr v0.24.0 // indirect @@ -86,7 +83,7 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect - github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/compress v1.20.2 // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect github.com/magiconair/properties v1.8.10 // indirect @@ -95,12 +92,12 @@ require ( github.com/mfridman/interpolate v0.0.2 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/go-archive v0.3.0 // indirect github.com/moby/moby/api v1.55.0 // indirect github.com/moby/moby/client v0.5.0 // indirect github.com/moby/patternmatcher v0.6.1 // indirect github.com/moby/sys/sequential v0.7.0 // indirect - github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/user v0.4.1 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect @@ -147,12 +144,12 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.54.0 // indirect + golang.org/x/crypto v0.56.0 // indirect golang.org/x/image v0.39.0 // indirect - golang.org/x/net v0.56.0 // indirect + golang.org/x/net v0.57.0 // indirect golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect google.golang.org/grpc v1.75.0 // indirect diff --git a/go.sum b/go.sum index b83663c..b12b0c8 100644 --- a/go.sum +++ b/go.sum @@ -158,8 +158,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= -github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= -github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.20.2 h1:v5tiZvm1KGLyg5d5oXdXe3lsSPrbEUiKoXGia//I7Qo= +github.com/klauspost/compress v1.20.2/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -187,8 +187,8 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 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.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= -github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/go-archive v0.3.0 h1:nos4BtzzUIqB406BgQnWGMI4qib9BZ8XUHU+ucv/n1c= +github.com/moby/go-archive v0.3.0/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE= github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= @@ -197,8 +197,8 @@ github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= -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/user v0.4.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0= +github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSDudF4Y= 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.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= @@ -333,8 +333,8 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= @@ -344,8 +344,8 @@ golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= @@ -359,13 +359,13 @@ golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= From 4ac74783aea24c400934be72bb60bb5215e6e03a Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sat, 7 Nov 2026 15:24:42 -0500 Subject: [PATCH 06/12] ci: lint the Dockerfile and build the image on every pull request The image first built in the release job, after the tag exists, so a package left out of the .dockerignore allowlist cost a version with binaries and no image. The repo now declares a docker stack, which brings hadolint. The Postgres SQL lint has a hook id of its own, because a second hook named sqlfluff-lint replaced the generated SQLite one on regeneration. Agent-Name: claude-code-unattended Agent-Session: b6774e69-f156-4d1a-9713-9886fbbc39f7 Agent-Run: 01a1b797-8518-7db1-982b-27aaa8245b56 Worklog: 2401.1 --- .github/workflows/validate.yml | 13 ++++++++++ .golangci.yml | 2 +- .pre-commit-config.yaml | 43 +++++++++++++++++++++++++++------- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 82b3e1e..e0ff056 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -301,6 +301,7 @@ jobs: go-build-repo-mod go-mod-tidy-repo go-test-repo-mod + hadolint jactionlint sqlfluff-lint ) @@ -363,3 +364,15 @@ jobs: echo "::endgroup::" done <"$RUNNER_TEMP/paths" exit "$status" + + # The release builds the image only after it has tagged, and .dockerignore is an + # allowlist of what the binary imports, so a missing directory would otherwise + # surface with the version already cut. + api-image: + name: API image + runs-on: ubuntu-26.04 + steps: + - uses: actions/checkout@v7 + + - name: Build the image without pushing it + run: docker build --build-arg PROJECTS_VERSION=0.0.0 . diff --git a/.golangci.yml b/.golangci.yml index 917f3ff..ae9bab9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -4,7 +4,7 @@ run: modules-download-mode: readonly linters: # A linter joins by name after the sweep that makes every Go module pass it, - # never as a preset. The last four catch a defect rather than a style. + # never as a preset. enable: - gocritic - misspell diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5e3c95c..31b4312 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: hooks: # --unsafe parses instead of safe_load'ing. Every mkdocs-material repo # carries `!!python/name:material.extensions.emoji.twemoji`, which - # safe_load can never construct — five repos fail this hook without it. + # safe_load can never construct — a repo carrying that tag fails this hook without it. # The alternative is excluding mkdocs.yml, which checks strictly less. - id: check-yaml args: [--unsafe] @@ -114,8 +114,8 @@ repos: # The guard distinguishes "this repo has no tests" from "this repo has tests # and bats is missing". Only the first is allowed to pass: a bare `bats # tests/` under language: system would be the second hook here to report - # success because its tool was absent. The first hid seven shellcheck - # findings behind a green run. + # success because its tool was absent. The first hid shellcheck findings + # behind a green run. # # The unset clears every variable git reads to find a repository. A commit # from a linked worktree exports GIT_DIR and GIT_INDEX_FILE into each hook, @@ -294,6 +294,28 @@ repos: pass_filenames: false stages: [pre-commit] +# generated:docker - Docker + # + # hadolint-py installs hadolint's own release binary through pip and checks + # its sha256, so the hook needs no Docker daemon and no system package. Its + # tag is the hadolint release it installs. + - repo: https://github.com/AleksaC/hadolint-py + rev: v2.14.0 + hooks: + # DL3018 is DL3008's apk counterpart — the same "pin every package + # version" rule, for an Alpine base rather than a Debian one. Ignoring one + # and enforcing the other only means the Debian images pass and the Alpine + # ones do not; pinning a package version in either breaks on the next base + # image bump. + # + # The exclude is not about rules. hadolint matches on the Dockerfile name, + # and BuildKit's per-file ignore list is named .dockerignore, + # so a repo using one gets a file whose name hadolint reads as a + # Dockerfile. It then parses an ignore list and fails on its first glob. + - id: hadolint + args: ["--ignore", "DL3008", "--ignore", "DL3018", "--ignore", "DL4006"] + exclude: '\.dockerignore$' + # generated:github-actions - GitHub Actions - repo: https://github.com/jdx/jactionlint rev: v1.8.2 @@ -312,11 +334,14 @@ repos: # > custom:after:sql - Postgres SQL lints in its own dialect # .sqlfluffignore keeps the generated SQLite hook off store/postgres. This one - # reads only that directory, in the dialect it is written in. - - repo: https://github.com/sqlfluff/sqlfluff - rev: 4.2.2 + # reads only that directory, in the dialect it is written in. It is a local hook so + # its id differs from the generated hook's, which a second hook of that id would + # replace. + - repo: local hooks: - - id: sqlfluff-lint - alias: sqlfluff-lint-postgres + - id: sqlfluff-lint-postgres + name: sqlfluff-lint (Postgres) + language: python + additional_dependencies: ["sqlfluff==4.2.2"] + entry: sqlfluff lint --dialect postgres --config .sqlfluff --disregard-sqlfluffignores --disable-progress-bar files: ^store/postgres/.*\.sql$ - args: [--dialect, "postgres", --config, .sqlfluff, --disregard-sqlfluffignores] From cb5219db10459b4092284c33e7b7ceb2be500aa4 Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sat, 7 Nov 2026 15:24:45 -0500 Subject: [PATCH 07/12] fix: land a drop below a tied row, and keep a task's id across a push A database the single-row reorder wrote holds ties, and a row tied with the one above a drop can sit on either side of it, so the drop now counts members in the order a store lists them. A task pushed with no id got a new one on the server, so a check made before the next pull named an id the server never held. The push dropped that 404, and the pull brought the task back unchecked. Agent-Name: claude-code-unattended Agent-Session: b6774e69-f156-4d1a-9713-9886fbbc39f7 Agent-Run: 01a1b797-8518-7db1-982b-27aaa8245b56 Worklog: 2401.1 --- sync/backend.go | 6 +++++- sync/engine_test.go | 35 +++++++++++++++++++++++++++++++++++ tui/app.go | 30 +++++++++++++++++++++++++----- tui/app_test.go | 13 +++++++++++++ 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/sync/backend.go b/sync/backend.go index 149bd96..d7b0a54 100644 --- a/sync/backend.go +++ b/sync/backend.go @@ -235,6 +235,7 @@ func (s *SyncBackend) CreateTask(itemID string, input model.CreateProjectItemTas return nil, err } _ = s.engine.QueueOp(OpCreateTask, result.ID, taskPayload{ + ID: result.ID, ItemID: itemID, Title: input.Title, }) @@ -434,7 +435,7 @@ func (s *SyncBackend) queueTaskUndo(result *model.UndoResult, dropped int64) { return } task := detail.Task - _ = s.engine.QueueOp(OpCreateTask, task.ID, taskPayload{ItemID: task.ItemID, Title: task.Title}) + _ = s.engine.QueueOp(OpCreateTask, task.ID, taskPayload{ID: task.ID, ItemID: task.ItemID, Title: task.Title}) if task.Completed { _ = s.engine.QueueOp(OpCompleteTask, task.ID, taskRefPayload{ItemID: task.ItemID}) } @@ -456,6 +457,7 @@ func (s *SyncBackend) queueTaskUndo(result *model.UndoResult, dropped int64) { func (s *SyncBackend) queueRestoredChildren(result *model.UndoResult) { for _, task := range result.RestoredTasks { _ = s.engine.QueueOp(OpCreateTask, task.ID, taskPayload{ + ID: task.ID, ItemID: task.ItemID, Title: task.Title, }) @@ -525,7 +527,9 @@ type depPayload struct { DependsOnID string `json:"depends_on_id"` } +// taskPayload carries the local id, because a later update names the task by it. type taskPayload struct { + ID string `json:"id"` ItemID string `json:"item_id"` Title string `json:"title"` } diff --git a/sync/engine_test.go b/sync/engine_test.go index 62bd589..6eb0357 100644 --- a/sync/engine_test.go +++ b/sync/engine_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" goSync "sync" "testing" "time" @@ -205,6 +206,40 @@ func TestSyncBackend_TaskOperations(t *testing.T) { } } +// A person adds a task and checks it off before the next pull. The check names the +// local id, so the server has to hold the task under it, or a 404 drops the check and +// the pull brings the task back unchecked. +func TestATaskCheckedOffBeforeThePullReachesTheServerUnderItsOwnID(t *testing.T) { + createdAs := make(chan string, 8) + recorder := &callRecorder{} + sb, _ := setupSync(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/tasks") { + var body struct { + ID string `json:"id"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + createdAs <- body.ID + } + recorder.handler()(w, r) + })) + + p, _ := sb.CreateProject(model.CreateProject{Name: "Tasks"}) + item, _ := sb.CreateItem(model.CreateProjectItem{Title: "WithTasks", ProjectIDs: []string{p.ID}}) + task, err := sb.CreateTask(item.ID, model.CreateProjectItemTask{Title: "Sub"}) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + + select { + case got := <-createdAs: + if got != task.ID { + t.Errorf("the server was asked to create task %q, and the TUI holds it as %q", got, task.ID) + } + case <-time.After(5 * time.Second): + t.Fatal("the task's create never reached the server") + } +} + func TestSyncBackend_UndoAfterPushDeletesRemotely(t *testing.T) { recorder := &callRecorder{} sb, _ := setupSync(t, recorder.handler()) diff --git a/tui/app.go b/tui/app.go index e7ffd94..9dd2279 100644 --- a/tui/app.go +++ b/tui/app.go @@ -1,6 +1,7 @@ package tui import ( + "cmp" "context" "fmt" "slices" @@ -820,11 +821,7 @@ func reorderItemCmd(b backend.Backend, itemID, projectID string, above *model.Pr if err != nil { return errMsg{err} } - for _, member := range slices.Concat(unarchived, archived) { - if member.ID != itemID && member.Position <= above.Position { - position++ - } - } + position = positionBelow(slices.Concat(unarchived, archived), itemID, above.ID) } if err := b.ReorderItem(itemID, projectID, position); err != nil { return errMsg{err} @@ -833,6 +830,29 @@ func reorderItemCmd(b backend.Backend, itemID, projectID string, above *model.Pr } } +// positionBelow counts the members ahead of a drop, in the order a store lists them, +// because a row tied on position with aboveID can sit on either side of it. +func positionBelow(members []model.ProjectItemInProject, itemID, aboveID string) int { + slices.SortStableFunc(members, func(a, b model.ProjectItemInProject) int { + return cmp.Or( + cmp.Compare(a.Position, b.Position), + a.CreatedAt.Compare(b.CreatedAt), + strings.Compare(a.ID, b.ID), + ) + }) + position := 0 + for _, member := range members { + if member.ID == itemID { + continue + } + position++ + if member.ID == aboveID { + break + } + } + return position +} + func reorderProjectCmd(b backend.Backend, projectID string, newPos int) tea.Cmd { return func() tea.Msg { if err := b.ReorderProject(projectID, newPos); err != nil { diff --git a/tui/app_test.go b/tui/app_test.go index 8fda066..291b865 100644 --- a/tui/app_test.go +++ b/tui/app_test.go @@ -151,6 +151,19 @@ func TestAMovedItemLandsWhereItIsDroppedBelowAnArchivedOne(t *testing.T) { move("g", "bravo", "charlie", "delta") } +// A database written by the single-row reorder holds ties, and a tied row can sit +// below the drop while sharing a position with the row above it. +func TestADropBelowATiedRowLandsUnderIt(t *testing.T) { + member := func(id string, position int) model.ProjectItemInProject { + return model.ProjectItemInProject{ProjectItem: model.ProjectItem{ID: id}, Position: position} + } + members := []model.ProjectItemInProject{member("a", 0), member("b", 1), member("c", 1), member("d", 2)} + + if got := positionBelow(members, "d", "b"); got != 2 { + t.Errorf("dropping d under b landed at %d, want 2", got) + } +} + // The add-task prompt is an overlay, so it shows in a window too short for the // list. An input that is not drawn still swallows every keystroke. func TestAddTaskPromptVisibleInShortWindow(t *testing.T) { From d32399e62929b7b82c4114e4e810df7d9d6b24a1 Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sat, 7 Nov 2026 15:29:55 -0500 Subject: [PATCH 08/12] fix: answer 503 when the database stalls, and refuse input a store cannot hold A database dropping packets held a request until the client gave up, and pgx waits as long as its context allows. Each request now has a deadline, a connection attempt has its own timeout, and a readiness check answers inside the probe's timeout. A request past its deadline answers 503 rather than 500. A position past int32 and a NUL in a string are both refused at decode, because each store fails on them differently and neither failure is a client's to read. A refusal with no row in statusOfRefusal answers 422 and logs the missing row, so a new refusal kind cannot arrive as a 500. Any other 500 names its request id. Each route pattern is a literal string, so a reader of the source sees the whole route where it is registered. Agent-Name: claude-code-unattended Agent-Session: b6774e69-f156-4d1a-9713-9886fbbc39f7 Agent-Run: 01a1b797-8518-7db1-982b-27aaa8245b56 Worklog: 2401.1 --- api/main.go | 2 +- api/routes/answers.go | 50 +++++++++++++------ api/routes/request_input.go | 45 +++++++++++++++-- api/routes/routes.go | 99 +++++++++++++++++++++++-------------- api/settings/settings.go | 12 +++-- deploy/app/api.yaml | 10 ++-- 6 files changed, 149 insertions(+), 69 deletions(-) diff --git a/api/main.go b/api/main.go index eeecd58..fbc8d32 100644 --- a/api/main.go +++ b/api/main.go @@ -97,7 +97,7 @@ func serve(ctx context.Context, log *slog.Logger) error { server := &http.Server{ Addr: net.JoinHostPort("", loaded.ListenPort), - Handler: requestlog.Middleware(log, protected), + Handler: requestlog.Middleware(log, routes.WithRequestDeadline(protected)), ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, diff --git a/api/routes/answers.go b/api/routes/answers.go index 3e80757..4041c4a 100644 --- a/api/routes/answers.go +++ b/api/routes/answers.go @@ -1,6 +1,7 @@ package routes import ( + "context" "encoding/json" "errors" "fmt" @@ -99,30 +100,49 @@ var statusOfRefusal = []struct { } // fail answers what a rule returned. A refusal says its own words, and one naming a -// field marks it the way a validation error does. Anything else is a fault of this -// server, whose detail stays in the log. +// field marks it the way a validation error does. A refusal whose kind has no row is +// still the caller's to fix, so it answers as one and logs the missing row. A request +// that ran out of time answers 503. Anything else is a fault of this server, and its +// answer names the request whose log line holds the detail. func (t *table) fail(w http.ResponseWriter, r *http.Request, err error) { for _, known := range statusOfRefusal { - if !errors.Is(err, known.kind) { - continue - } - var refusal *model.Refusal - if errors.As(err, &refusal) && refusal.Field != "" { - problem := wire.ValidationError{ - Type: "value_error", Loc: []any{"body", refusal.Field}, Msg: "Value error, " + refusal.Message, - Input: refusal.Input, Ctx: map[string]any{"error": map[string]any{}}, - } - refuseInvalid(w, r, []wire.ValidationError{problem}) + if errors.Is(err, known.kind) { + answerRefusal(w, r, known.status, err) return } - writeRefusal(w, r, known.status, err.Error(), nil) + } + var refusal *model.Refusal + switch { + case errors.As(err, &refusal): + t.logFailure(r, "a refusal's kind has no row in statusOfRefusal", err) + answerRefusal(w, r, http.StatusUnprocessableEntity, err) + case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled): + t.logFailure(r, "request ran out of time", err) + writeRefusal(w, r, http.StatusServiceUnavailable, "The database did not answer in time.", nil) + default: + t.logFailure(r, "request failed", err) + writeRefusal(w, r, http.StatusInternalServerError, "Internal server error in request "+requestlog.RequestID(r.Context()), nil) + } +} + +func answerRefusal(w http.ResponseWriter, r *http.Request, status int, err error) { + var refusal *model.Refusal + if errors.As(err, &refusal) && refusal.Field != "" { + problem := wire.ValidationError{ + Type: "value_error", Loc: []any{"body", refusal.Field}, Msg: "Value error, " + refusal.Message, + Input: refusal.Input, Ctx: map[string]any{"error": map[string]any{}}, + } + refuseInvalid(w, r, []wire.ValidationError{problem}) return } - t.log.LogAttrs(r.Context(), slog.LevelError, "request failed", + writeRefusal(w, r, status, err.Error(), nil) +} + +func (t *table) logFailure(r *http.Request, message string, err error) { + t.log.LogAttrs(r.Context(), slog.LevelError, message, slog.String("err", err.Error()), slog.String("method", r.Method), slog.String("path", r.URL.EscapedPath()), slog.String("request_id", requestlog.RequestID(r.Context())), ) - writeRefusal(w, r, http.StatusInternalServerError, fmt.Sprintf("Internal server error: %T", err), nil) } diff --git a/api/routes/request_input.go b/api/routes/request_input.go index 47cdf8e..df6394c 100644 --- a/api/routes/request_input.go +++ b/api/routes/request_input.go @@ -7,7 +7,9 @@ import ( "io" "net/http" "net/url" + "slices" "strconv" + "strings" "time" "github.com/google/uuid" @@ -28,6 +30,7 @@ const ( messageNoOffset = "Input should have timezone info" messageNotAnInstant = "Input should be a valid datetime or date" messageCannotBeNull = "Value error, cannot be null; omit the field to leave it unchanged" + messageHoldsNUL = "Input should not hold a NUL character" ) // requestBody reads a JSON object field by field. A partial update has to tell a @@ -67,12 +70,34 @@ func readRequestBody(r *http.Request) *requestBody { body.refuse("model_attributes_type", messageNotAnObject, body.whole, "body") return body } + if holdsNUL(body.whole) { + body.refuse("string_type", messageHoldsNUL, nil, "body") + return body + } if err := json.Unmarshal(raw, &body.fields); err != nil { body.refuse("model_attributes_type", messageNotAnObject, body.whole, "body") } return body } +// holdsNUL looks through a decoded body. Postgres text cannot hold a NUL and SQLite's +// can, so a body holding one is refused here, on either store. +func holdsNUL(value any) bool { + switch decoded := value.(type) { + case string: + return strings.ContainsRune(decoded, 0) + case []any: + return slices.ContainsFunc(decoded, holdsNUL) + case map[string]any: + for key, element := range decoded { + if strings.ContainsRune(key, 0) || holdsNUL(element) { + return true + } + } + } + return false +} + func (b *requestBody) refuse(kind, message string, input any, loc ...any) { b.problems = append(b.problems, wire.ValidationError{Type: kind, Loc: loc, Msg: message, Input: input}) } @@ -180,8 +205,18 @@ func (b *requestBody) partialBool(field string) *bool { return partial[bool](b, field, "bool_type", messageNotABoolean) } +// A position column holds 32 bits, so the integers here decode as int32. A larger +// number is refused rather than wrapped into some other position. +func widened(value *int32) *int { + if value == nil { + return nil + } + wide := int(*value) + return &wide +} + func (b *requestBody) partialInt(field string) *int { - return partial[int](b, field, "int_type", messageNotAnInteger) + return widened(partial[int32](b, field, "int_type", messageNotAnInteger)) } // intOr answers fallback for a field left out, and refuses null. @@ -190,8 +225,8 @@ func (b *requestBody) intOr(field string, fallback int) int { if !sent { return fallback } - if value := decodeField[int](b, field, "int_type", messageNotAnInteger, raw); value != nil { - return *value + if value := decodeField[int32](b, field, "int_type", messageNotAnInteger, raw); value != nil { + return int(*value) } return fallback } @@ -202,8 +237,8 @@ func (b *requestBody) requiredInt(field string) int { b.refuseMissing(field) return 0 } - if value := decodeField[int](b, field, "int_type", messageNotAnInteger, raw); value != nil { - return *value + if value := decodeField[int32](b, field, "int_type", messageNotAnInteger, raw); value != nil { + return int(*value) } return 0 } diff --git a/api/routes/routes.go b/api/routes/routes.go index 3e1cc93..970a2d3 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -8,14 +8,34 @@ import ( "net/http" "slices" "strings" + "time" "github.com/datapointchris/projects/api/wire" "github.com/datapointchris/projects/service" ) -// prefix is where every route but the probes and the login metadata lives. +// prefix is where every route but the probes and the login metadata lives. Each +// route spells it out, so a search or a route reader finds the whole path. const prefix = "/api/v1" +const ( + // requestDeadline sits under the server's WriteTimeout, so a database that stops + // answering costs a client a 503 it can read, not a closed connection. + requestDeadline = 10 * time.Second + // readinessCheckDeadline sits under the readiness probe's timeoutSeconds, so the + // probe reads the 503 rather than timing out on its own. + readinessCheckDeadline = 2 * time.Second +) + +// WithRequestDeadline bounds everything a request waits on, the write lock included. +func WithRequestDeadline(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), requestDeadline) + defer cancel() + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + // PublicPaths answer with no credential, because a cluster's probes hold none. var PublicPaths = []string{"/health", "/ready"} @@ -23,13 +43,13 @@ var PublicPaths = []string{"/health", "/ready"} // patterns. A person's client reaches every route, and a service reaches only these. var ServiceScopes = map[string][]string{ "projects.items.read": { - "GET " + prefix + "/project-items", - "GET " + prefix + "/project-items/blocked", - "GET " + prefix + "/project-items/search", - "GET " + prefix + "/project-items/{id}", - "GET " + prefix + "/project-items/{id}/blockers", - "GET " + prefix + "/project-items/{item_id}/tasks", - "GET " + prefix + "/projects/{id}/items", + "GET /api/v1/project-items", + "GET /api/v1/project-items/blocked", + "GET /api/v1/project-items/search", + "GET /api/v1/project-items/{id}", + "GET /api/v1/project-items/{id}/blockers", + "GET /api/v1/project-items/{item_id}/tasks", + "GET /api/v1/projects/{id}/items", }, } @@ -63,35 +83,35 @@ func New(svc *service.Service, log *slog.Logger, ready ...ReadinessCheck) *http. mux.HandleFunc("GET /ready", t.readiness) mux.HandleFunc("/", t.unrouted) - mux.HandleFunc("GET "+prefix+"/projects", t.listProjects) - mux.HandleFunc("POST "+prefix+"/projects", t.createProject) - mux.HandleFunc("GET "+prefix+"/projects/{id}/items", t.listProjectItems) - mux.HandleFunc("GET "+prefix+"/projects/{id}", t.readProject) - mux.HandleFunc("PATCH "+prefix+"/projects/{id}", t.updateProject) - mux.HandleFunc("DELETE "+prefix+"/projects/{id}", t.deleteProject) - - mux.HandleFunc("GET "+prefix+"/project-items", t.listItems) - mux.HandleFunc("POST "+prefix+"/project-items", t.createItem) - mux.HandleFunc("GET "+prefix+"/project-items/blocked", t.listBlockedItems) - mux.HandleFunc("GET "+prefix+"/project-items/search", t.searchItems) - mux.HandleFunc("GET "+prefix+"/project-items/{id}", t.readItem) - mux.HandleFunc("PATCH "+prefix+"/project-items/{id}", t.updateItem) - mux.HandleFunc("DELETE "+prefix+"/project-items/{id}", t.deleteItem) - mux.HandleFunc("PATCH "+prefix+"/project-items/{id}/reorder", t.reorderItem) - mux.HandleFunc("GET "+prefix+"/project-items/{id}/projects", t.listItemProjects) - mux.HandleFunc("POST "+prefix+"/project-items/{id}/projects", t.addItemToProject) - mux.HandleFunc("DELETE "+prefix+"/project-items/{id}/projects/{project_id}", t.removeItemFromProject) - mux.HandleFunc("POST "+prefix+"/project-items/{id}/dependencies", t.addDependency) - mux.HandleFunc("DELETE "+prefix+"/project-items/{id}/dependencies/{dep_id}", t.removeDependency) - mux.HandleFunc("GET "+prefix+"/project-items/{id}/blockers", t.listBlockers) - - mux.HandleFunc("GET "+prefix+"/project-items/{item_id}/tasks", t.listTasks) - mux.HandleFunc("POST "+prefix+"/project-items/{item_id}/tasks", t.createTask) - mux.HandleFunc("PATCH "+prefix+"/project-items/{item_id}/tasks/{task_id}", t.updateTask) - mux.HandleFunc("DELETE "+prefix+"/project-items/{item_id}/tasks/{task_id}", t.deleteTask) - - mux.HandleFunc("POST "+prefix+"/undo", t.undo) - mux.HandleFunc("GET "+prefix+"/undo", t.undoState) + mux.HandleFunc("GET /api/v1/projects", t.listProjects) + mux.HandleFunc("POST /api/v1/projects", t.createProject) + mux.HandleFunc("GET /api/v1/projects/{id}/items", t.listProjectItems) + mux.HandleFunc("GET /api/v1/projects/{id}", t.readProject) + mux.HandleFunc("PATCH /api/v1/projects/{id}", t.updateProject) + mux.HandleFunc("DELETE /api/v1/projects/{id}", t.deleteProject) + + mux.HandleFunc("GET /api/v1/project-items", t.listItems) + mux.HandleFunc("POST /api/v1/project-items", t.createItem) + mux.HandleFunc("GET /api/v1/project-items/blocked", t.listBlockedItems) + mux.HandleFunc("GET /api/v1/project-items/search", t.searchItems) + mux.HandleFunc("GET /api/v1/project-items/{id}", t.readItem) + mux.HandleFunc("PATCH /api/v1/project-items/{id}", t.updateItem) + mux.HandleFunc("DELETE /api/v1/project-items/{id}", t.deleteItem) + mux.HandleFunc("PATCH /api/v1/project-items/{id}/reorder", t.reorderItem) + mux.HandleFunc("GET /api/v1/project-items/{id}/projects", t.listItemProjects) + mux.HandleFunc("POST /api/v1/project-items/{id}/projects", t.addItemToProject) + mux.HandleFunc("DELETE /api/v1/project-items/{id}/projects/{project_id}", t.removeItemFromProject) + mux.HandleFunc("POST /api/v1/project-items/{id}/dependencies", t.addDependency) + mux.HandleFunc("DELETE /api/v1/project-items/{id}/dependencies/{dep_id}", t.removeDependency) + mux.HandleFunc("GET /api/v1/project-items/{id}/blockers", t.listBlockers) + + mux.HandleFunc("GET /api/v1/project-items/{item_id}/tasks", t.listTasks) + mux.HandleFunc("POST /api/v1/project-items/{item_id}/tasks", t.createTask) + mux.HandleFunc("PATCH /api/v1/project-items/{item_id}/tasks/{task_id}", t.updateTask) + mux.HandleFunc("DELETE /api/v1/project-items/{item_id}/tasks/{task_id}", t.deleteTask) + + mux.HandleFunc("POST /api/v1/undo", t.undo) + mux.HandleFunc("GET /api/v1/undo", t.undoState) return mux } @@ -104,7 +124,10 @@ func (t *table) health(w http.ResponseWriter, _ *http.Request) { // Service without restarting it. func (t *table) readiness(w http.ResponseWriter, r *http.Request) { for _, dependency := range t.ready { - if err := dependency.Check(r.Context()); err != nil { + checking, cancel := context.WithTimeout(r.Context(), readinessCheckDeadline) + err := dependency.Check(checking) + cancel() + if err != nil { t.log.WarnContext(r.Context(), "not ready", "dependency", dependency.Name, "err", err) writeJSON(w, http.StatusServiceUnavailable, wire.Probe{Status: "unavailable", Reason: dependency.Name}) return diff --git a/api/settings/settings.go b/api/settings/settings.go index bafd997..a60d20e 100644 --- a/api/settings/settings.go +++ b/api/settings/settings.go @@ -24,13 +24,15 @@ type Postgres struct { Database string } -// URL is the connection string pgx and goose read. +// URL is the connection string pgx and goose read. A host that drops packets fails +// its dial in seconds rather than at the system's TCP timeout. func (p Postgres) URL() string { return (&url.URL{ - Scheme: "postgres", - User: url.UserPassword(p.Username, p.Password), - Host: net.JoinHostPort(p.Host, p.Port), - Path: "/" + p.Database, + Scheme: "postgres", + User: url.UserPassword(p.Username, p.Password), + Host: net.JoinHostPort(p.Host, p.Port), + Path: "/" + p.Database, + RawQuery: "connect_timeout=5", }).String() } diff --git a/deploy/app/api.yaml b/deploy/app/api.yaml index facabb4..6ac047e 100644 --- a/deploy/app/api.yaml +++ b/deploy/app/api.yaml @@ -72,8 +72,7 @@ spec: runAsNonRoot: true seccompProfile: type: RuntimeDefault - # Covers the preStop sleep plus the API's 20 second drain, with room to spare. - # Raise it with drainTimeout in api/main.go. + # Covers the preStop sleep plus drainTimeout in api/main.go, with room to spare. terminationGracePeriodSeconds: 30 containers: - name: api @@ -95,20 +94,21 @@ spec: seconds: 5 # /ready answers 503 while Postgres or the identity provider does not, # which takes the pod out of the Service without restarting it. + # timeoutSeconds outlasts readinessCheckDeadline in api/routes/routes.go. readinessProbe: httpGet: path: /ready port: http periodSeconds: 10 + timeoutSeconds: 3 livenessProbe: httpGet: path: /health port: http periodSeconds: 20 failureThreshold: 3 - # A resident set of about 22 MiB after a couple of thousand requests over - # a few hundred rows. The room is for a list answered to several clients at - # once. The limit equals the request, so the pod never uses more memory + # The room above the resident set is for a list answered to several clients + # at once. The limit equals the request, so the pod never uses more memory # than the scheduler set aside for it. resources: requests: From 7c955436d67e81637f290eb859cbf67647d2cc1d Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sat, 7 Nov 2026 15:30:10 -0500 Subject: [PATCH 09/12] refactor: give each read in service one owner, and collapse a project named twice Two reads of the same rows could disagree, because the TUI's reads and the API's each built their own answer. ListProjectsByStatus, ListItemsByProject, ListArchived, ListBlockedItems and Search now sit over the read the API answers from. CreateItem treats a project named twice as one membership, as a store's primary key would otherwise refuse it as a server fault. Agent-Name: claude-code-unattended Agent-Session: b6774e69-f156-4d1a-9713-9886fbbc39f7 Agent-Run: 01a1b797-8518-7db1-982b-27aaa8245b56 Worklog: 2401.1 --- service/listing.go | 23 +++++++++++++++-------- service/service.go | 42 ++++++++++++++---------------------------- 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/service/listing.go b/service/listing.go index ac0674a..24b37ce 100644 --- a/service/listing.go +++ b/service/listing.go @@ -211,21 +211,28 @@ func (s *Service) ListProjectItems(projectRef string, filter model.ItemFilter) ( // ListBlockedItems is ListBlocked narrowed to a repo, each item carrying what is // filed against it. func (s *Service) ListBlockedItems(repo *string) ([]model.ProjectItemDetail, error) { - ctx := s.ctx() - items, err := s.store.ListBlockedItems(ctx) + items, err := s.ListBlocked() if err != nil { - return nil, fmt.Errorf("listing blocked: %w", err) + return nil, err } - return s.withEverythingFiled(ctx, keepRepo(items, repo)) + return s.withEverythingFiled(s.ctx(), keepRepo(items, repo)) +} + +func (s *Service) matchingItems(ctx context.Context, query string) ([]model.ProjectItem, error) { + items, err := s.store.SearchItems(ctx, query) + if err != nil { + return nil, fmt.Errorf("searching items: %w", err) + } + return items, nil } // SearchItems matches archived items too, narrowed to a repo, each carrying what // is filed against it. func (s *Service) SearchItems(query string, repo *string) ([]model.ProjectItemDetail, error) { ctx := s.ctx() - items, err := s.store.SearchItems(ctx, query) + items, err := s.matchingItems(ctx, query) if err != nil { - return nil, fmt.Errorf("searching items: %w", err) + return nil, err } return s.withEverythingFiled(ctx, keepRepo(items, repo)) } @@ -324,8 +331,8 @@ func sortProjectsByPosition(projects []model.Project) { }) } -// refuseUnknownKind reads the lookup table rather than a constant, so adding a kind -// stays an insert. +// refuseUnknownKind reads the lookup table, whose foreign key would refuse the row +// anyway, so a client reads which kinds this store holds. func refuseUnknownKind(ctx context.Context, reader store.Operations, kind string) error { known, err := reader.ListProjectKinds(ctx) if err != nil { diff --git a/service/service.go b/service/service.go index 9cec637..9f76a6e 100644 --- a/service/service.go +++ b/service/service.go @@ -161,18 +161,12 @@ func refuseTakenName(ctx context.Context, tx store.Operations, name, excludingID return nil } -// ListProjectsByStatus takes a status name or model.StatusAll, in board order. +// ListProjectsByStatus is ListProjects in board order. func (s *Service) ListProjectsByStatus(status string) ([]model.ProjectWithItemCount, error) { - counted, _, err := s.projectsWithItemCounts(s.ctx()) + kept, err := s.ListProjects(model.ProjectFilter{Status: status}) if err != nil { return nil, err } - kept := make([]model.ProjectWithItemCount, 0, len(counted)) - for _, project := range counted { - if status == model.StatusAll || project.Status == status { - kept = append(kept, project) - } - } model.SortProjectsForBoard(kept) return kept, nil } @@ -462,17 +456,12 @@ func (s *Service) ListAllItemsIncludingArchived() ([]model.ProjectItem, error) { return items, nil } -// ListItemsByProject leaves out archived items, in project order. +// ListItemsByProject is ListProjectItems without the archived items, in project order. func (s *Service) ListItemsByProject(projectRef string) ([]model.ProjectItemInProject, error) { - ctx := s.ctx() - project, err := resolveProject(ctx, s.store, projectRef) + items, err := s.ListProjectItems(projectRef, model.ItemFilter{Status: model.StatusAll}) if err != nil { return nil, err } - items, err := s.store.ListItemsInProject(ctx, project.ID) - if err != nil { - return nil, fmt.Errorf("listing items by project: %w", err) - } return slices.DeleteFunc(items, func(item model.ProjectItemInProject) bool { return item.Archived }), nil } @@ -534,13 +523,16 @@ func (s *Service) CreateItem(input model.CreateProjectItem) (*model.ProjectItemD ctx := s.ctx() var itemID string err := s.store.InTx(ctx, func(tx store.Operations) error { - projects := make([]model.Project, len(input.ProjectIDs)) - for i, ref := range input.ProjectIDs { + // A project named twice, by id and by name, is one membership. + projects := make([]model.Project, 0, len(input.ProjectIDs)) + for _, ref := range input.ProjectIDs { project, err := resolveProject(ctx, tx, ref) if err != nil { return err } - projects[i] = project + if !slices.ContainsFunc(projects, func(held model.Project) bool { return held.ID == project.ID }) { + projects = append(projects, project) + } } var number *int @@ -1077,9 +1069,9 @@ func (s *Service) DeleteTask(itemRef, taskID string) error { // Search leaves out archived items, which SearchItems keeps. func (s *Service) Search(query string) ([]model.ProjectItem, error) { - items, err := s.store.SearchItems(s.ctx(), query) + items, err := s.matchingItems(s.ctx(), query) if err != nil { - return nil, fmt.Errorf("searching items: %w", err) + return nil, err } return slices.DeleteFunc(items, func(item model.ProjectItem) bool { return item.Archived }), nil } @@ -1094,18 +1086,12 @@ func (s *Service) ListBlocked() ([]model.ProjectItem, error) { return items, nil } -// ListArchived orders by most recently updated. +// ListArchived is ListProjectItems for the archived items, most recently updated first. func (s *Service) ListArchived(projectRef string) ([]model.ProjectItemInProject, error) { - ctx := s.ctx() - project, err := resolveProject(ctx, s.store, projectRef) + archived, err := s.ListProjectItems(projectRef, model.ItemFilter{Status: model.ItemStatusArchived}) if err != nil { return nil, err } - items, err := s.store.ListItemsInProject(ctx, project.ID) - if err != nil { - return nil, fmt.Errorf("listing archived: %w", err) - } - archived := slices.DeleteFunc(items, func(item model.ProjectItemInProject) bool { return !item.Archived }) slices.SortStableFunc(archived, func(a, b model.ProjectItemInProject) int { return b.UpdatedAt.Compare(a.UpdatedAt) }) return archived, nil } From 87fae1aecceac36f4a18ab7fd97ff761e1b01306 Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sat, 7 Nov 2026 15:30:29 -0500 Subject: [PATCH 10/12] feat: carry each write's trace into its audit row, and seed lookup rows from model An audit row names its actor and request, and now also the traceparent and baggage the request arrived with, so a row joins to the trace that wrote it. The request log line carries the traceparent too. Project kinds and statuses each have one list, in model. postgres.Migrate upserts them on every run, so a kind added to the list reaches a database with no new migration file. TestEachStoreHoldsTheKnownKindsAndStatuses holds both stores to it. TestEveryTableIsAuditedButWhatOptsOut fails when a new table brings no trigger. Agent-Name: claude-code-unattended Agent-Session: b6774e69-f156-4d1a-9713-9886fbbc39f7 Agent-Run: 01a1b797-8518-7db1-982b-27aaa8245b56 Worklog: 2401.1 --- api/authentication/authentication.go | 6 ++- api/requestlog/requestlog.go | 5 ++ model/project.go | 8 ++++ service/service_test.go | 22 +++++++++ store/postgres/generated/models.go | 20 ++++---- store/postgres/generated/queries.sql.go | 20 ++++++-- store/postgres/migrations/00001_projects.sql | 20 ++++---- store/postgres/open.go | 22 +++++++++ store/postgres/postgrestest/postgrestest.go | 3 +- store/postgres/queries.sql | 4 +- store/postgres/store.go | 8 ++-- store/postgres/store_test.go | 49 ++++++++++++++++---- store/sqlite/schema.sql | 4 +- store/store.go | 8 ++-- 14 files changed, 157 insertions(+), 42 deletions(-) diff --git a/api/authentication/authentication.go b/api/authentication/authentication.go index d1aee5f..16b3ec0 100644 --- a/api/authentication/authentication.go +++ b/api/authentication/authentication.go @@ -45,7 +45,11 @@ func Protect(routes *http.ServeMux, scopes map[string][]string, verifier goapiau func bindWriter(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - writer := store.Writer{RequestID: requestlog.RequestID(ctx)} + writer := store.Writer{ + RequestID: requestlog.RequestID(ctx), + Traceparent: r.Header.Get(requestlog.HeaderTraceparent), + Baggage: r.Header.Get("baggage"), + } if identity, ok := goapiauth.IdentityFrom(ctx); ok { writer.Actor = identity.Subject if identity.Service { diff --git a/api/requestlog/requestlog.go b/api/requestlog/requestlog.go index 6c63fde..d4c8604 100644 --- a/api/requestlog/requestlog.go +++ b/api/requestlog/requestlog.go @@ -15,6 +15,10 @@ import ( // traces through this API's logs and its audit trail. const HeaderRequestID = "X-Request-ID" +// HeaderTraceparent carries the W3C trace a request belongs to, which its log line +// and its audit rows keep, so either joins back to the trace. +const HeaderTraceparent = "traceparent" + // record is what inner layers learn about a request that the final line reports. type record struct { id string @@ -103,6 +107,7 @@ func Middleware(log *slog.Logger, next http.Handler) http.Handler { slog.Int("bytes", recorder.bytes), slog.Float64("duration_ms", float64(time.Since(started).Microseconds())/1000), slog.String("request_id", id), + slog.String("traceparent", r.Header.Get(HeaderTraceparent)), slog.String("actor", current.actor), slog.String("client_id", current.clientID), slog.Bool("service", current.service), diff --git a/model/project.go b/model/project.go index b1fea4a..6c845ee 100644 --- a/model/project.go +++ b/model/project.go @@ -53,6 +53,14 @@ type Project struct { CreatedAt time.Time `json:"created_at"` } +// ProjectKinds and ProjectStatuses are the rows each store's lookup table holds. A new +// status also takes a decision in ProjectIsTerminal and SortProjectsForBoard, whose +// defaults otherwise treat it as open and as closed. +var ( + ProjectKinds = []string{"build", "chore", "life"} + ProjectStatuses = []string{StatusActive, StatusSomeday, StatusCompleted, StatusDropped} +) + // ProjectIsTerminal reports whether a status closes the project it is on. func ProjectIsTerminal(status string) bool { return status == StatusCompleted || status == StatusDropped diff --git a/service/service_test.go b/service/service_test.go index 15b7a87..8096cd4 100644 --- a/service/service_test.go +++ b/service/service_test.go @@ -40,6 +40,28 @@ func newTestBackend(t *testing.T) *Service { return New(sqlite.New(database)) } +// Each store seeds its lookup rows its own way. A kind one holds and the other lacks +// is accepted on one and refused on the other. +func TestEachStoreHoldsTheKnownKindsAndStatuses(t *testing.T) { + b := newTestBackend(t) + ctx := context.Background() + + kinds, err := b.store.ListProjectKinds(ctx) + if err != nil { + t.Fatalf("listing kinds: %v", err) + } + statuses, err := b.store.ListProjectStatuses(ctx) + if err != nil { + t.Fatalf("listing statuses: %v", err) + } + if want := slices.Sorted(slices.Values(model.ProjectKinds)); !slices.Equal(kinds, want) { + t.Errorf("the store holds kinds %v, want %v", kinds, want) + } + if want := slices.Sorted(slices.Values(model.ProjectStatuses)); !slices.Equal(statuses, want) { + t.Errorf("the store holds statuses %v, want %v", statuses, want) + } +} + func mustCreateProject(t *testing.T, b *Service, name string) *model.Project { t.Helper() p, err := b.CreateProject(model.CreateProject{Name: name}) diff --git a/store/postgres/generated/models.go b/store/postgres/generated/models.go index 8aa693e..77ea32d 100644 --- a/store/postgres/generated/models.go +++ b/store/postgres/generated/models.go @@ -9,15 +9,17 @@ import ( ) type AuditTrail struct { - ID int64 - ChangedAt time.Time - TableName string - RecordID string - Operation string - Changes []byte - Actor *string - RequestID *string - JobRunID *string + ID int64 + ChangedAt time.Time + TableName string + RecordID string + Operation string + Changes []byte + Actor *string + RequestID *string + JobRunID *string + Traceparent *string + Baggage *string } type Project struct { diff --git a/store/postgres/generated/queries.sql.go b/store/postgres/generated/queries.sql.go index ccda925..2cbbcfb 100644 --- a/store/postgres/generated/queries.sql.go +++ b/store/postgres/generated/queries.sql.go @@ -45,18 +45,28 @@ const bindWriter = `-- name: BindWriter :exec SELECT set_config('projects.actor', $1::text, true), set_config('projects.request_id', $2::text, true), - set_config('projects.job_run_id', $3::text, true) + set_config('projects.job_run_id', $3::text, true), + set_config('projects.traceparent', $4::text, true), + set_config('projects.baggage', $5::text, true) ` type BindWriterParams struct { - Actor string - RequestID string - JobRunID string + Actor string + RequestID string + JobRunID string + Traceparent string + Baggage string } // The third argument makes each setting end with this transaction. func (q *Queries) BindWriter(ctx context.Context, arg BindWriterParams) error { - _, err := q.db.Exec(ctx, bindWriter, arg.Actor, arg.RequestID, arg.JobRunID) + _, err := q.db.Exec(ctx, bindWriter, + arg.Actor, + arg.RequestID, + arg.JobRunID, + arg.Traceparent, + arg.Baggage, + ) return err } diff --git a/store/postgres/migrations/00001_projects.sql b/store/postgres/migrations/00001_projects.sql index 86d11a5..240c029 100644 --- a/store/postgres/migrations/00001_projects.sql +++ b/store/postgres/migrations/00001_projects.sql @@ -1,16 +1,14 @@ -- +goose Up +-- Lookup tables. postgres.Migrate upserts their rows from model.ProjectKinds and +-- model.ProjectStatuses on every run, so a new kind or status needs no migration. CREATE TABLE project_kinds ( name text PRIMARY KEY ); -INSERT INTO project_kinds (name) VALUES ('build'), ('chore'), ('life'); - CREATE TABLE project_statuses ( name text PRIMARY KEY ); -INSERT INTO project_statuses (name) VALUES ('active'), ('someday'), ('completed'), ('dropped'); - CREATE TABLE projects ( id uuid CONSTRAINT pk_projects PRIMARY KEY, name text NOT NULL, @@ -107,8 +105,9 @@ CREATE TABLE undo_log ( created_at timestamptz NOT NULL DEFAULT now() ); --- Every insert, update and delete to a row of an audited table leaves one entry --- here, whatever wrote it: a statement, a cascade, or a person at a SQL prompt. +-- Every insert, delete and changing update to a row of an audited table leaves one +-- entry here, whatever wrote it: a statement, a cascade, or a person at a SQL prompt. +-- traceparent and baggage join an entry to the trace of the request that wrote it. CREATE TABLE audit_trail ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, changed_at timestamptz NOT NULL DEFAULT now(), @@ -119,6 +118,8 @@ CREATE TABLE audit_trail ( actor text, request_id text, job_run_id text, + traceparent text, + baggage text, CONSTRAINT ck_audit_trail_operation_is_a_write CHECK (operation IN ('insert', 'update', 'delete')) ); @@ -160,7 +161,8 @@ BEGIN RETURN NULL; END IF; - INSERT INTO audit_trail (table_name, record_id, operation, changes, actor, request_id, job_run_id) + INSERT INTO audit_trail + (table_name, record_id, operation, changes, actor, request_id, job_run_id, traceparent, baggage) VALUES ( CASE WHEN TG_TABLE_SCHEMA = 'public' THEN TG_TABLE_NAME ELSE TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME END, coalesce(primary_key_text, ''), @@ -168,7 +170,9 @@ BEGIN written, nullif(current_setting('projects.actor', true), ''), nullif(current_setting('projects.request_id', true), ''), - nullif(current_setting('projects.job_run_id', true), '') + nullif(current_setting('projects.job_run_id', true), ''), + nullif(current_setting('projects.traceparent', true), ''), + nullif(current_setting('projects.baggage', true), '') ); RETURN NULL; END; diff --git a/store/postgres/open.go b/store/postgres/open.go index 6d16139..4bce7c0 100644 --- a/store/postgres/open.go +++ b/store/postgres/open.go @@ -11,6 +11,8 @@ import ( "github.com/jackc/pgx/v5/pgxpool" _ "github.com/jackc/pgx/v5/stdlib" // goose reads through database/sql "github.com/pressly/goose/v3" + + "github.com/datapointchris/projects/model" ) //go:embed migrations/*.sql @@ -37,6 +39,26 @@ func Migrate(ctx context.Context, url string) error { if _, err := provider.Up(ctx); err != nil { return fmt.Errorf("running migrations: %w", err) } + return upsertLookupRows(ctx, database) +} + +// upsertLookupRows runs on every migrate, so a kind or status added to model reaches +// a database that has already taken every migration. +func upsertLookupRows(ctx context.Context, database *sql.DB) error { + lookups := []struct { + statement string + names []string + }{ + {"INSERT INTO project_kinds (name) VALUES ($1) ON CONFLICT (name) DO NOTHING", model.ProjectKinds}, + {"INSERT INTO project_statuses (name) VALUES ($1) ON CONFLICT (name) DO NOTHING", model.ProjectStatuses}, + } + for _, lookup := range lookups { + for _, name := range lookup.names { + if _, err := database.ExecContext(ctx, lookup.statement, name); err != nil { + return fmt.Errorf("adding lookup row %q: %w", name, err) + } + } + } return nil } diff --git a/store/postgres/postgrestest/postgrestest.go b/store/postgres/postgrestest/postgrestest.go index 56de683..ed97845 100644 --- a/store/postgres/postgrestest/postgrestest.go +++ b/store/postgres/postgrestest/postgrestest.go @@ -1,7 +1,6 @@ // Package postgrestest gives a test a migrated database of its own. One container // serves the whole test binary, or the server TEST_DATABASE_URL names, and each test -// clones a migrated template, which costs a few milliseconds where migrating costs -// a hundred. +// clones a migrated template, which is cheaper than migrating. package postgrestest import ( diff --git a/store/postgres/queries.sql b/store/postgres/queries.sql index 24f93a4..abfcc04 100644 --- a/store/postgres/queries.sql +++ b/store/postgres/queries.sql @@ -8,7 +8,9 @@ SELECT pg_advisory_xact_lock(sqlc.arg(lock_key)::bigint); SELECT set_config('projects.actor', sqlc.arg(actor)::text, true), set_config('projects.request_id', sqlc.arg(request_id)::text, true), - set_config('projects.job_run_id', sqlc.arg(job_run_id)::text, true); + set_config('projects.job_run_id', sqlc.arg(job_run_id)::text, true), + set_config('projects.traceparent', sqlc.arg(traceparent)::text, true), + set_config('projects.baggage', sqlc.arg(baggage)::text, true); -- name: ListProjects :many SELECT * FROM projects ORDER BY position, name; diff --git a/store/postgres/store.go b/store/postgres/store.go index d511e60..afd58dd 100644 --- a/store/postgres/store.go +++ b/store/postgres/store.go @@ -68,9 +68,11 @@ func (s *Store) InTx(ctx context.Context, fn func(tx store.Operations) error) er } writer := store.WriterFrom(ctx) if err := q.BindWriter(ctx, generated.BindWriterParams{ - Actor: writer.Actor, - RequestID: writer.RequestID, - JobRunID: writer.JobRunID, + Actor: writer.Actor, + RequestID: writer.RequestID, + JobRunID: writer.JobRunID, + Traceparent: writer.Traceparent, + Baggage: writer.Baggage, }); err != nil { return fmt.Errorf("binding the writer: %w", err) } diff --git a/store/postgres/store_test.go b/store/postgres/store_test.go index 7e0fe30..73105a4 100644 --- a/store/postgres/store_test.go +++ b/store/postgres/store_test.go @@ -2,6 +2,7 @@ package postgres_test import ( "context" + "slices" "testing" "github.com/datapointchris/projects/model" @@ -16,7 +17,9 @@ import ( // named its rows. func TestEveryWriteIsAuditedWithItsWriter(t *testing.T) { pool := postgrestest.Pool(t) - ctx := store.WithWriter(context.Background(), store.Writer{Actor: "a-person", RequestID: "a-request", JobRunID: "a-run"}) + ctx := store.WithWriter(context.Background(), store.Writer{ + Actor: "a-person", RequestID: "a-request", JobRunID: "a-run", Traceparent: "a-trace", Baggage: "a-baggage", + }) svc := service.New(postgres.New(pool), service.AssigningItemNumbers()).WithContext(ctx) project, err := svc.CreateProject(model.CreateProject{Name: "audited"}) @@ -32,7 +35,7 @@ func TestEveryWriteIsAuditedWithItsWriter(t *testing.T) { } rows, err := pool.Query(ctx, ` - SELECT table_name, operation, actor, request_id, job_run_id + SELECT table_name, operation, actor, request_id, job_run_id, traceparent, baggage FROM audit_trail WHERE table_name <> 'project_kinds' AND table_name <> 'project_statuses' ORDER BY id`) @@ -43,12 +46,13 @@ func TestEveryWriteIsAuditedWithItsWriter(t *testing.T) { seen := map[string]bool{} for rows.Next() { - var table, operation, actor, requestID, jobRunID string - if err := rows.Scan(&table, &operation, &actor, &requestID, &jobRunID); err != nil { + var table, operation, actor, requestID, jobRunID, traceparent, baggage string + if err := rows.Scan(&table, &operation, &actor, &requestID, &jobRunID, &traceparent, &baggage); err != nil { t.Fatalf("scanning: %v", err) } - if actor != "a-person" || requestID != "a-request" || jobRunID != "a-run" { - t.Errorf("%s %s recorded writer %q, request %q, run %q", operation, table, actor, requestID, jobRunID) + if actor != "a-person" || requestID != "a-request" || jobRunID != "a-run" || traceparent != "a-trace" || baggage != "a-baggage" { + t.Errorf("%s %s recorded writer %q, request %q, run %q, trace %q, baggage %q", + operation, table, actor, requestID, jobRunID, traceparent, baggage) } seen[table+" "+operation] = true } @@ -66,7 +70,36 @@ func TestEveryWriteIsAuditedWithItsWriter(t *testing.T) { t.Errorf("the trail holds no %q row: %v", want, seen) } } - if seen["undo_log insert"] { - t.Error("undo_log is audited, which copies each write's whole row twice") +} + +// A table added to the migrations has to bring its trigger, or its writes go +// unrecorded with nothing to say so. +func TestEveryTableIsAuditedButWhatOptsOut(t *testing.T) { + pool := postgrestest.Pool(t) + optsOut := []string{"audit_trail", "goose_db_version", "undo_log"} + + rows, err := pool.Query(context.Background(), ` + SELECT c.relname, EXISTS ( + SELECT 1 FROM pg_trigger AS tr WHERE tr.tgrelid = c.oid AND tr.tgname = 'audit_trail' + ) + FROM pg_class AS c + JOIN pg_namespace AS n ON n.oid = c.relnamespace + WHERE c.relkind = 'r' AND n.nspname = 'public'`) + if err != nil { + t.Fatalf("listing tables: %v", err) + } + defer rows.Close() + for rows.Next() { + var table string + var audited bool + if err := rows.Scan(&table, &audited); err != nil { + t.Fatalf("scanning: %v", err) + } + if audited == slices.Contains(optsOut, table) { + t.Errorf("table %s: audited %v, and listed as opting out %v", table, audited, slices.Contains(optsOut, table)) + } + } + if err := rows.Err(); err != nil { + t.Fatalf("listing tables: %v", err) } } diff --git a/store/sqlite/schema.sql b/store/sqlite/schema.sql index 737789b..f9277f1 100644 --- a/store/sqlite/schema.sql +++ b/store/sqlite/schema.sql @@ -1,7 +1,7 @@ -- Schema definition for sqlc --- Lookup tables, so adding a kind or a status is an insert. lookup_rows.sql holds --- their rows. +-- Lookup tables. lookup_rows.sql holds their rows, and +-- TestEachStoreHoldsTheKnownKindsAndStatuses holds those to model's lists. CREATE TABLE project_kinds ( name TEXT PRIMARY KEY ); diff --git a/store/store.go b/store/store.go index 8fd4948..fad0844 100644 --- a/store/store.go +++ b/store/store.go @@ -30,9 +30,11 @@ type Store interface { // Writer names who a transaction writes for. A store that keeps an audit trail // records it beside each change, and one that keeps none ignores it. type Writer struct { - Actor string - RequestID string - JobRunID string + Actor string + RequestID string + JobRunID string + Traceparent string + Baggage string } type writerKey struct{} From d5a9474b7ec8a92932d95234d7b7d50f2297804a Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sat, 7 Nov 2026 15:30:58 -0500 Subject: [PATCH 11/12] test: delete assertions the schema and the routes already pin A partial index allowing many unnumbered items and the name constraint's other clauses pin a migration nothing else depends on. Comparing a refusal's message byte for byte fails on wording no client reads. Agent-Name: claude-code-unattended Agent-Session: b6774e69-f156-4d1a-9713-9886fbbc39f7 Agent-Run: 01a1b797-8518-7db1-982b-27aaa8245b56 Worklog: 2401.1 --- api/routes/routes_test.go | 8 +++----- store/sqlite/open_test.go | 38 -------------------------------------- 2 files changed, 3 insertions(+), 43 deletions(-) diff --git a/api/routes/routes_test.go b/api/routes/routes_test.go index e0637dd..1bc568d 100644 --- a/api/routes/routes_test.go +++ b/api/routes/routes_test.go @@ -275,17 +275,15 @@ func TestAPartialUpdateTellsNullFromLeftOut(t *testing.T) { status, answer := a.send(t, a.person, http.MethodPatch, path, map[string]any{"title": nil}) var refused struct { - Message string `json:"message"` - Errors []struct { + Errors []struct { Loc []any `json:"loc"` } `json:"errors"` } if err := json.Unmarshal(answer, &refused); err != nil { t.Fatalf("reading %s: %v", answer, err) } - const want = "title: Value error, cannot be null; omit the field to leave it unchanged" - if status != http.StatusUnprocessableEntity || refused.Message != want || len(refused.Errors) != 1 { - t.Errorf("null on a title answered %d: %s", status, answer) + if status != http.StatusUnprocessableEntity || len(refused.Errors) != 1 { + t.Fatalf("null on a title answered %d: %s", status, answer) } if refused.Errors[0].Loc[1] != "title" { t.Errorf("the refusal marks %v", refused.Errors[0].Loc) diff --git a/store/sqlite/open_test.go b/store/sqlite/open_test.go index ebb1b90..3b228f1 100644 --- a/store/sqlite/open_test.go +++ b/store/sqlite/open_test.go @@ -325,30 +325,6 @@ func TestMigrate_AddsNumberColumnToExistingDatabase(t *testing.T) { } } -// Several items can be waiting for a number at once, so the unique index has to -// tolerate more than one null. SQLite does; a UNIQUE column constraint applied -// to a backfilled column would not have been reachable by ALTER TABLE anyway. -func TestOpen_NumberIndexAllowsManyUnnumberedItems(t *testing.T) { - database, err := Open(":memory:") - if err != nil { - t.Fatalf("opening database: %v", err) - } - defer func() { _ = database.Close() }() - - for _, id := range []string{"a", "b", "c"} { - if _, err := database.Exec("INSERT INTO project_items (id, title) VALUES (?, ?)", id, id); err != nil { - t.Fatalf("inserting unnumbered item %s: %v", id, err) - } - } - - if _, err := database.Exec("INSERT INTO project_items (id, title, number) VALUES ('d', 'd', 7)"); err != nil { - t.Fatalf("inserting numbered item: %v", err) - } - if _, err := database.Exec("INSERT INTO project_items (id, title, number) VALUES ('e', 'e', 7)"); err == nil { - t.Error("a duplicate number was accepted — the handle has to name exactly one item") - } -} - // TestMigrate_ReplacesTheGlobalNameConstraint is the one migration that rebuilds // a table rather than adding a column, because SQLite attaches a column-level // UNIQUE as an index no DDL can drop. Two things have to survive it: the rows, @@ -432,23 +408,9 @@ func TestMigrate_ReplacesTheGlobalNameConstraint(t *testing.T) { t.Error("foreign keys left off after the rebuild — every later cascade would silently stop working") } - // The global constraint is gone and the partial one takes over. Created - // directly rather than by applying indexes.sql, which touches tables this - // fixture has no reason to build; that the file carries it is covered by - // TestOpen_ExistingDatabaseGainsTheUniquenessRules. - if _, err := database.Exec( - "CREATE UNIQUE INDEX idx_projects_name_active ON projects(name) WHERE status = 'active'", - ); err != nil { - t.Fatalf("creating the partial index: %v", err) - } if _, err := database.Exec( "INSERT INTO projects (id, name, status) VALUES ('p2', 'clisteno', 'completed')", ); err != nil { t.Errorf("a closed project must be allowed to share a name: %v", err) } - if _, err := database.Exec( - "INSERT INTO projects (id, name) VALUES ('p3', 'clisteno')", - ); err == nil { - t.Error("two ACTIVE projects sharing a name must still be refused") - } } From 3e8c3a7438517a8cae540d5aac8612dce7f5a37e Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sat, 7 Nov 2026 15:31:04 -0500 Subject: [PATCH 12/12] docs: state what the API bans, how it versions, and what moving a caller takes The API sets no repo-name ban, because a container holds no registry. /api/v2 is its version signal, and each schema says a field added is not a new version. A client holding neither prefix answers 401, so moving a caller takes a client of its own. Agent-Name: claude-code-unattended Agent-Session: b6774e69-f156-4d1a-9713-9886fbbc39f7 Agent-Run: 01a1b797-8518-7db1-982b-27aaa8245b56 Worklog: 2401.1 --- CLAUDE.md | 46 ++++++++++++------- README.md | 37 +++++++-------- api/schemas/error.schema.json | 2 +- api/schemas/probe.schema.json | 2 +- api/schemas/project-item-detail.schema.json | 2 +- .../project-item-in-project.schema.json | 2 +- api/schemas/project-item-task.schema.json | 2 +- api/schemas/project-item.schema.json | 2 +- .../project-with-item-count.schema.json | 2 +- api/schemas/project.schema.json | 2 +- api/schemas/undo-result.schema.json | 2 +- api/schemas/undo-state.schema.json | 2 +- api/wire/wire.go | 8 ++-- 13 files changed, 59 insertions(+), 52 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f104bcd..9fb1a12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,16 +126,17 @@ store/postgres the same, with goose migrations and an audit trigger whatever types a store uses for its own rows. A new column on a project, item or task joins its record there and its store's restore, and `TestEveryModelFieldSurvivesAnUndo` fails until both carry it. - **A store's statements carry what only a statement can do atomically, or what only it can sort.** - `store/store.go` states each on its method: a new row's position, an item's `updated_at`, a restore's - handling of a row already there, and the order projects list in by status. Each store states them in - its own `queries.sql`. + `store/store.go` states each on its method: a new row's position, an item's `updated_at`, and a + restore's handling of a row already there. Each store states them in its own `queries.sql`. Two carve-outs are declared here rather than assumed: - **`sync` reaches `store/sqlite/generated` directly.** A pull rewrites rows wholesale, with no undo entry and none of the rules, and the push queue and origin binding belong to no rule. Routing that through - `service` would give the rules a surface only the engine calls. It ends when the sync engine is deleted. - `generated` can then move under `store/sqlite/internal`, where the compiler enforces the seam. + `service` would give the rules a surface only the engine calls. A pull writes only the columns it has + always carried, so on a synced database `kind` and `completed_at` are what that database set, and neither + arrives from elsewhere. It ends when the sync engine is deleted. `generated` can then move under + `store/sqlite/internal`, where the compiler enforces the seam. - **Local mode links `service` directly.** A client normally depends on the wire contract and never on a server's types, because importing them couples deploy order. Local mode has no server, so that reason does not reach it. Anything on the HTTP path keeps its own lean structs. @@ -189,7 +190,8 @@ started this. A missing registry bans nothing. Enforcement is on the service (`RefusingRepoNames`), the one path every create -and rename takes. +and rename takes. It is an option `main.go` sets. `api/main.go` does not, because a +container holds no registry, so the API bans nothing, as a missing registry does. ## A project has a status, and it is not an `archived` flag @@ -302,16 +304,19 @@ PROJECTS_TEST_STORE=postgres go test ./service/ # every rule on Postgres (cd store/postgres && go tool sqlc generate) # after editing queries.sql or a migration ``` -`postgrestest` starts one `postgres:16-alpine` per test binary with testcontainers, and clones a template +`postgrestest` starts one Postgres container per test binary with testcontainers, and clones a template per test. Set `TEST_DATABASE_URL` to use a running server. `-short` skips them, and the commit hook runs `-short`, so CI is what runs them. - **`api/wire` is the contract, and `model` is not.** A client depends on the wire, so an answer changes only by editing a `wire` struct and its file under `api/schemas`. `TestEveryAnswerMatchesItsSchema` fails when the two differ. A new shape takes a file and a row in that test. -- **Every field is sent, an empty one as `null`, and times are UTC with a `Z`.** `wire.Time` prints six - fractional digits when there is a fraction, and none when there is not. Existing clients index these - by name. +- **A row's every field is always sent: an empty value as `null`, and an empty list as `[]`. Times are UTC + with a `Z`.** `wire.Time` prints six fractional digits when there is a fraction, and none when there is + not. Existing clients index these by name. +- **`/api/v2` is this API's version signal, and no answer carries `schema_version`.** That is an exception + to the rule that a shape names its version in the document, because a list answers a bare array, which + has no place for the field. A field added is not a new version, and a field reshaped or removed is. - **A `PATCH` is read field by field**, by `requestBody`, because a struct cannot tell a field left out from one sent as null. Left out keeps, null clears, and a column that cannot be empty refuses null. - **A wildcard holds an escaped slash.** `{id}` matches one escaped segment, so a project name holding @@ -320,19 +325,26 @@ per test. Set `TEST_DATABASE_URL` to use a running server. `-short` skips them, out of the scope. - **`InTx` takes `pg_advisory_xact_lock` first**, the analog of SQLite's immediate transaction, so what a rule read still holds at commit. It costs one writer at a time, which one person's write rate never - feels. + feels. `WithRequestDeadline` bounds that wait, and every other, so a database that stops answering + costs a client a 503. - **Every write is audited by a row trigger**, into `audit_trail`, because a trigger also sees cascades. - `InTx` binds `projects.actor`, `projects.request_id` and `projects.job_run_id` with `set_config(..., - true)`, and the trigger reads them. A person is recorded by subject and a service by client id. - `undo_log` has no trigger: it writes on every write, and would copy each whole row the real tables - already audit. + `InTx` binds `projects.actor`, `projects.request_id`, `projects.job_run_id`, `projects.traceparent` and + `projects.baggage` with `set_config(..., true)`, and the trigger reads them. A person is recorded by + subject and a service by client id. `undo_log` has no trigger: it writes on every write, and would copy + each whole row the real tables already audit. A new table brings its trigger, or + `TestEveryTableIsAuditedButWhatOptsOut` fails. +- **A kind or status is a row in `model.ProjectKinds` or `model.ProjectStatuses`**, and in SQLite's + `lookup_rows.sql`. `postgres.Migrate` upserts Postgres's from `model`, and + `TestEachStoreHoldsTheKnownKindsAndStatuses` holds both stores to it. - **Item numbers come from the `item_numbers` sequence**, through `AssigningItemNumbers`. A database - loaded from elsewhere needs the sequence set past its last number. + loaded from elsewhere needs the sequence set from the old sequence's own counter, because numbers it + already handed out can outrun the rows left. - **`.sqlfluffignore` keeps the SQLite hook off `store/postgres`**, and a second hook in `.pre-commit-config.yaml` lints it in its own dialect, because the config holds one dialect. - **A release publishes the image and manifests at the tag `go-semantic-release` cut**, in `release.yml`. One version names both the TUI and the API, because a rule change in `service` moves both. A new - directory the binary imports joins `.dockerignore`'s allowlist, or the image build fails. + directory the binary imports joins `.dockerignore`'s allowlist, or `validate.yml`'s image build fails + the pull request. ## Never write the breaking-change trailer in a commit message diff --git a/README.md b/README.md index 510231d..c2acbb3 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ the background when it is on. `projects` is the TUI and nothing more. Its subcommands are `sync`, `update`, `version` and `completion`, and none of them reads or writes an item. Scripts and agents reach the same projects -and items through the API. `projects-api`, in this repo, serves it on Postgres. +and items through the API the database is bound to. `projects-api`, in this repo, is one server of +that API, on Postgres. ## Install @@ -289,7 +290,8 @@ registry are read and never written. ## The API — the same rules over HTTP, on Postgres `projects-api` serves projects, items, dependencies and tasks under `/api/v1`. It runs the rules the TUI -runs, over a Postgres store instead of SQLite, so a rule behaves the same in both. +runs, over a Postgres store instead of SQLite, so a rule behaves the same in both. The one exception is +the ban on a project named for a repo, which reads a registry file this server does not hold. ```text projects-api @@ -317,9 +319,9 @@ Every setting comes from the environment: | `POSTGRES_DB` | required | | | `PORT` | `8000` | Where it listens | -`projects-api migrate` brings the schema up to date and exits. It reads only the `POSTGRES_` settings. -With no argument, `projects-api` serves, and it never migrates on its own. Two pods starting together -would race, and a bad migration would crash-loop what is serving. +`projects-api migrate` brings the schema up to date, adds any new kind or status, and exits. It reads +only the `POSTGRES_` settings. With no argument, `projects-api` serves, and it never migrates on its own. +Two pods starting together would race, and a bad migration would crash-loop what is serving. ```bash docker run --rm --env-file settings.env ghcr.io/datapointchris/projects:latest projects-api migrate @@ -327,28 +329,21 @@ docker run --rm --env-file settings.env -p 8000:8000 ghcr.io/datapointchris/proj ``` `/health` answers for the process. `/ready` answers 503 while Postgres or the identity provider does -not. TERM stops new requests, lets the ones in flight finish for up to 20 seconds, and exits zero. Logs -are one JSON object per line on stdout, and each request's line names its caller and its -`X-Request-ID`. +not. A request that outlasts `requestDeadline` in `api/routes/routes.go`, as when Postgres stops +answering, answers 503. TERM stops new requests and lets the ones in flight finish. It exits zero if +they finish inside `drainTimeout` in `api/main.go`, and non-zero if one outlasts it. Logs are one JSON +object per line on stdout, and each request's line names its caller, its `X-Request-ID` and its +`traceparent`. **A client's id says who it is.** A token from a client whose id starts `projects-cli-` acts as the person -who logged in, and reaches every route. One starting `projects-svc-` is a service. It reaches only what -its scopes list, and the one scope is `projects.items.read`: - -```text -GET /api/v1/project-items -GET /api/v1/project-items/blocked -GET /api/v1/project-items/search -GET /api/v1/project-items/{id} -GET /api/v1/project-items/{id}/blockers -GET /api/v1/project-items/{item_id}/tasks -GET /api/v1/projects/{id}/items -``` +who logged in, and reaches every route. One starting `projects-svc-` is a service, and reaches only what +its scopes list in `routes.ServiceScopes`. `projects.items.read` is the read scope. A client with neither +prefix answers 401, whatever it holds, so moving a caller onto this API takes a client of its own. **`{id}` takes more than an id.** A project answers to its UUID or its name, and an item to its UUID or its number. A name holding a slash is reached by escaping it, as `/api/v1/projects/web%2Fapi`. -**Every field is always sent, an empty one as `null`.** In a `PATCH`, a field left out stays as it is, +**A row's every field is always sent: an empty value as `null`, and an empty list as `[]`.** In a `PATCH`, a field left out stays as it is, and a field sent as `null` clears it. A field that cannot be empty refuses `null` by name. A refusal is `{"error": , "message": }`. A refused input adds `errors`, listing each diff --git a/api/schemas/error.schema.json b/api/schemas/error.schema.json index 81ab432..cd62168 100644 --- a/api/schemas/error.schema.json +++ b/api/schemas/error.schema.json @@ -3,7 +3,7 @@ "$id": "urn:projects:error:v1", "title": "error", "type": "object", - "description": "Every refusal. message always says why in one string. errors lists each refused input on a validation 422.", + "description": "Every refusal. message always says why in one string. errors lists each refused input on a validation 422. A field added is not a new version, and a field reshaped or removed is.", "required": [ "error", "message" diff --git a/api/schemas/probe.schema.json b/api/schemas/probe.schema.json index 8606faf..cc8d547 100644 --- a/api/schemas/probe.schema.json +++ b/api/schemas/probe.schema.json @@ -3,7 +3,7 @@ "$id": "urn:projects:probe:v1", "title": "probe", "type": "object", - "description": "What /health and /ready answer.", + "description": "What /health and /ready answer. A field added is not a new version, and a field reshaped or removed is.", "additionalProperties": false, "required": [ "status" diff --git a/api/schemas/project-item-detail.schema.json b/api/schemas/project-item-detail.schema.json index ef97b35..f8ddcd4 100644 --- a/api/schemas/project-item-detail.schema.json +++ b/api/schemas/project-item-detail.schema.json @@ -3,7 +3,7 @@ "$id": "urn:projects:project-item-detail:v1", "title": "project-item-detail", "type": "object", - "description": "One item read alone, naming what it waits on.", + "description": "One item read alone, naming what it waits on. A field added is not a new version, and a field reshaped or removed is.", "additionalProperties": false, "required": [ "id", diff --git a/api/schemas/project-item-in-project.schema.json b/api/schemas/project-item-in-project.schema.json index c2e42bf..1643d1a 100644 --- a/api/schemas/project-item-in-project.schema.json +++ b/api/schemas/project-item-in-project.schema.json @@ -3,7 +3,7 @@ "$id": "urn:projects:project-item-in-project:v1", "title": "project-item-in-project", "type": "object", - "description": "An item as one project holds it.", + "description": "An item as one project holds it. A field added is not a new version, and a field reshaped or removed is.", "additionalProperties": false, "required": [ "id", diff --git a/api/schemas/project-item-task.schema.json b/api/schemas/project-item-task.schema.json index 2dced59..bb762b6 100644 --- a/api/schemas/project-item-task.schema.json +++ b/api/schemas/project-item-task.schema.json @@ -3,7 +3,7 @@ "$id": "urn:projects:project-item-task:v1", "title": "project-item-task", "type": "object", - "description": "One task on an item.", + "description": "One task on an item. A field added is not a new version, and a field reshaped or removed is.", "additionalProperties": false, "required": [ "id", diff --git a/api/schemas/project-item.schema.json b/api/schemas/project-item.schema.json index 2d678c1..93c64ad 100644 --- a/api/schemas/project-item.schema.json +++ b/api/schemas/project-item.schema.json @@ -3,7 +3,7 @@ "$id": "urn:projects:project-item:v1", "title": "project-item", "type": "object", - "description": "An item with what is filed against it, as a list returns it.", + "description": "An item with what is filed against it, as a list returns it. A field added is not a new version, and a field reshaped or removed is.", "additionalProperties": false, "required": [ "id", diff --git a/api/schemas/project-with-item-count.schema.json b/api/schemas/project-with-item-count.schema.json index eb4107c..5d502c7 100644 --- a/api/schemas/project-with-item-count.schema.json +++ b/api/schemas/project-with-item-count.schema.json @@ -3,7 +3,7 @@ "$id": "urn:projects:project-with-item-count:v1", "title": "project-with-item-count", "type": "object", - "description": "A project and what its items add up to.", + "description": "A project and what its items add up to. A field added is not a new version, and a field reshaped or removed is.", "additionalProperties": false, "required": [ "id", diff --git a/api/schemas/project.schema.json b/api/schemas/project.schema.json index a5f5157..0a7b958 100644 --- a/api/schemas/project.schema.json +++ b/api/schemas/project.schema.json @@ -3,7 +3,7 @@ "$id": "urn:projects:project:v1", "title": "project", "type": "object", - "description": "One project.", + "description": "One project. A field added is not a new version, and a field reshaped or removed is.", "additionalProperties": false, "required": [ "id", diff --git a/api/schemas/undo-result.schema.json b/api/schemas/undo-result.schema.json index 61c8136..bde76dd 100644 --- a/api/schemas/undo-result.schema.json +++ b/api/schemas/undo-result.schema.json @@ -3,7 +3,7 @@ "$id": "urn:projects:undo-result:v1", "title": "undo-result", "type": "object", - "description": "What an undo reversed.", + "description": "What an undo reversed. A field added is not a new version, and a field reshaped or removed is.", "additionalProperties": false, "required": [ "description", diff --git a/api/schemas/undo-state.schema.json b/api/schemas/undo-state.schema.json index 5ff5be1..3e0cb86 100644 --- a/api/schemas/undo-state.schema.json +++ b/api/schemas/undo-state.schema.json @@ -3,7 +3,7 @@ "$id": "urn:projects:undo-state:v1", "title": "undo-state", "type": "object", - "description": "Whether anything is left to undo.", + "description": "Whether anything is left to undo. A field added is not a new version, and a field reshaped or removed is.", "additionalProperties": false, "required": [ "can_undo" diff --git a/api/wire/wire.go b/api/wire/wire.go index e973ce4..b156485 100644 --- a/api/wire/wire.go +++ b/api/wire/wire.go @@ -1,8 +1,8 @@ // Package wire is what the API answers with. It is apart from model because a -// client depends on this contract and never on the server's types: every field is -// always sent, an empty one as null, so a reader indexing by name finds it, and a -// change to model cannot change an answer by accident. Each struct has its JSON -// Schema under api/schemas. +// client depends on this contract and never on the server's types: every field of a +// row is always sent, an empty value as null and an empty list as [], so a reader +// indexing by name finds it, and a change to model cannot change an answer by +// accident. Each struct has its JSON Schema under api/schemas. package wire import (