repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

commit
9c8d241
parent
d247cbd
author
Eric Bower
date
2025-12-17 22:03:10 -0500 EST
refactor(db): use sqlx
29 files changed,  +2081, -1070
M go.mod
M go.sum
M .gitignore
+1, -0
1@@ -16,3 +16,4 @@ __debug_bin
2 .aider*
3 ssh
4 web
5+.beads/
M cmd/scripts/dates/dates.go
+2, -1
 1@@ -7,12 +7,13 @@ import (
 2 	"os"
 3 	"time"
 4 
 5+	"github.com/jmoiron/sqlx"
 6 	"github.com/picosh/pico/pkg/db"
 7 	"github.com/picosh/pico/pkg/db/postgres"
 8 	"github.com/picosh/pico/pkg/shared"
 9 )
10 
11-func findPosts(dbpool *sql.DB) ([]*db.Post, error) {
12+func findPosts(dbpool *sqlx.DB) ([]*db.Post, error) {
13 	var posts []*db.Post
14 	rs, err := dbpool.Query(`SELECT
15 		id, user_id, filename, title, text, description,
M cmd/scripts/migrate/migrate.go
+5, -4
 1@@ -7,12 +7,13 @@ import (
 2 	"log/slog"
 3 	"os"
 4 
 5+	"github.com/jmoiron/sqlx"
 6 	"github.com/picosh/pico/pkg/db"
 7 	"github.com/picosh/pico/pkg/db/postgres"
 8 	"github.com/picosh/pico/pkg/shared"
 9 )
10 
11-func findPosts(dbpool *sql.DB) ([]*db.Post, error) {
12+func findPosts(dbpool *sqlx.DB) ([]*db.Post, error) {
13 	var posts []*db.Post
14 	rs, err := dbpool.Query(`SELECT
15 		posts.id, user_id, filename, title, text, description,
16@@ -147,7 +148,7 @@ func main() {
17 			panic(err)
18 		}
19 
20-		proseKeys, err := proseDb.FindKeysForUser(proseUser)
21+		proseKeys, err := proseDb.FindKeysByUser(proseUser)
22 		if err != nil {
23 			panic(err)
24 		}
25@@ -165,7 +166,7 @@ func main() {
26 	updateIDs := []*ConflictData{}
27 	logger.Info("Finding conflicts")
28 	for _, listUser := range listUsers {
29-		listKeys, err := listsDb.FindKeysForUser(listUser)
30+		listKeys, err := listsDb.FindKeysByUser(listUser)
31 		if err != nil {
32 			panic(err)
33 		}
34@@ -180,7 +181,7 @@ func main() {
35 			continue
36 		} else {
37 			proseUser := userMap[listUser.Name]
38-			proseKeys, err := proseDb.FindKeysForUser(proseUser)
39+			proseKeys, err := proseDb.FindKeysByUser(proseUser)
40 			if err != nil {
41 				panic(err)
42 			}
M cmd/scripts/tags/tags.go
+3, -3
 1@@ -1,16 +1,16 @@
 2 package main
 3 
 4 import (
 5-	"database/sql"
 6 	"log/slog"
 7 	"os"
 8 
 9+	"github.com/jmoiron/sqlx"
10 	"github.com/picosh/pico/pkg/db"
11 	"github.com/picosh/pico/pkg/db/postgres"
12 	"github.com/picosh/pico/pkg/shared"
13 )
14 
15-func findPosts(dbpool *sql.DB) ([]*db.Post, error) {
16+func findPosts(dbpool *sqlx.DB) ([]*db.Post, error) {
17 	var posts []*db.Post
18 	rs, err := dbpool.Query(`SELECT
19 		posts.id, user_id, filename, title, text, description,
20@@ -69,7 +69,7 @@ func main() {
21 			continue
22 		}
23 		if len(parsed.Tags) > 0 {
24-			err := picoDb.ReplaceTagsForPost(parsed.Tags, post.ID)
25+			err := picoDb.ReplaceTagsByPost(parsed.Tags, post.ID)
26 			panic(err)
27 		}
28 	}
M go.mod
+48, -12
  1@@ -1,8 +1,6 @@
  2 module github.com/picosh/pico
  3 
  4-go 1.24
  5-
  6-toolchain go1.24.0
  7+go 1.24.0
  8 
  9 // replace github.com/picosh/tunkit => ../tunkit
 10 
 11@@ -55,6 +53,8 @@ require (
 12 	github.com/prometheus/client_golang v1.22.0
 13 	github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
 14 	github.com/simplesurance/go-ip-anonymizer v0.0.0-20200429124537-35a880f8e87d
 15+	github.com/testcontainers/testcontainers-go v0.40.0
 16+	github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0
 17 	github.com/x-way/crawlerdetect v0.2.28
 18 	github.com/yuin/goldmark v1.7.8
 19 	github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
 20@@ -62,7 +62,7 @@ require (
 21 	go.abhg.dev/goldmark/anchor v0.2.0
 22 	go.abhg.dev/goldmark/hashtag v0.3.1
 23 	go.abhg.dev/goldmark/toc v0.11.0
 24-	golang.org/x/crypto v0.38.0
 25+	golang.org/x/crypto v0.43.0
 26 	google.golang.org/protobuf v1.36.6
 27 	gopkg.in/yaml.v2 v2.4.0
 28 	modernc.org/sqlite v1.36.2
 29@@ -71,9 +71,10 @@ require (
 30 require (
 31 	cel.dev/expr v0.22.1 // indirect
 32 	codeberg.org/emersion/go-scfg v0.1.0 // indirect
 33-	dario.cat/mergo v1.0.1 // indirect
 34+	dario.cat/mergo v1.0.2 // indirect
 35 	filippo.io/edwards25519 v1.1.0 // indirect
 36 	github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
 37+	github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
 38 	github.com/KimMachineGun/automemlimit v0.7.1 // indirect
 39 	github.com/Masterminds/goutils v1.1.1 // indirect
 40 	github.com/Masterminds/semver/v3 v3.3.1 // indirect
 41@@ -98,13 +99,19 @@ require (
 42 	github.com/caddyserver/certmagic v0.23.0 // indirect
 43 	github.com/caddyserver/zerossl v0.1.3 // indirect
 44 	github.com/ccoveille/go-safecast v1.6.1 // indirect
 45+	github.com/cenkalti/backoff/v4 v4.3.0 // indirect
 46 	github.com/cespare/xxhash v1.1.0 // indirect
 47 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 48 	github.com/chzyer/readline v1.5.1 // indirect
 49 	github.com/cloudflare/circl v1.6.0 // indirect
 50+	github.com/containerd/errdefs v1.0.0 // indirect
 51+	github.com/containerd/errdefs/pkg v0.3.0 // indirect
 52+	github.com/containerd/log v0.1.0 // indirect
 53+	github.com/containerd/platforms v0.2.1 // indirect
 54 	github.com/coreos/go-oidc/v3 v3.13.0 // indirect
 55 	github.com/coreos/go-semver v0.3.1 // indirect
 56 	github.com/coreos/go-systemd/v22 v22.5.0 // indirect
 57+	github.com/cpuguy83/dockercfg v0.3.2 // indirect
 58 	github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
 59 	github.com/darkweak/go-esi v0.0.6 // indirect
 60 	github.com/darkweak/storages/badger v0.0.14 // indirect
 61@@ -115,6 +122,7 @@ require (
 62 	github.com/darkweak/storages/otter v0.0.14 // indirect
 63 	github.com/darkweak/storages/redis v0.0.14 // indirect
 64 	github.com/darkweak/storages/simplefs v0.0.14 // indirect
 65+	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
 66 	github.com/delthas/go-libnp v0.1.0 // indirect
 67 	github.com/delthas/go-localeinfo v0.1.0 // indirect
 68 	github.com/dgraph-io/badger v1.6.2 // indirect
 69@@ -124,7 +132,11 @@ require (
 70 	github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da // indirect
 71 	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
 72 	github.com/disintegration/imaging v1.6.2 // indirect
 73+	github.com/distribution/reference v0.6.0 // indirect
 74 	github.com/dlclark/regexp2 v1.11.5 // indirect
 75+	github.com/docker/docker v28.5.1+incompatible // indirect
 76+	github.com/docker/go-connections v0.6.0 // indirect
 77+	github.com/docker/go-units v0.5.0 // indirect
 78 	github.com/dolthub/maphash v0.1.0 // indirect
 79 	github.com/dsoprea/go-exif v0.0.0-20230826092837-6579e82b732d // indirect
 80 	github.com/dsoprea/go-exif/v2 v2.0.0-20230826092837-6579e82b732d // indirect
 81@@ -134,6 +146,8 @@ require (
 82 	github.com/dsoprea/go-png-image-structure v0.0.0-20210512210324-29b889a6093d // indirect
 83 	github.com/dsoprea/go-utility v0.0.0-20221003172846-a3e1774ef349 // indirect
 84 	github.com/dustin/go-humanize v1.0.1 // indirect
 85+	github.com/ebitengine/purego v0.8.4 // indirect
 86+	github.com/felixge/httpsnoop v1.0.4 // indirect
 87 	github.com/forPelevin/gomoji v1.3.0 // indirect
 88 	github.com/francoispqt/gojay v1.2.13 // indirect
 89 	github.com/gammazero/deque v1.0.0 // indirect
 90@@ -143,6 +157,8 @@ require (
 91 	github.com/go-ini/ini v1.67.0 // indirect
 92 	github.com/go-jose/go-jose/v3 v3.0.4 // indirect
 93 	github.com/go-jose/go-jose/v4 v4.0.5 // indirect
 94+	github.com/go-logr/logr v1.4.2 // indirect
 95+	github.com/go-logr/stdr v1.2.2 // indirect
 96 	github.com/go-ole/go-ole v1.3.0 // indirect
 97 	github.com/go-redis/redis/v8 v8.11.5 // indirect
 98 	github.com/go-sql-driver/mysql v1.9.1 // indirect
 99@@ -186,6 +202,7 @@ require (
100 	github.com/kr/text v0.2.0 // indirect
101 	github.com/libdns/libdns v1.0.0-beta.1 // indirect
102 	github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
103+	github.com/magiconair/properties v1.8.10 // indirect
104 	github.com/manifoldco/promptui v0.9.0 // indirect
105 	github.com/maruel/natural v1.1.1 // indirect
106 	github.com/mattn/go-colorable v0.1.14 // indirect
107@@ -204,8 +221,16 @@ require (
108 	github.com/mitchellh/reflectwalk v1.0.2 // indirect
109 	github.com/mmcdole/goxpp v1.1.1 // indirect
110 	github.com/mmcloughlin/md4 v0.1.2 // indirect
111+	github.com/moby/docker-image-spec v1.3.1 // indirect
112+	github.com/moby/go-archive v0.1.0 // indirect
113+	github.com/moby/patternmatcher v0.6.0 // indirect
114+	github.com/moby/sys/sequential v0.6.0 // indirect
115+	github.com/moby/sys/user v0.4.0 // indirect
116+	github.com/moby/sys/userns v0.1.0 // indirect
117+	github.com/moby/term v0.5.0 // indirect
118 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
119 	github.com/modern-go/reflect2 v1.0.2 // indirect
120+	github.com/morikuni/aec v1.0.0 // indirect
121 	github.com/mschoch/smat v0.2.0 // indirect
122 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
123 	github.com/nats-io/nats.go v1.40.1 // indirect
124@@ -215,10 +240,13 @@ require (
125 	github.com/neurosnap/go-jpeg-image-structure v0.0.0-20221010133817-70b1c1ff679e // indirect
126 	github.com/nutsdb/nutsdb v1.0.4 // indirect
127 	github.com/onsi/ginkgo/v2 v2.23.3 // indirect
128+	github.com/opencontainers/go-digest v1.0.0 // indirect
129+	github.com/opencontainers/image-spec v1.1.1 // indirect
130 	github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect
131 	github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
132 	github.com/pierrec/lz4/v4 v4.1.22 // indirect
133 	github.com/pkg/errors v0.9.1 // indirect
134+	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
135 	github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
136 	github.com/pquerna/cachecontrol v0.2.0 // indirect
137 	github.com/prometheus/client_model v0.6.2 // indirect
138@@ -238,9 +266,11 @@ require (
139 	github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
140 	github.com/secure-io/sio-go v0.3.1 // indirect
141 	github.com/shirou/gopsutil/v3 v3.24.5 // indirect
142+	github.com/shirou/gopsutil/v4 v4.25.6 // indirect
143 	github.com/shoenig/go-m1cpu v0.1.6 // indirect
144 	github.com/shopspring/decimal v1.4.0 // indirect
145 	github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
146+	github.com/sirupsen/logrus v1.9.3 // indirect
147 	github.com/slackhq/nebula v1.9.5 // indirect
148 	github.com/smallstep/certificates v0.28.3 // indirect
149 	github.com/smallstep/cli-utils v0.12.1 // indirect
150@@ -254,6 +284,7 @@ require (
151 	github.com/spf13/cobra v1.9.1 // indirect
152 	github.com/spf13/pflag v1.0.6 // indirect
153 	github.com/stoewer/go-strcase v1.3.0 // indirect
154+	github.com/stretchr/testify v1.11.1 // indirect
155 	github.com/tailscale/tscert v0.0.0-20240608151842-d3f834017e53 // indirect
156 	github.com/tidwall/btree v1.7.0 // indirect
157 	github.com/tidwall/gjson v1.17.0 // indirect
158@@ -276,6 +307,11 @@ require (
159 	go.etcd.io/etcd/client/pkg/v3 v3.5.21 // indirect
160 	go.etcd.io/etcd/client/v3 v3.5.21 // indirect
161 	go.opencensus.io v0.24.0 // indirect
162+	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
163+	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect
164+	go.opentelemetry.io/otel v1.35.0 // indirect
165+	go.opentelemetry.io/otel/metric v1.35.0 // indirect
166+	go.opentelemetry.io/otel/trace v1.35.0 // indirect
167 	go.step.sm/crypto v0.60.0 // indirect
168 	go.uber.org/automaxprocs v1.6.0 // indirect
169 	go.uber.org/mock v0.5.0 // indirect
170@@ -285,15 +321,15 @@ require (
171 	golang.org/x/crypto/x509roots/fallback v0.0.0-20250317152234-d0a798f77473 // indirect
172 	golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
173 	golang.org/x/image v0.25.0 // indirect
174-	golang.org/x/mod v0.24.0 // indirect
175-	golang.org/x/net v0.38.0 // indirect
176+	golang.org/x/mod v0.28.0 // indirect
177+	golang.org/x/net v0.45.0 // indirect
178 	golang.org/x/oauth2 v0.28.0 // indirect
179-	golang.org/x/sync v0.14.0 // indirect
180-	golang.org/x/sys v0.33.0 // indirect
181-	golang.org/x/term v0.32.0 // indirect
182-	golang.org/x/text v0.25.0 // indirect
183+	golang.org/x/sync v0.17.0 // indirect
184+	golang.org/x/sys v0.37.0 // indirect
185+	golang.org/x/term v0.36.0 // indirect
186+	golang.org/x/text v0.30.0 // indirect
187 	golang.org/x/time v0.11.0 // indirect
188-	golang.org/x/tools v0.31.0 // indirect
189+	golang.org/x/tools v0.37.0 // indirect
190 	google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 // indirect
191 	google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
192 	google.golang.org/grpc v1.71.0 // indirect
M go.sum
+101, -26
  1@@ -20,8 +20,8 @@ cloud.google.com/go/longrunning v0.6.6 h1:XJNDo5MUfMM05xK3ewpbSdmt7R2Zw+aQEMbdQR
  2 cloud.google.com/go/longrunning v0.6.6/go.mod h1:hyeGJUrPHcx0u2Uu1UFSoYZLn4lkMrccJig0t4FI7yw=
  3 codeberg.org/emersion/go-scfg v0.1.0 h1:6dnGU0ZI4gX+O5rMjwhoaySItzHG710eXL5TIQKl+uM=
  4 codeberg.org/emersion/go-scfg v0.1.0/go.mod h1:0nooW1ufBB4SlJEdTtiVN9Or+bnNM1icOkQ6Tbrq6O0=
  5-dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
  6-dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
  7+dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
  8+dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
  9 dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl+fi1br7+Rr3LqpNJf1/uxUdtRUV+Tnj0o93V2B9MU=
 10 dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU=
 11 dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4=
 12@@ -33,8 +33,12 @@ git.sr.ht/~delthas/senpai v0.4.0 h1:G1cycL0ARqYkqOErjEx/7Cneqykq3POfCsMYnpkWhUY=
 13 git.sr.ht/~delthas/senpai v0.4.0/go.mod h1:mzdu4o3wANA6cYzRnrz3w+uGPHA2z3j02JDrr/M3Myc=
 14 git.sr.ht/~rockorager/vaxis v0.14.1-0.20250527151737-5530f9f4bcf6 h1:lmSD6ksbKNMLdhT8R2k5FQPz8ObjslX8NkCM6z1kifI=
 15 git.sr.ht/~rockorager/vaxis v0.14.1-0.20250527151737-5530f9f4bcf6/go.mod h1:h94aKek3frIV1hJbdXjqnBqaLkbWXvV+UxAsQHg9bns=
 16+github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
 17+github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
 18 github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 h1:cTp8I5+VIoKjsnZuH8vjyaysT/ses3EvZeaV/1UkF2M=
 19 github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
 20+github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
 21+github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
 22 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 23 github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
 24 github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
 25@@ -146,6 +150,8 @@ github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+Y
 26 github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
 27 github.com/ccoveille/go-safecast v1.6.1 h1:Nb9WMDR8PqhnKCVs2sCB+OqhohwO5qaXtCviZkIff5Q=
 28 github.com/ccoveille/go-safecast v1.6.1/go.mod h1:QqwNjxQ7DAqY0C721OIO9InMk9zCwcsO7tnRuHytad8=
 29+github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
 30+github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
 31 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 32 github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
 33 github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
 34@@ -170,6 +176,14 @@ github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZ
 35 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
 36 github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro=
 37 github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
 38+github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
 39+github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
 40+github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
 41+github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
 42+github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
 43+github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
 44+github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
 45+github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
 46 github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
 47 github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
 48 github.com/coreos/go-oidc/v3 v3.13.0 h1:M66zd0pcc5VxvBNM4pB331Wrsanby+QomQYjN8HamW8=
 49@@ -180,11 +194,15 @@ github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03V
 50 github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 51 github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
 52 github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
 53+github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
 54+github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
 55 github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
 56 github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
 57 github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
 58 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
 59 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 60+github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
 61+github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
 62 github.com/darkweak/go-esi v0.0.6 h1:eVHCJfqrZwOHPfRK7JTlSYG9F8lfpX/d4lz/41RQkd8=
 63 github.com/darkweak/go-esi v0.0.6/go.mod h1:IJSayeQZDUh5R5ayyDC3wUEBykti12aUa0eUxZZeodk=
 64 github.com/darkweak/souin v1.7.7 h1:pIj/cknEsV/+xgq74VOGE1+0yy0dNnt5I6HGRczJlKk=
 65@@ -235,10 +253,18 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
 66 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
 67 github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
 68 github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
 69+github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
 70+github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
 71 github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
 72 github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
 73 github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
 74 github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
 75+github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
 76+github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 77+github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
 78+github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
 79+github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
 80+github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
 81 github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
 82 github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
 83 github.com/dsoprea/go-exif v0.0.0-20230826092837-6579e82b732d h1:ygcRCGNKuEiA98k7X35hknEN8RIRUF1jrz7k1rZCvsk=
 84@@ -268,6 +294,8 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20200717064901-2fccff4aa15e/go.mod h1:uA
 85 github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
 86 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 87 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 88+github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
 89+github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
 90 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
 91 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
 92 github.com/emersion/go-smtp v0.23.0 h1:ZiriTOTK7sKep7jbWqgB5kPsiBp5wnE5auEMnwRMnGc=
 93@@ -317,6 +345,7 @@ github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vb
 94 github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
 95 github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
 96 github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
 97+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
 98 github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
 99 github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
100 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
101@@ -437,6 +466,9 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
102 github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
103 github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
104 github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw=
105+github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo=
106+github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
107+github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ=
108 github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
109 github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
110 github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
111@@ -536,6 +568,8 @@ github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr32
112 github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
113 github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
114 github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
115+github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
116+github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
117 github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
118 github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
119 github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
120@@ -561,6 +595,8 @@ github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zk
121 github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
122 github.com/maypok86/otter v1.2.4 h1:HhW1Pq6VdJkmWwcZZq19BlEQkHtI8xgsQzBVXJU0nfc=
123 github.com/maypok86/otter v1.2.4/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4=
124+github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
125+github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
126 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
127 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
128 github.com/mholt/acmez/v3 v3.1.2 h1:auob8J/0FhmdClQicvJvuDavgd5ezwLBfKuYmynhYzc=
129@@ -596,6 +632,22 @@ github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8=
130 github.com/mmcdole/goxpp v1.1.1/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
131 github.com/mmcloughlin/md4 v0.1.2 h1:kGYl+iNbxhyz4u76ka9a+0TXP9KWt/LmnM0QhZwhcBo=
132 github.com/mmcloughlin/md4 v0.1.2/go.mod h1:AAxFX59fddW0IguqNzWlf1lazh1+rXeIt/Bj49cqDTQ=
133+github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
134+github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
135+github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
136+github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
137+github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
138+github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
139+github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
140+github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
141+github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
142+github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
143+github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
144+github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
145+github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
146+github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
147+github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
148+github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
149 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
150 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
151 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
152@@ -603,6 +655,8 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
153 github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
154 github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
155 github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
156+github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
157+github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
158 github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
159 github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
160 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
161@@ -642,6 +696,10 @@ github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAl
162 github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
163 github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
164 github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=
165+github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
166+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
167+github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
168+github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
169 github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8=
170 github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
171 github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
172@@ -746,6 +804,8 @@ github.com/secure-io/sio-go v0.3.1/go.mod h1:+xbkjDzPjwh4Axd07pRKSNriS9SCiYksWnZ
173 github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
174 github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
175 github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
176+github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
177+github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
178 github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
179 github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
180 github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
181@@ -829,6 +889,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
182 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
183 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
184 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
185+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
186 github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
187 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
188 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
189@@ -841,11 +902,15 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
190 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
191 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
192 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
193-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
194-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
195+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
196+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
197 github.com/tailscale/tscert v0.0.0-20240608151842-d3f834017e53 h1:uxMgm0C+EjytfAqyfBG55ZONKQ7mvd7x4YYCWsf8QHQ=
198 github.com/tailscale/tscert v0.0.0-20240608151842-d3f834017e53/go.mod h1:kNGUQ3VESx3VZwRwA9MSCUegIl6+saPL8Noq82ozCaU=
199 github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
200+github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
201+github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
202+github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk=
203+github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0/go.mod h1:h+u/2KoREGTnTl9UwrQ/g+XhasAT8E6dClclAADeXoQ=
204 github.com/tidwall/btree v1.1.0/go.mod h1:TzIRzen6yHbibdSfK6t8QimqbUnoxUSrZfeW7Uob0q4=
205 github.com/tidwall/btree v1.7.0 h1:L1fkJH/AuEh5zBnnBbmTwQ5Lt+bRJ5A8EWecslvo9iI=
206 github.com/tidwall/btree v1.7.0/go.mod h1:twD9XRA5jj9VUQGELzDO4HPQTNJsoWWfYEL+EUQ2cKY=
207@@ -928,16 +993,22 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.5
208 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4=
209 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s=
210 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I=
211-go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
212-go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
213-go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
214-go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
215+go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
216+go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
217+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60=
218+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM=
219+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 h1:BEj3SPM81McUZHYjRS5pEgNgnmzGJ5tRpU5krWnV8Bs=
220+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0/go.mod h1:9cKLGBDzI/F3NoHLQGm4ZrYdIHsvGt6ej6hUowxY0J4=
221+go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
222+go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
223 go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
224 go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
225 go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
226 go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
227-go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
228-go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
229+go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
230+go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
231+go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
232+go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
233 go.step.sm/crypto v0.60.0 h1:UgSw8DFG5xUOGB3GUID17UA32G4j1iNQ4qoMhBmsVFw=
234 go.step.sm/crypto v0.60.0/go.mod h1:Ep83Lv818L4gV0vhFTdPWRKnL6/5fRMpi8SaoP5ArSw=
235 go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
236@@ -970,8 +1041,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
237 golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
238 golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
239 golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
240-golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
241-golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
242+golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
243+golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
244 golang.org/x/crypto/x509roots/fallback v0.0.0-20250317152234-d0a798f77473 h1:XxwA7BHJuoBTkv1tIM7d/8rDnl9i9M0M5anwcJWZtbA=
245 golang.org/x/crypto/x509roots/fallback v0.0.0-20250317152234-d0a798f77473/go.mod h1:lxN5T34bK4Z/i6cMaU7frUU57VkDXFD4Kamfl/cp9oU=
246 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
247@@ -992,8 +1063,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
248 golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
249 golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
250 golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
251-golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
252-golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
253+golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
254+golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
255 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
256 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
257 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
258@@ -1030,8 +1101,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
259 golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
260 golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
261 golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
262-golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
263-golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
264+golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
265+golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
266 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
267 golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
268 golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
269@@ -1056,8 +1127,8 @@ golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
270 golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
271 golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
272 golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
273-golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
274-golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
275+golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
276+golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
277 golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
278 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
279 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
280@@ -1097,10 +1168,12 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
281 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
282 golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
283 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
284+golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
285 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
286 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
287 golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
288 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
289+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
290 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
291 golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
292 golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
293@@ -1115,8 +1188,8 @@ golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
294 golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
295 golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
296 golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
297-golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
298-golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
299+golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
300+golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
301 golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
302 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
303 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
304@@ -1128,8 +1201,8 @@ golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
305 golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
306 golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
307 golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
308-golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
309-golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
310+golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
311+golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
312 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
313 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
314 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
315@@ -1144,8 +1217,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
316 golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
317 golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
318 golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
319-golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
320-golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
321+golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
322+golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
323 golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
324 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
325 golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
326@@ -1167,8 +1240,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
327 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
328 golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
329 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
330-golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
331-golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
332+golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
333+golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
334 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
335 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
336 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
337@@ -1243,6 +1316,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
338 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
339 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
340 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
341+gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
342+gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
343 grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o=
344 honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
345 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
M pkg/apps/auth/api.go
+6, -6
 1@@ -85,7 +85,7 @@ func introspectHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
 2 		token := r.FormValue("token")
 3 		apiConfig.Cfg.Logger.Info("introspect token", "token", token)
 4 
 5-		user, err := apiConfig.Dbpool.FindUserForToken(token)
 6+		user, err := apiConfig.Dbpool.FindUserByToken(token)
 7 		if err != nil {
 8 			apiConfig.Cfg.Logger.Error(err.Error())
 9 			http.Error(w, err.Error(), http.StatusUnauthorized)
10@@ -207,7 +207,7 @@ func tokenHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
11 			"grantType", grantType,
12 		)
13 
14-		_, err := apiConfig.Dbpool.FindUserForToken(token)
15+		_, err := apiConfig.Dbpool.FindUserByToken(token)
16 		if err != nil {
17 			apiConfig.Cfg.Logger.Error(err.Error())
18 			http.Error(w, err.Error(), http.StatusUnauthorized)
19@@ -269,7 +269,7 @@ func keyHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
20 			return
21 		}
22 
23-		user, err := apiConfig.Dbpool.FindUserForKey(data.Username, authed.Pubkey)
24+		user, err := apiConfig.Dbpool.FindUserByKey(data.Username, authed.Pubkey)
25 		if err != nil {
26 			log.Error("find user for key", "err", err)
27 			w.WriteHeader(http.StatusUnauthorized)
28@@ -337,7 +337,7 @@ func userHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
29 			return
30 		}
31 
32-		keys, err := apiConfig.Dbpool.FindKeysForUser(user)
33+		keys, err := apiConfig.Dbpool.FindKeysByUser(user)
34 		if err != nil {
35 			apiConfig.Cfg.Logger.Error(err.Error())
36 			http.Error(w, err.Error(), http.StatusNotFound)
37@@ -357,7 +357,7 @@ func userHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
38 func rssHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
39 	return func(w http.ResponseWriter, r *http.Request) {
40 		apiToken := r.PathValue("token")
41-		user, err := apiConfig.Dbpool.FindUserForToken(apiToken)
42+		user, err := apiConfig.Dbpool.FindUserByToken(apiToken)
43 		if err != nil {
44 			apiConfig.Cfg.Logger.Error(
45 				"could not find user for token",
46@@ -545,7 +545,7 @@ func paymentWebhookHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
47 
48 func AddPlusFeedForUser(dbpool db.DB, userID, email string) error {
49 	// check if they already have a post grepping for the auth rss url
50-	posts, err := dbpool.FindPostsForUser(&db.Pager{Num: 1000, Page: 0}, userID, "feeds")
51+	posts, err := dbpool.FindPostsByUser(&db.Pager{Num: 1000, Page: 0}, userID, "feeds")
52 	if err != nil {
53 		return err
54 	}
M pkg/apps/auth/api_test.go
+6, -6
 1@@ -53,7 +53,7 @@ func TestPaymentWebhook(t *testing.T) {
 2 
 3 	testResponse(t, responseRecorder, 200, "text/plain")
 4 
 5-	posts, err := apiConfig.Dbpool.FindPostsForUser(&db.Pager{Num: 1000, Page: 0}, testUserID, "feeds")
 6+	posts, err := apiConfig.Dbpool.FindPostsByUser(&db.Pager{Num: 1000, Page: 0}, testUserID, "feeds")
 7 	if err != nil {
 8 		t.Error("could not find posts for user")
 9 	}
10@@ -224,22 +224,22 @@ func (a *AuthDb) FindUserByName(username string) (*db.User, error) {
11 	return &db.User{ID: testUserID, Name: username}, nil
12 }
13 
14-func (a *AuthDb) FindUserForKey(username string, pubkey string) (*db.User, error) {
15+func (a *AuthDb) FindUserByKey(username string, pubkey string) (*db.User, error) {
16 	return &db.User{ID: testUserID, Name: username}, nil
17 }
18 
19-func (a *AuthDb) FindUserForToken(token string) (*db.User, error) {
20+func (a *AuthDb) FindUserByToken(token string) (*db.User, error) {
21 	if token != "123" {
22 		return nil, fmt.Errorf("invalid token")
23 	}
24 	return &db.User{ID: testUserID, Name: testUsername}, nil
25 }
26 
27-func (a *AuthDb) HasFeatureForUser(userID string, feature string) bool {
28+func (a *AuthDb) HasFeatureByUser(userID string, feature string) bool {
29 	return true
30 }
31 
32-func (a *AuthDb) FindKeysForUser(user *db.User) ([]*db.PublicKey, error) {
33+func (a *AuthDb) FindKeysByUser(user *db.User) ([]*db.PublicKey, error) {
34 	return []*db.PublicKey{{ID: "1", UserID: user.ID, Name: "my-key", Key: "nice-pubkey", CreatedAt: &time.Time{}}}, nil
35 }
36 
37@@ -254,7 +254,7 @@ func (a *AuthDb) InsertPost(post *db.Post) (*db.Post, error) {
38 	return post, nil
39 }
40 
41-func (a *AuthDb) FindPostsForUser(pager *db.Pager, userID, space string) (*db.Paginate[*db.Post], error) {
42+func (a *AuthDb) FindPostsByUser(pager *db.Pager, userID, space string) (*db.Paginate[*db.Post], error) {
43 	return &db.Paginate[*db.Post]{
44 		Data: a.Posts,
45 	}, nil
M pkg/apps/feeds/cli.go
+1, -1
1@@ -64,7 +64,7 @@ func Middleware(dbpool db.DB, cfg *shared.ConfigSite) pssh.SSHServerMiddleware {
2 				)
3 				return writer.Flush()
4 			case "ls":
5-				posts, err := dbpool.FindPostsForUser(&db.Pager{Page: 0, Num: 1000}, user.ID, "feeds")
6+				posts, err := dbpool.FindPostsByUser(&db.Pager{Page: 0, Num: 1000}, user.ID, "feeds")
7 				if err != nil {
8 					_, _ = fmt.Fprintln(sesh.Stderr(), err)
9 					return err
M pkg/apps/feeds/cron.go
+1, -1
1@@ -332,7 +332,7 @@ Also, we have centralized logs in our pico.sh TUI that will display realtime fee
2 func (f *Fetcher) RunUser(user *db.User, now time.Time) error {
3 	logger := shared.LoggerWithUser(f.cfg.Logger, user)
4 	logger.Info("run user")
5-	posts, err := f.db.FindPostsForUser(&db.Pager{Num: 100}, user.ID, "feeds")
6+	posts, err := f.db.FindPostsByUser(&db.Pager{Num: 100}, user.ID, "feeds")
7 	if err != nil {
8 		return err
9 	}
M pkg/apps/pastes/api.go
+1, -1
1@@ -87,7 +87,7 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
2 	}
3 	logger = shared.LoggerWithUser(blogger, user)
4 
5-	pager, err := dbpool.FindPostsForUser(&db.Pager{Num: 1000, Page: 0}, user.ID, cfg.Space)
6+	pager, err := dbpool.FindPostsByUser(&db.Pager{Num: 1000, Page: 0}, user.ID, cfg.Space)
7 	if err != nil {
8 		logger.Error("could not find posts for user", "err", err.Error())
9 		http.Error(w, "could not fetch posts for blog", http.StatusInternalServerError)
M pkg/apps/pgs/db/postgres.go
+1, -1
1@@ -53,7 +53,7 @@ func (me *PgsPsqlDB) FindUserByPubkey(key string) (*db.User, error) {
2 	if len(pk) == 0 {
3 		return nil, fmt.Errorf("pubkey not found in our database: [%s]", key)
4 	}
5-	// When we run PublicKeyForKey and there are multiple public keys returned from the database
6+	// When we run PublicKeyByKey and there are multiple public keys returned from the database
7 	// that should mean that we don't have the correct username for this public key.
8 	// When that happens we need to reject the authentication and ask the user to provide the correct
9 	// username when using ssh.  So instead of `ssh <domain>` it should be `ssh user@<domain>`
M pkg/apps/pico/cli.go
+1, -1
1@@ -24,7 +24,7 @@ func getUser(s *pssh.SSHServerConnSession, dbpool db.DB) (*db.User, error) {
2 
3 	key := utils.KeyForKeyText(s.PublicKey())
4 
5-	user, err := dbpool.FindUserForKey(s.User(), key)
6+	user, err := dbpool.FindUserByKey(s.User(), key)
7 	if err != nil {
8 		return nil, err
9 	}
M pkg/apps/pico/file_handler.go
+4, -4
 1@@ -32,7 +32,7 @@ func NewUploadHandler(dbpool db.DB, cfg *shared.ConfigSite) *UploadHandler {
 2 }
 3 
 4 func (h *UploadHandler) getAuthorizedKeyFile(user *db.User) (*sendutils.VirtualFile, string, error) {
 5-	keys, err := h.DBPool.FindKeysForUser(user)
 6+	keys, err := h.DBPool.FindKeysByUser(user)
 7 	text := ""
 8 	var modTime time.Time
 9 	for _, pk := range keys {
10@@ -137,7 +137,7 @@ func (h *UploadHandler) Validate(s *pssh.SSHServerConnSession) error {
11 		return fmt.Errorf("key not found")
12 	}
13 
14-	user, err := h.DBPool.FindUserForKey(s.User(), key)
15+	user, err := h.DBPool.FindUserByKey(s.User(), key)
16 	if err != nil {
17 		return err
18 	}
19@@ -215,7 +215,7 @@ func (h *UploadHandler) ProcessAuthorizedKeys(text []byte, logger *slog.Logger,
20 	logger.Info("processing new authorized_keys")
21 	dbpool := h.DBPool
22 
23-	curKeysStr, err := dbpool.FindKeysForUser(user)
24+	curKeysStr, err := dbpool.FindKeysByUser(user)
25 	if err != nil {
26 		return err
27 	}
28@@ -247,7 +247,7 @@ func (h *UploadHandler) ProcessAuthorizedKeys(text []byte, logger *slog.Logger,
29 		_, _ = fmt.Fprintf(s.Stderr(), "adding pubkey (%s)\n", key)
30 		logger.Info("adding pubkey", "pubkey", key)
31 
32-		err = dbpool.InsertPublicKey(user.ID, key, pk.Comment, nil)
33+		err = dbpool.InsertPublicKey(user.ID, key, pk.Comment)
34 		if err != nil {
35 			_, _ = fmt.Fprintf(s.Stderr(), "error: could not insert pubkey: %s (%s)\n", err.Error(), key)
36 			logger.Error("could not insert pubkey", "err", err.Error())
M pkg/apps/pipe/cli.go
+1, -1
1@@ -173,7 +173,7 @@ func Middleware(handler *CliHandler) pssh.SSHServerMiddleware {
2 			isAdmin := false
3 			impersonate := false
4 			if user != nil {
5-				isAdmin = handler.DBPool.HasFeatureForUser(user.ID, "admin")
6+				isAdmin = handler.DBPool.HasFeatureByUser(user.ID, "admin")
7 				if isAdmin && strings.HasPrefix(sesh.User(), "admin__") {
8 					impersonate = true
9 				}
M pkg/apps/prose/api.go
+4, -4
 1@@ -171,7 +171,7 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
 2 	var posts []*db.Post
 3 	var p *db.Paginate[*db.Post]
 4 	if tag == "" {
 5-		p, err = dbpool.FindPostsForUser(pager, user.ID, cfg.Space)
 6+		p, err = dbpool.FindPostsByUser(pager, user.ID, cfg.Space)
 7 	} else {
 8 		p, err = dbpool.FindUserPostsByTag(pager, tag, user.ID, cfg.Space)
 9 	}
10@@ -548,7 +548,7 @@ func readHandler(w http.ResponseWriter, r *http.Request) {
11 	var pager *db.Paginate[*db.Post]
12 	var err error
13 	if tag == "" {
14-		pager, err = dbpool.FindPostsForFeed(&db.Pager{Num: 30, Page: page}, cfg.Space)
15+		pager, err = dbpool.FindPostsByFeed(&db.Pager{Num: 30, Page: page}, cfg.Space)
16 	} else {
17 		pager, err = dbpool.FindPostsByTag(&db.Pager{Num: 30, Page: page}, tag, cfg.Space)
18 	}
19@@ -639,7 +639,7 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
20 	var posts []*db.Post
21 	var p *db.Paginate[*db.Post]
22 	if tag == "" {
23-		p, err = dbpool.FindPostsForUser(pager, user.ID, cfg.Space)
24+		p, err = dbpool.FindPostsByUser(pager, user.ID, cfg.Space)
25 	} else {
26 		p, err = dbpool.FindUserPostsByTag(pager, tag, user.ID, cfg.Space)
27 	}
28@@ -762,7 +762,7 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
29 	logger := shared.GetLogger(r)
30 	cfg := shared.GetCfg(r)
31 
32-	pager, err := dbpool.FindPostsForFeed(&db.Pager{Num: 25, Page: 0}, cfg.Space)
33+	pager, err := dbpool.FindPostsByFeed(&db.Pager{Num: 25, Page: 0}, cfg.Space)
34 	if err != nil {
35 		logger.Error("find all posts", "err", err.Error())
36 		http.Error(w, err.Error(), http.StatusInternalServerError)
M pkg/db/db.go
+94, -94
  1@@ -127,29 +127,29 @@ func (p *FeedItemData) Scan(value any) error {
  2 }
  3 
  4 type Post struct {
  5-	ID          string     `json:"id"`
  6-	UserID      string     `json:"user_id"`
  7-	Filename    string     `json:"filename"`
  8-	Slug        string     `json:"slug"`
  9-	Title       string     `json:"title"`
 10-	Text        string     `json:"text"`
 11-	Description string     `json:"description"`
 12-	CreatedAt   *time.Time `json:"created_at"`
 13-	PublishAt   *time.Time `json:"publish_at"`
 14-	Username    string     `json:"username"`
 15-	UpdatedAt   *time.Time `json:"updated_at"`
 16-	ExpiresAt   *time.Time `json:"expires_at"`
 17-	Hidden      bool       `json:"hidden"`
 18-	Views       int        `json:"views"`
 19-	Space       string     `json:"space"`
 20-	Shasum      string     `json:"shasum"`
 21-	FileSize    int        `json:"file_size"`
 22-	MimeType    string     `json:"mime_type"`
 23-	Data        PostData   `json:"data"`
 24-	Tags        []string   `json:"tags"`
 25+	ID          string     `json:"id" db:"id"`
 26+	UserID      string     `json:"user_id" db:"user_id"`
 27+	Filename    string     `json:"filename" db:"filename"`
 28+	Slug        string     `json:"slug" db:"slug"`
 29+	Title       string     `json:"title" db:"title"`
 30+	Text        string     `json:"text" db:"text"`
 31+	Description string     `json:"description" db:"description"`
 32+	CreatedAt   *time.Time `json:"created_at" db:"created_at"`
 33+	PublishAt   *time.Time `json:"publish_at" db:"publish_at"`
 34+	Username    string     `json:"username" db:"name"`
 35+	UpdatedAt   *time.Time `json:"updated_at" db:"updated_at"`
 36+	ExpiresAt   *time.Time `json:"expires_at" db:"expires_at"`
 37+	Hidden      bool       `json:"hidden" db:"hidden"`
 38+	Views       int        `json:"views" db:"views"`
 39+	Space       string     `json:"space" db:"cur_space"`
 40+	Shasum      string     `json:"shasum" db:"shasum"`
 41+	FileSize    int        `json:"file_size" db:"file_size"`
 42+	MimeType    string     `json:"mime_type" db:"mime_type"`
 43+	Data        PostData   `json:"data" db:"data"`
 44+	Tags        []string   `json:"tags" db:"-"`
 45 
 46 	// computed
 47-	IsVirtual bool
 48+	IsVirtual bool `db:"-"`
 49 }
 50 
 51 type Paginate[T any] struct {
 52@@ -158,13 +158,13 @@ type Paginate[T any] struct {
 53 }
 54 
 55 type VisitInterval struct {
 56-	Interval *time.Time `json:"interval"`
 57-	Visitors int        `json:"visitors"`
 58+	Interval *time.Time `json:"interval" db:"interval"`
 59+	Visitors int        `json:"visitors" db:"visitors"`
 60 }
 61 
 62 type VisitUrl struct {
 63-	Url   string `json:"url"`
 64-	Count int    `json:"count"`
 65+	Url   string `json:"url" db:"url"`
 66+	Count int    `json:"count" db:"count"`
 67 }
 68 
 69 type SummaryOpts struct {
 70@@ -184,34 +184,46 @@ type SummaryVisits struct {
 71 }
 72 
 73 type PostAnalytics struct {
 74-	ID       string
 75-	PostID   string
 76-	Views    int
 77-	UpdateAt *time.Time
 78+	ID       string     `json:"id" db:"id"`
 79+	PostID   string     `json:"post_id" db:"post_id"`
 80+	Views    int        `json:"views" db:"views"`
 81+	UpdateAt *time.Time `json:"updated_at" db:"updated_at"`
 82 }
 83 
 84 type AnalyticsVisits struct {
 85-	ID          string `json:"id"`
 86-	UserID      string `json:"user_id"`
 87-	ProjectID   string `json:"project_id"`
 88-	PostID      string `json:"post_id"`
 89-	Namespace   string `json:"namespace"`
 90-	Host        string `json:"host"`
 91-	Path        string `json:"path"`
 92-	IpAddress   string `json:"ip_address"`
 93-	UserAgent   string `json:"user_agent"`
 94-	Referer     string `json:"referer"`
 95-	Status      int    `json:"status"`
 96-	ContentType string `json:"content_type"`
 97+	ID          string `json:"id" db:"id"`
 98+	UserID      string `json:"user_id" db:"user_id"`
 99+	ProjectID   string `json:"project_id" db:"project_id"`
100+	PostID      string `json:"post_id" db:"post_id"`
101+	Namespace   string `json:"namespace" db:"namespace"`
102+	Host        string `json:"host" db:"host"`
103+	Path        string `json:"path" db:"path"`
104+	IpAddress   string `json:"ip_address" db:"ip_address"`
105+	UserAgent   string `json:"user_agent" db:"user_agent"`
106+	Referer     string `json:"referer" db:"referer"`
107+	Status      int    `json:"status" db:"status"`
108+	ContentType string `json:"content_type" db:"content_type"`
109+}
110+
111+type AccessLogData struct{}
112+
113+func (p *AccessLogData) Scan(value any) error {
114+	b, err := tcast(value)
115+	if err != nil {
116+		return err
117+	}
118+
119+	return json.Unmarshal(b, &p)
120 }
121 
122 type AccessLog struct {
123-	ID        string     `json:"id"`
124-	UserID    string     `json:"user_id"`
125-	Service   string     `json:"service"`
126-	Pubkey    string     `json:"pubkey"`
127-	Identity  string     `json:"identity"`
128-	CreatedAt *time.Time `json:"created_at"`
129+	ID        string        `json:"id" db:"id"`
130+	UserID    string        `json:"user_id" db:"user_id"`
131+	Service   string        `json:"service" db:"service"`
132+	Pubkey    string        `json:"pubkey" db:"pubkey"`
133+	Identity  string        `json:"identity" db:"identity"`
134+	Data      AccessLogData `json:"data" db:"data"`
135+	CreatedAt *time.Time    `json:"created_at" db:"created_at"`
136 }
137 
138 type Pager struct {
139@@ -220,19 +232,20 @@ type Pager struct {
140 }
141 
142 type FeedItem struct {
143-	ID        string
144-	PostID    string
145-	GUID      string
146-	Data      FeedItemData
147-	CreatedAt *time.Time
148+	ID        string       `json:"id" db:"id"`
149+	PostID    string       `json:"post_id" db:"post_id"`
150+	GUID      string       `json:"guid" db:"guid"`
151+	Data      FeedItemData `json:"data" db:"data"`
152+	CreatedAt *time.Time   `json:"created_at" db:"created_at"`
153 }
154 
155 type Token struct {
156-	ID        string     `json:"id"`
157-	UserID    string     `json:"user_id"`
158-	Name      string     `json:"name"`
159-	CreatedAt *time.Time `json:"created_at"`
160-	ExpiresAt *time.Time `json:"expires_at"`
161+	ID        string     `json:"id" db:"id"`
162+	UserID    string     `json:"user_id" db:"user_id"`
163+	Name      string     `json:"name" db:"name"`
164+	Token     string     `json:"token" db:"token"`
165+	CreatedAt *time.Time `json:"created_at" db:"created_at"`
166+	ExpiresAt *time.Time `json:"expires_at" db:"expires_at"`
167 }
168 
169 type FeatureFlag struct {
170@@ -352,17 +365,17 @@ type UserServiceStats struct {
171 }
172 
173 type TunsEventLog struct {
174-	ID             string     `json:"id"`
175-	ServerID       string     `json:"server_id"`
176-	Time           *time.Time `json:"time"`
177-	User           string     `json:"user"`
178-	UserId         string     `json:"user_id"`
179-	RemoteAddr     string     `json:"remote_addr"`
180-	EventType      string     `json:"event_type"`
181-	TunnelID       string     `json:"tunnel_id"`
182-	TunnelType     string     `json:"tunnel_type"`
183-	ConnectionType string     `json:"connection_type"`
184-	CreatedAt      *time.Time `json:"created_at"`
185+	ID             string     `json:"id" db:"id"`
186+	ServerID       string     `json:"server_id" db:"server_id"`
187+	Time           *time.Time `json:"time" db:"time"`
188+	User           string     `json:"user" db:"user"`
189+	UserId         string     `json:"user_id" db:"user_id"`
190+	RemoteAddr     string     `json:"remote_addr" db:"remote_addr"`
191+	EventType      string     `json:"event_type" db:"event_type"`
192+	TunnelID       string     `json:"tunnel_id" db:"tunnel_id"`
193+	TunnelType     string     `json:"tunnel_type" db:"tunnel_type"`
194+	ConnectionType string     `json:"connection_type" db:"connection_type"`
195+	CreatedAt      *time.Time `json:"created_at" db:"created_at"`
196 }
197 
198 var NameValidator = regexp.MustCompile("^[a-zA-Z0-9]{1,50}$")
199@@ -389,54 +402,41 @@ var DenyList = []string{
200 
201 type DB interface {
202 	RegisterUser(name, pubkey, comment string) (*User, error)
203-	RemoveUsers(userIDs []string) error
204 	UpdatePublicKey(pubkeyID, name string) (*PublicKey, error)
205-	InsertPublicKey(userID, pubkey, name string, tx *sql.Tx) error
206-	FindPublicKeyForKey(pubkey string) (*PublicKey, error)
207-	FindPublicKey(pubkeyID string) (*PublicKey, error)
208-	FindKeysForUser(user *User) ([]*PublicKey, error)
209+	InsertPublicKey(userID, pubkey, name string) error
210+	FindKeysByUser(user *User) ([]*PublicKey, error)
211 	RemoveKeys(pubkeyIDs []string) error
212 
213 	FindUsers() ([]*User, error)
214 	FindUserByName(name string) (*User, error)
215-	FindUserForNameAndKey(name string, pubkey string) (*User, error)
216-	FindUserForKey(name string, pubkey string) (*User, error)
217+	FindUserByKey(name string, pubkey string) (*User, error)
218 	FindUserByPubkey(pubkey string) (*User, error)
219 	FindUser(userID string) (*User, error)
220-	ValidateName(name string) (bool, error)
221-	SetUserName(userID string, name string) error
222 
223-	FindUserForToken(token string) (*User, error)
224-	FindTokensForUser(userID string) ([]*Token, error)
225+	FindUserByToken(token string) (*User, error)
226+	FindTokensByUser(userID string) ([]*Token, error)
227 	InsertToken(userID, name string) (string, error)
228 	UpsertToken(userID, name string) (string, error)
229-	FindTokenByName(userID, name string) (string, error)
230 	RemoveToken(tokenID string) error
231 
232 	FindPosts() ([]*Post, error)
233 	FindPost(postID string) (*Post, error)
234-	FindPostsForUser(pager *Pager, userID string, space string) (*Paginate[*Post], error)
235-	FindAllPostsForUser(userID string, space string) ([]*Post, error)
236+	FindPostsByUser(pager *Pager, userID string, space string) (*Paginate[*Post], error)
237+	FindAllPostsByUser(userID string, space string) ([]*Post, error)
238 	FindUsersWithPost(space string) ([]*User, error)
239-	FindPostsBeforeDate(date *time.Time, space string) ([]*Post, error)
240 	FindExpiredPosts(space string) ([]*Post, error)
241-	FindUpdatedPostsForUser(userID string, space string) ([]*Post, error)
242 	FindPostWithFilename(filename string, userID string, space string) (*Post, error)
243 	FindPostWithSlug(slug string, userID string, space string) (*Post, error)
244-	FindPostsForFeed(pager *Pager, space string) (*Paginate[*Post], error)
245-	FindAllUpdatedPosts(pager *Pager, space string) (*Paginate[*Post], error)
246+	FindPostsByFeed(pager *Pager, space string) (*Paginate[*Post], error)
247 	InsertPost(post *Post) (*Post, error)
248 	UpdatePost(post *Post) (*Post, error)
249 	RemovePosts(postIDs []string) error
250 
251-	ReplaceTagsForPost(tags []string, postID string) error
252+	ReplaceTagsByPost(tags []string, postID string) error
253 	FindUserPostsByTag(pager *Pager, tag, userID, space string) (*Paginate[*Post], error)
254 	FindPostsByTag(pager *Pager, tag, space string) (*Paginate[*Post], error)
255 	FindPopularTags(space string) ([]string, error)
256-	FindTagsForPost(postID string) ([]string, error)
257-	FindTagsForUser(userID string, space string) ([]string, error)
258-
259-	ReplaceAliasesForPost(aliases []string, postID string) error
260+	ReplaceAliasesByPost(aliases []string, postID string) error
261 
262 	InsertVisit(view *AnalyticsVisits) error
263 	VisitSummary(opts *SummaryOpts) (*SummaryVisits, error)
264@@ -445,9 +445,9 @@ type DB interface {
265 
266 	AddPicoPlusUser(username, email, paymentType, txId string) error
267 	FindFeature(userID string, feature string) (*FeatureFlag, error)
268-	FindFeaturesForUser(userID string) ([]*FeatureFlag, error)
269-	HasFeatureForUser(userID string, feature string) bool
270-	FindTotalSizeForUser(userID string) (int, error)
271+	FindFeaturesByUser(userID string) ([]*FeatureFlag, error)
272+	HasFeatureByUser(userID string, feature string) bool
273+
274 	InsertFeature(userID, name string, expiresAt time.Time) (*FeatureFlag, error)
275 	RemoveFeature(userID, names string) error
276 
M pkg/db/postgres/storage.go
+299, -813
   1@@ -1,7 +1,6 @@
   2 package postgres
   3 
   4 import (
   5-	"context"
   6 	"database/sql"
   7 	"errors"
   8 	"fmt"
   9@@ -12,6 +11,7 @@ import (
  10 
  11 	"slices"
  12 
  13+	"github.com/jmoiron/sqlx"
  14 	_ "github.com/lib/pq"
  15 	"github.com/picosh/pico/pkg/db"
  16 	"github.com/picosh/utils"
  17@@ -23,275 +23,16 @@ var SelectPost = `
  18 	posts.id, user_id, app_users.name, filename, slug, title, text, description,
  19 	posts.created_at, publish_at, posts.updated_at, hidden, file_size, mime_type, shasum, data, expires_at, views`
  20 
  21-var (
  22-	sqlSelectPosts = fmt.Sprintf(`
  23-	SELECT %s
  24-	FROM posts
  25-	LEFT JOIN app_users ON app_users.id = posts.user_id`, SelectPost)
  26-
  27-	sqlSelectPostsBeforeDate = fmt.Sprintf(`
  28-	SELECT %s
  29-	FROM posts
  30-	LEFT JOIN app_users ON app_users.id = posts.user_id
  31-	WHERE publish_at::date <= $1 AND cur_space = $2`, SelectPost)
  32-
  33-	sqlSelectPostWithFilename = fmt.Sprintf(`
  34-	SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
  35-	FROM posts
  36-	LEFT JOIN app_users ON app_users.id = posts.user_id
  37-	LEFT JOIN post_tags ON post_tags.post_id = posts.id
  38-	WHERE filename = $1 AND user_id = $2 AND cur_space = $3
  39-	GROUP BY %s`, SelectPost, SelectPost)
  40-
  41-	sqlSelectPostWithSlug = fmt.Sprintf(`
  42-	SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
  43-	FROM posts
  44-	LEFT JOIN app_users ON app_users.id = posts.user_id
  45-	LEFT JOIN post_tags ON post_tags.post_id = posts.id
  46-	WHERE slug = $1 AND user_id = $2 AND cur_space = $3
  47-	GROUP BY %s`, SelectPost, SelectPost)
  48-
  49-	sqlSelectPost = fmt.Sprintf(`
  50-	SELECT %s
  51-	FROM posts
  52-	LEFT JOIN app_users ON app_users.id = posts.user_id
  53-	WHERE posts.id = $1`, SelectPost)
  54-
  55-	sqlSelectUpdatedPostsForUser = fmt.Sprintf(`
  56-	SELECT %s
  57-	FROM posts
  58-	LEFT JOIN app_users ON app_users.id = posts.user_id
  59-	WHERE user_id = $1 AND publish_at::date <= CURRENT_DATE AND cur_space = $2
  60-	ORDER BY posts.updated_at DESC`, SelectPost)
  61-
  62-	sqlSelectExpiredPosts = fmt.Sprintf(`
  63-		SELECT %s
  64-		FROM posts
  65-		LEFT JOIN app_users ON app_users.id = posts.user_id
  66-		WHERE
  67-			cur_space = $1 AND
  68-			expires_at <= now();
  69-	`, SelectPost)
  70-
  71-	sqlSelectPostsForUser = fmt.Sprintf(`
  72-	SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
  73-	FROM posts
  74-	LEFT JOIN app_users ON app_users.id = posts.user_id
  75-	LEFT JOIN post_tags ON post_tags.post_id = posts.id
  76-	WHERE
  77-		hidden = FALSE AND
  78-		user_id = $1 AND
  79-		publish_at::date <= CURRENT_DATE AND
  80-		cur_space = $2
  81-	GROUP BY %s
  82-	ORDER BY publish_at DESC, slug DESC
  83-	LIMIT $3 OFFSET $4`, SelectPost, SelectPost)
  84-
  85-	sqlSelectAllPostsForUser = fmt.Sprintf(`
  86-	SELECT %s
  87-	FROM posts
  88-	LEFT JOIN app_users ON app_users.id = posts.user_id
  89-	WHERE
  90-		user_id = $1 AND
  91-		cur_space = $2
  92-	ORDER BY publish_at DESC`, SelectPost)
  93-
  94-	sqlSelectPostsByTag = `
  95-	SELECT
  96-		posts.id,
  97-		user_id,
  98-		filename,
  99-		slug,
 100-		title,
 101-		text,
 102-		description,
 103-		publish_at,
 104-		app_users.name as username,
 105-		posts.updated_at,
 106-		posts.mime_type
 107-	FROM posts
 108-	LEFT JOIN app_users ON app_users.id = posts.user_id
 109-	LEFT JOIN post_tags ON post_tags.post_id = posts.id
 110-	WHERE
 111-		post_tags.name = $3 AND
 112-		publish_at::date <= CURRENT_DATE AND
 113-		cur_space = $4
 114-	ORDER BY publish_at DESC
 115-	LIMIT $1 OFFSET $2`
 116-
 117-	sqlSelectUserPostsByTag = fmt.Sprintf(`
 118-	SELECT %s
 119-	FROM posts
 120-	LEFT JOIN app_users ON app_users.id = posts.user_id
 121-	LEFT JOIN post_tags ON post_tags.post_id = posts.id
 122-	WHERE
 123-		hidden = FALSE AND
 124-		user_id = $1 AND
 125-		(post_tags.name = $2 OR hidden = true) AND
 126-		publish_at::date <= CURRENT_DATE AND
 127-		cur_space = $3
 128-	ORDER BY publish_at DESC
 129-	LIMIT $4 OFFSET $5`, SelectPost)
 130-)
 131-
 132-const (
 133-	sqlSelectPublicKey         = `SELECT id, user_id, name, public_key, created_at FROM public_keys WHERE public_key = $1`
 134-	sqlSelectPublicKeys        = `SELECT id, user_id, name, public_key, created_at FROM public_keys WHERE user_id = $1 ORDER BY created_at ASC`
 135-	sqlSelectUser              = `SELECT id, name, created_at FROM app_users WHERE id = $1`
 136-	sqlSelectUserForName       = `SELECT id, name, created_at FROM app_users WHERE name = $1`
 137-	sqlSelectUserForNameAndKey = `SELECT app_users.id, app_users.name, app_users.created_at, public_keys.id as pk_id, public_keys.public_key, public_keys.created_at as pk_created_at FROM app_users LEFT JOIN public_keys ON public_keys.user_id = app_users.id WHERE app_users.name = $1 AND public_keys.public_key = $2`
 138-	sqlSelectUsers             = `SELECT id, name, created_at FROM app_users ORDER BY name ASC`
 139-
 140-	sqlSelectUserForToken = `
 141-	SELECT app_users.id, app_users.name, app_users.created_at
 142-	FROM app_users
 143-	LEFT JOIN tokens ON tokens.user_id = app_users.id
 144-	WHERE tokens.token = $1 AND tokens.expires_at > NOW()`
 145-	sqlInsertToken              = `INSERT INTO tokens (user_id, name) VALUES($1, $2) RETURNING token;`
 146-	sqlRemoveToken              = `DELETE FROM tokens WHERE id = $1`
 147-	sqlSelectTokensForUser      = `SELECT id, user_id, name, created_at, expires_at FROM tokens WHERE user_id = $1`
 148-	sqlSelectTokenByNameForUser = `SELECT token FROM tokens WHERE user_id = $1 AND name = $2`
 149-
 150-	sqlSelectFeatureForUser = `SELECT id, user_id, payment_history_id, name, data, created_at, expires_at FROM feature_flags WHERE user_id = $1 AND name = $2 ORDER BY expires_at DESC LIMIT 1`
 151-	sqlSelectSizeForUser    = `SELECT COALESCE(sum(file_size), 0) FROM posts WHERE user_id = $1`
 152-
 153-	sqlSelectPostIdByAliasSlug = `SELECT post_id FROM post_aliases WHERE slug = $1`
 154-	sqlSelectTagPostCount      = `
 155-	SELECT count(posts.id)
 156-	FROM posts
 157-	LEFT JOIN post_tags ON post_tags.post_id = posts.id
 158-	WHERE hidden = FALSE AND cur_space=$1 and post_tags.name = $2`
 159-	sqlSelectPostCount       = `SELECT count(id) FROM posts WHERE hidden = FALSE AND cur_space=$1`
 160-	sqlSelectAllUpdatedPosts = `
 161-	SELECT
 162-		posts.id,
 163-		user_id,
 164-		filename,
 165-		slug,
 166-		title,
 167-		text,
 168-		description,
 169-		publish_at,
 170-		app_users.name as username,
 171-		posts.updated_at,
 172-		posts.mime_type
 173-	FROM posts
 174-	LEFT JOIN app_users ON app_users.id = posts.user_id
 175-	WHERE hidden = FALSE AND publish_at::date <= CURRENT_DATE AND cur_space = $3
 176-	ORDER BY updated_at DESC
 177-	LIMIT $1 OFFSET $2`
 178-	// add some users to deny list since they are robogenerating a bunch of posts
 179-	// per day and are creating a lot of noise.
 180-	sqlSelectPostsByRank = `
 181-	SELECT *
 182-	FROM (
 183-	    SELECT DISTINCT ON (posts.user_id)
 184-	        posts.id,
 185-	        posts.user_id,
 186-	        posts.filename,
 187-	        posts.slug,
 188-	        posts.title,
 189-	        posts.text,
 190-	        posts.description,
 191-	        posts.publish_at,
 192-	        app_users.name AS username,
 193-	        posts.updated_at,
 194-	        posts.mime_type
 195-	    FROM posts
 196-	    LEFT JOIN app_users ON app_users.id = posts.user_id
 197-	    WHERE
 198-	        hidden = FALSE
 199-	        AND publish_at::date <= CURRENT_DATE
 200-	        AND cur_space = $3
 201-	    ORDER BY posts.user_id, publish_at DESC
 202-	) AS latest_posts
 203-	ORDER BY publish_at DESC
 204-	LIMIT $1 OFFSET $2`
 205-
 206-	sqlSelectPopularTags = `
 207-	SELECT name, count(post_id) as "tally"
 208-	FROM post_tags
 209-	LEFT JOIN posts ON posts.id = post_id
 210-	WHERE posts.cur_space = $1
 211-	GROUP BY name
 212-	ORDER BY tally DESC
 213-	LIMIT 5`
 214-	sqlSelectTagsForUser = `
 215-	SELECT name
 216-	FROM post_tags
 217-	LEFT JOIN posts ON posts.id = post_id
 218-	WHERE posts.user_id = $1 AND posts.cur_space = $2
 219-	GROUP BY name`
 220-	sqlSelectTagsForPost     = `SELECT name FROM post_tags WHERE post_id=$1`
 221-	sqlSelectFeedItemsByPost = `SELECT id, post_id, guid, data, created_at FROM feed_items WHERE post_id=$1`
 222-
 223-	sqlInsertPost = `
 224-	INSERT INTO posts
 225-		(user_id, filename, slug, title, text, description, publish_at, hidden, cur_space,
 226-		file_size, mime_type, shasum, data, expires_at, updated_at)
 227-	VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
 228-	RETURNING id`
 229-	sqlInsertUser      = `INSERT INTO app_users (name) VALUES($1) returning id`
 230-	sqlInsertTag       = `INSERT INTO post_tags (post_id, name) VALUES($1, $2) RETURNING id;`
 231-	sqlInsertAliases   = `INSERT INTO post_aliases (post_id, slug) VALUES($1, $2) RETURNING id;`
 232-	sqlInsertFeedItems = `INSERT INTO feed_items (post_id, guid, data) VALUES ($1, $2, $3) RETURNING id;`
 233-
 234-	sqlUpdatePost = `
 235-	UPDATE posts
 236-	SET slug = $1, title = $2, text = $3, description = $4, updated_at = $5, publish_at = $6,
 237-		file_size = $7, shasum = $8, data = $9, hidden = $11, expires_at = $12
 238-	WHERE id = $10`
 239-	sqlUpdateUserName = `UPDATE app_users SET name = $1 WHERE id = $2`
 240-
 241-	sqlRemoveAliasesByPost = `DELETE FROM post_aliases WHERE post_id = $1`
 242-	sqlRemoveTagsByPost    = `DELETE FROM post_tags WHERE post_id = $1`
 243-	sqlRemovePosts         = `DELETE FROM posts WHERE id = ANY($1::uuid[])`
 244-	sqlRemoveKeys          = `DELETE FROM public_keys WHERE id = ANY($1::uuid[])`
 245-	sqlRemoveUsers         = `DELETE FROM app_users WHERE id = ANY($1::uuid[])`
 246-
 247-	sqlInsertProject     = `INSERT INTO projects (user_id, name, project_dir) VALUES ($1, $2, $3) RETURNING id;`
 248-	sqlUpdateProject     = `UPDATE projects SET updated_at = $3 WHERE user_id = $1 AND name = $2;`
 249-	sqlFindProjectByName = `SELECT id, user_id, name, project_dir, acl, blocked, created_at, updated_at FROM projects WHERE user_id = $1 AND name = $2;`
 250-)
 251-
 252 type PsqlDB struct {
 253 	Logger *slog.Logger
 254-	Db     *sql.DB
 255+	Db     *sqlx.DB
 256 }
 257 
 258 type RowScanner interface {
 259 	Scan(dest ...any) error
 260 }
 261 
 262-func CreatePostFromRow(r RowScanner) (*db.Post, error) {
 263-	post := &db.Post{}
 264-	err := r.Scan(
 265-		&post.ID,
 266-		&post.UserID,
 267-		&post.Username,
 268-		&post.Filename,
 269-		&post.Slug,
 270-		&post.Title,
 271-		&post.Text,
 272-		&post.Description,
 273-		&post.CreatedAt,
 274-		&post.PublishAt,
 275-		&post.UpdatedAt,
 276-		&post.Hidden,
 277-		&post.FileSize,
 278-		&post.MimeType,
 279-		&post.Shasum,
 280-		&post.Data,
 281-		&post.ExpiresAt,
 282-		&post.Views,
 283-	)
 284-	if err != nil {
 285-		return nil, err
 286-	}
 287-	return post, nil
 288-}
 289-
 290-func CreatePostWithTagsFromRow(r RowScanner) (*db.Post, error) {
 291+func CreatePostWithTagsByRow(r RowScanner) (*db.Post, error) {
 292 	post := &db.Post{}
 293 	tagStr := ""
 294 	err := r.Scan(
 295@@ -338,7 +79,7 @@ func NewDB(databaseUrl string, logger *slog.Logger) *PsqlDB {
 296 	}
 297 	d.Logger.Info("Connecting to postgres", "databaseUrl", databaseUrl)
 298 
 299-	db, err := sql.Open("postgres", databaseUrl)
 300+	db, err := sqlx.Connect("postgres", databaseUrl)
 301 	if err != nil {
 302 		d.Logger.Error(err.Error())
 303 	}
 304@@ -348,35 +89,26 @@ func NewDB(databaseUrl string, logger *slog.Logger) *PsqlDB {
 305 
 306 func (me *PsqlDB) RegisterUser(username, pubkey, comment string) (*db.User, error) {
 307 	lowerName := strings.ToLower(username)
 308-	valid, err := me.ValidateName(lowerName)
 309+	valid, err := me.validateName(lowerName)
 310 	if !valid {
 311 		return nil, err
 312 	}
 313 
 314-	ctx := context.Background()
 315-	tx, err := me.Db.BeginTx(ctx, nil)
 316-	if err != nil {
 317-		return nil, err
 318-	}
 319-	defer func() {
 320-		err = tx.Rollback()
 321-	}()
 322-
 323-	stmt, err := tx.Prepare(sqlInsertUser)
 324+	tx, err := me.Db.Beginx()
 325 	if err != nil {
 326 		return nil, err
 327 	}
 328 	defer func() {
 329-		_ = stmt.Close()
 330+		_ = tx.Rollback()
 331 	}()
 332 
 333 	var id string
 334-	err = stmt.QueryRow(lowerName).Scan(&id)
 335+	err = tx.QueryRow(`INSERT INTO app_users (name) VALUES($1) returning id`, lowerName).Scan(&id)
 336 	if err != nil {
 337 		return nil, err
 338 	}
 339 
 340-	err = me.InsertPublicKey(id, pubkey, comment, tx)
 341+	err = me.insertPublicKeyWithTx(id, pubkey, comment, tx)
 342 	if err != nil {
 343 		return nil, err
 344 	}
 345@@ -386,36 +118,31 @@ func (me *PsqlDB) RegisterUser(username, pubkey, comment string) (*db.User, erro
 346 		return nil, err
 347 	}
 348 
 349-	return me.FindUserForKey(username, pubkey)
 350+	return me.FindUserByKey(username, pubkey)
 351 }
 352 
 353-func (me *PsqlDB) RemoveUsers(userIDs []string) error {
 354-	param := "{" + strings.Join(userIDs, ",") + "}"
 355-	_, err := me.Db.Exec(sqlRemoveUsers, param)
 356+func (me *PsqlDB) insertPublicKeyWithTx(userID, key, name string, tx *sqlx.Tx) error {
 357+	pk, _ := me.findPublicKeyByKey(key)
 358+	if pk != nil {
 359+		return db.ErrPublicKeyTaken
 360+	}
 361+	query := `INSERT INTO public_keys (user_id, public_key, name) VALUES ($1, $2, $3)`
 362+	_, err := tx.Exec(query, userID, key, name)
 363 	return err
 364 }
 365 
 366-func (me *PsqlDB) InsertPublicKey(userID, key, name string, tx *sql.Tx) error {
 367-	pk, _ := me.FindPublicKeyForKey(key)
 368+func (me *PsqlDB) InsertPublicKey(userID, key, name string) error {
 369+	pk, _ := me.findPublicKeyByKey(key)
 370 	if pk != nil {
 371 		return db.ErrPublicKeyTaken
 372 	}
 373 	query := `INSERT INTO public_keys (user_id, public_key, name) VALUES ($1, $2, $3)`
 374-	var err error
 375-	if tx != nil {
 376-		_, err = tx.Exec(query, userID, key, name)
 377-	} else {
 378-		_, err = me.Db.Exec(query, userID, key, name)
 379-	}
 380-	if err != nil {
 381-		return err
 382-	}
 383-
 384-	return nil
 385+	_, err := me.Db.Exec(query, userID, key, name)
 386+	return err
 387 }
 388 
 389 func (me *PsqlDB) UpdatePublicKey(pubkeyID, name string) (*db.PublicKey, error) {
 390-	pk, err := me.FindPublicKey(pubkeyID)
 391+	pk, err := me.findPublicKey(pubkeyID)
 392 	if err != nil {
 393 		return nil, err
 394 	}
 395@@ -426,19 +153,20 @@ func (me *PsqlDB) UpdatePublicKey(pubkeyID, name string) (*db.PublicKey, error)
 396 		return nil, err
 397 	}
 398 
 399-	pk, err = me.FindPublicKey(pubkeyID)
 400+	pk, err = me.findPublicKey(pubkeyID)
 401 	if err != nil {
 402 		return nil, err
 403 	}
 404 	return pk, nil
 405 }
 406 
 407-func (me *PsqlDB) FindPublicKeyForKey(key string) (*db.PublicKey, error) {
 408+func (me *PsqlDB) findPublicKeyByKey(key string) (*db.PublicKey, error) {
 409 	var keys []*db.PublicKey
 410-	rs, err := me.Db.Query(sqlSelectPublicKey, key)
 411+	rs, err := me.Db.Queryx(`SELECT id, user_id, name, public_key, created_at FROM public_keys WHERE public_key = $1`, key)
 412 	if err != nil {
 413 		return nil, err
 414 	}
 415+	defer func() { _ = rs.Close() }()
 416 
 417 	for rs.Next() {
 418 		pk := &db.PublicKey{}
 419@@ -458,7 +186,7 @@ func (me *PsqlDB) FindPublicKeyForKey(key string) (*db.PublicKey, error) {
 420 		return nil, fmt.Errorf("pubkey not found in our database: [%s]", key)
 421 	}
 422 
 423-	// When we run PublicKeyForKey and there are multiple public keys returned from the database
 424+	// When we run PublicKeyByKey and there are multiple public keys returned from the database
 425 	// that should mean that we don't have the correct username for this public key.
 426 	// When that happens we need to reject the authentication and ask the user to provide the correct
 427 	// username when using ssh.  So instead of `ssh <domain>` it should be `ssh user@<domain>`
 428@@ -469,86 +197,33 @@ func (me *PsqlDB) FindPublicKeyForKey(key string) (*db.PublicKey, error) {
 429 	return keys[0], nil
 430 }
 431 
 432-func (me *PsqlDB) FindPublicKey(pubkeyID string) (*db.PublicKey, error) {
 433-	var keys []*db.PublicKey
 434-	rs, err := me.Db.Query(`SELECT id, user_id, name, public_key, created_at FROM public_keys WHERE id = $1`, pubkeyID)
 435+func (me *PsqlDB) findPublicKey(pubkeyID string) (*db.PublicKey, error) {
 436+	pk := &db.PublicKey{}
 437+	err := me.Db.Get(pk, `SELECT * FROM public_keys WHERE id = $1`, pubkeyID)
 438 	if err != nil {
 439 		return nil, err
 440 	}
 441-
 442-	for rs.Next() {
 443-		pk := &db.PublicKey{}
 444-		err := rs.Scan(&pk.ID, &pk.UserID, &pk.Name, &pk.Key, &pk.CreatedAt)
 445-		if err != nil {
 446-			return nil, err
 447-		}
 448-
 449-		keys = append(keys, pk)
 450-	}
 451-
 452-	if rs.Err() != nil {
 453-		return nil, rs.Err()
 454-	}
 455-
 456-	if len(keys) == 0 {
 457-		return nil, errors.New("no public keys found for key provided")
 458-	}
 459-
 460-	return keys[0], nil
 461+	return pk, nil
 462 }
 463 
 464-func (me *PsqlDB) FindKeysForUser(user *db.User) ([]*db.PublicKey, error) {
 465+func (me *PsqlDB) FindKeysByUser(user *db.User) ([]*db.PublicKey, error) {
 466 	var keys []*db.PublicKey
 467-	rs, err := me.Db.Query(sqlSelectPublicKeys, user.ID)
 468+	err := me.Db.Select(&keys, `SELECT * FROM public_keys WHERE user_id = $1 ORDER BY created_at ASC`, user.ID)
 469 	if err != nil {
 470-		return keys, err
 471-	}
 472-	for rs.Next() {
 473-		pk := &db.PublicKey{}
 474-		err := rs.Scan(&pk.ID, &pk.UserID, &pk.Name, &pk.Key, &pk.CreatedAt)
 475-		if err != nil {
 476-			return keys, err
 477-		}
 478-
 479-		keys = append(keys, pk)
 480-	}
 481-	if rs.Err() != nil {
 482-		return keys, rs.Err()
 483+		return nil, err
 484 	}
 485 	return keys, nil
 486 }
 487 
 488 func (me *PsqlDB) RemoveKeys(keyIDs []string) error {
 489 	param := "{" + strings.Join(keyIDs, ",") + "}"
 490-	_, err := me.Db.Exec(sqlRemoveKeys, param)
 491+	_, err := me.Db.Exec(`DELETE FROM public_keys WHERE id = ANY($1::uuid[])`, param)
 492 	return err
 493 }
 494 
 495-func (me *PsqlDB) FindPostsBeforeDate(date *time.Time, space string) ([]*db.Post, error) {
 496-	// now := time.Now()
 497-	// expired := now.AddDate(0, 0, -3)
 498-	var posts []*db.Post
 499-	rs, err := me.Db.Query(sqlSelectPostsBeforeDate, date, space)
 500-	if err != nil {
 501-		return posts, err
 502-	}
 503-	for rs.Next() {
 504-		post, err := CreatePostFromRow(rs)
 505-		if err != nil {
 506-			return nil, err
 507-		}
 508-
 509-		posts = append(posts, post)
 510-	}
 511-	if rs.Err() != nil {
 512-		return posts, rs.Err()
 513-	}
 514-	return posts, nil
 515-}
 516-
 517 func (me *PsqlDB) FindUsersWithPost(space string) ([]*db.User, error) {
 518 	var users []*db.User
 519-	rs, err := me.Db.Query(
 520+	rs, err := me.Db.Queryx(
 521 		`SELECT u.id, u.name, u.created_at
 522 		FROM app_users u
 523 		INNER JOIN posts ON u.id=posts.user_id
 524@@ -559,6 +234,7 @@ func (me *PsqlDB) FindUsersWithPost(space string) ([]*db.User, error) {
 525 	if err != nil {
 526 		return users, err
 527 	}
 528+	defer func() { _ = rs.Close() }()
 529 	for rs.Next() {
 530 		var name sql.NullString
 531 		user := &db.User{}
 532@@ -580,9 +256,9 @@ func (me *PsqlDB) FindUsersWithPost(space string) ([]*db.User, error) {
 533 	return users, nil
 534 }
 535 
 536-func (me *PsqlDB) FindUserForKey(username string, key string) (*db.User, error) {
 537+func (me *PsqlDB) FindUserByKey(username string, key string) (*db.User, error) {
 538 	me.Logger.Info("attempting to find user with only public key", "key", key)
 539-	pk, err := me.FindPublicKeyForKey(key)
 540+	pk, err := me.findPublicKeyByKey(key)
 541 	if err == nil {
 542 		me.Logger.Info("found pubkey, looking for user", "key", key, "userId", pk.UserID)
 543 		user, err := me.FindUser(pk.UserID)
 544@@ -595,7 +271,7 @@ func (me *PsqlDB) FindUserForKey(username string, key string) (*db.User, error)
 545 
 546 	if errors.Is(err, &db.ErrMultiplePublicKeys{}) {
 547 		me.Logger.Info("detected multiple users with same public key", "user", username)
 548-		user, err := me.FindUserForNameAndKey(username, key)
 549+		user, err := me.findUserForNameAndKey(username, key)
 550 		if err != nil {
 551 			me.Logger.Info("could not find user by username and public key", "user", username, "key", key)
 552 			// this is a little hacky but if we cannot find a user by name and public key
 553@@ -611,7 +287,7 @@ func (me *PsqlDB) FindUserForKey(username string, key string) (*db.User, error)
 554 
 555 func (me *PsqlDB) FindUserByPubkey(key string) (*db.User, error) {
 556 	me.Logger.Info("attempting to find user with only public key", "key", key)
 557-	pk, err := me.FindPublicKeyForKey(key)
 558+	pk, err := me.findPublicKeyByKey(key)
 559 	if err != nil {
 560 		return nil, err
 561 	}
 562@@ -627,19 +303,14 @@ func (me *PsqlDB) FindUserByPubkey(key string) (*db.User, error) {
 563 
 564 func (me *PsqlDB) FindUser(userID string) (*db.User, error) {
 565 	user := &db.User{}
 566-	var un sql.NullString
 567-	r := me.Db.QueryRow(sqlSelectUser, userID)
 568-	err := r.Scan(&user.ID, &un, &user.CreatedAt)
 569+	err := me.Db.Get(user, `SELECT id, COALESCE(name, '') as name, created_at FROM app_users WHERE id = $1`, userID)
 570 	if err != nil {
 571 		return nil, err
 572 	}
 573-	if un.Valid {
 574-		user.Name = un.String
 575-	}
 576 	return user, nil
 577 }
 578 
 579-func (me *PsqlDB) ValidateName(name string) (bool, error) {
 580+func (me *PsqlDB) validateName(name string) (bool, error) {
 581 	lower := strings.ToLower(name)
 582 	if slices.Contains(db.DenyList, lower) {
 583 		return false, fmt.Errorf("%s is on deny list: %w", lower, db.ErrNameDenied)
 584@@ -657,19 +328,18 @@ func (me *PsqlDB) ValidateName(name string) (bool, error) {
 585 
 586 func (me *PsqlDB) FindUserByName(name string) (*db.User, error) {
 587 	user := &db.User{}
 588-	r := me.Db.QueryRow(sqlSelectUserForName, strings.ToLower(name))
 589-	err := r.Scan(&user.ID, &user.Name, &user.CreatedAt)
 590+	err := me.Db.Get(user, `SELECT * FROM app_users WHERE name = $1`, strings.ToLower(name))
 591 	if err != nil {
 592 		return nil, err
 593 	}
 594 	return user, nil
 595 }
 596 
 597-func (me *PsqlDB) FindUserForNameAndKey(name string, key string) (*db.User, error) {
 598+func (me *PsqlDB) findUserForNameAndKey(name string, key string) (*db.User, error) {
 599 	user := &db.User{}
 600 	pk := &db.PublicKey{}
 601 
 602-	r := me.Db.QueryRow(sqlSelectUserForNameAndKey, strings.ToLower(name), key)
 603+	r := me.Db.QueryRow(`SELECT app_users.id, app_users.name, app_users.created_at, public_keys.id as pk_id, public_keys.public_key, public_keys.created_at as pk_created_at FROM app_users LEFT JOIN public_keys ON public_keys.user_id = app_users.id WHERE app_users.name = $1 AND public_keys.public_key = $2`, strings.ToLower(name), key)
 604 	err := r.Scan(&user.ID, &user.Name, &user.CreatedAt, &pk.ID, &pk.Key, &pk.CreatedAt)
 605 	if err != nil {
 606 		return nil, err
 607@@ -679,32 +349,29 @@ func (me *PsqlDB) FindUserForNameAndKey(name string, key string) (*db.User, erro
 608 	return user, nil
 609 }
 610 
 611-func (me *PsqlDB) FindUserForToken(token string) (*db.User, error) {
 612+func (me *PsqlDB) FindUserByToken(token string) (*db.User, error) {
 613 	user := &db.User{}
 614-
 615-	r := me.Db.QueryRow(sqlSelectUserForToken, token)
 616-	err := r.Scan(&user.ID, &user.Name, &user.CreatedAt)
 617+	err := me.Db.Get(user, `
 618+	SELECT app_users.id, app_users.name, app_users.created_at
 619+	FROM app_users
 620+	LEFT JOIN tokens ON tokens.user_id = app_users.id
 621+	WHERE tokens.token = $1 AND tokens.expires_at > NOW()`, token)
 622 	if err != nil {
 623 		return nil, err
 624 	}
 625-
 626 	return user, nil
 627 }
 628 
 629-func (me *PsqlDB) SetUserName(userID string, name string) error {
 630-	lowerName := strings.ToLower(name)
 631-	valid, err := me.ValidateName(lowerName)
 632-	if !valid {
 633-		return err
 634-	}
 635-
 636-	_, err = me.Db.Exec(sqlUpdateUserName, lowerName, userID)
 637-	return err
 638-}
 639-
 640 func (me *PsqlDB) FindPostWithFilename(filename string, persona_id string, space string) (*db.Post, error) {
 641-	r := me.Db.QueryRow(sqlSelectPostWithFilename, filename, persona_id, space)
 642-	post, err := CreatePostWithTagsFromRow(r)
 643+	query := fmt.Sprintf(`
 644+	SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
 645+	FROM posts
 646+	LEFT JOIN app_users ON app_users.id = posts.user_id
 647+	LEFT JOIN post_tags ON post_tags.post_id = posts.id
 648+	WHERE filename = $1 AND user_id = $2 AND cur_space = $3
 649+	GROUP BY %s`, SelectPost, SelectPost)
 650+	r := me.Db.QueryRow(query, filename, persona_id, space)
 651+	post, err := CreatePostWithTagsByRow(r)
 652 	if err != nil {
 653 		return nil, err
 654 	}
 655@@ -713,11 +380,18 @@ func (me *PsqlDB) FindPostWithFilename(filename string, persona_id string, space
 656 }
 657 
 658 func (me *PsqlDB) FindPostWithSlug(slug string, user_id string, space string) (*db.Post, error) {
 659-	r := me.Db.QueryRow(sqlSelectPostWithSlug, slug, user_id, space)
 660-	post, err := CreatePostWithTagsFromRow(r)
 661+	query := fmt.Sprintf(`
 662+	SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
 663+	FROM posts
 664+	LEFT JOIN app_users ON app_users.id = posts.user_id
 665+	LEFT JOIN post_tags ON post_tags.post_id = posts.id
 666+	WHERE slug = $1 AND user_id = $2 AND cur_space = $3
 667+	GROUP BY %s`, SelectPost, SelectPost)
 668+	r := me.Db.QueryRow(query, slug, user_id, space)
 669+	post, err := CreatePostWithTagsByRow(r)
 670 	if err != nil {
 671 		// attempt to find post inside post_aliases
 672-		alias := me.Db.QueryRow(sqlSelectPostIdByAliasSlug, slug)
 673+		alias := me.Db.QueryRow(`SELECT post_id FROM post_aliases WHERE slug = $1`, slug)
 674 		postID := ""
 675 		err := alias.Scan(&postID)
 676 		if err != nil {
 677@@ -731,16 +405,20 @@ func (me *PsqlDB) FindPostWithSlug(slug string, user_id string, space string) (*
 678 }
 679 
 680 func (me *PsqlDB) FindPost(postID string) (*db.Post, error) {
 681-	r := me.Db.QueryRow(sqlSelectPost, postID)
 682-	post, err := CreatePostFromRow(r)
 683+	post := &db.Post{}
 684+	query := fmt.Sprintf(`
 685+	SELECT %s
 686+	FROM posts
 687+	LEFT JOIN app_users ON app_users.id = posts.user_id
 688+	WHERE posts.id = $1`, SelectPost)
 689+	err := me.Db.Get(post, query, postID)
 690 	if err != nil {
 691 		return nil, err
 692 	}
 693-
 694 	return post, nil
 695 }
 696 
 697-func (me *PsqlDB) postPager(rs *sql.Rows, pageNum int, space string, tag string) (*db.Paginate[*db.Post], error) {
 698+func (me *PsqlDB) postPager(rs *sqlx.Rows, pageNum int, space string, tag string) (*db.Paginate[*db.Post], error) {
 699 	var posts []*db.Post
 700 	for rs.Next() {
 701 		post := &db.Post{}
 702@@ -770,9 +448,13 @@ func (me *PsqlDB) postPager(rs *sql.Rows, pageNum int, space string, tag string)
 703 	var count int
 704 	var err error
 705 	if tag == "" {
 706-		err = me.Db.QueryRow(sqlSelectPostCount, space).Scan(&count)
 707+		err = me.Db.QueryRow(`SELECT count(id) FROM posts WHERE hidden = FALSE AND cur_space=$1`, space).Scan(&count)
 708 	} else {
 709-		err = me.Db.QueryRow(sqlSelectTagPostCount, space, tag).Scan(&count)
 710+		err = me.Db.QueryRow(`
 711+	SELECT count(posts.id)
 712+	FROM posts
 713+	LEFT JOIN post_tags ON post_tags.post_id = posts.id
 714+	WHERE hidden = FALSE AND cur_space=$1 and post_tags.name = $2`, space, tag).Scan(&count)
 715 	}
 716 	if err != nil {
 717 		return nil, err
 718@@ -786,26 +468,50 @@ func (me *PsqlDB) postPager(rs *sql.Rows, pageNum int, space string, tag string)
 719 	return pager, nil
 720 }
 721 
 722-func (me *PsqlDB) FindPostsForFeed(page *db.Pager, space string) (*db.Paginate[*db.Post], error) {
 723-	rs, err := me.Db.Query(sqlSelectPostsByRank, page.Num, page.Num*page.Page, space)
 724-	if err != nil {
 725-		return nil, err
 726-	}
 727-	return me.postPager(rs, page.Num, space, "")
 728-}
 729-
 730-func (me *PsqlDB) FindAllUpdatedPosts(page *db.Pager, space string) (*db.Paginate[*db.Post], error) {
 731-	rs, err := me.Db.Query(sqlSelectAllUpdatedPosts, page.Num, page.Num*page.Page, space)
 732+func (me *PsqlDB) FindPostsByFeed(page *db.Pager, space string) (*db.Paginate[*db.Post], error) {
 733+	query := `
 734+	SELECT *
 735+	FROM (
 736+	    SELECT DISTINCT ON (posts.user_id)
 737+	        posts.id,
 738+	        posts.user_id,
 739+	        posts.filename,
 740+	        posts.slug,
 741+	        posts.title,
 742+	        posts.text,
 743+	        posts.description,
 744+	        posts.publish_at,
 745+	        app_users.name AS username,
 746+	        posts.updated_at,
 747+	        posts.mime_type
 748+	    FROM posts
 749+	    LEFT JOIN app_users ON app_users.id = posts.user_id
 750+	    WHERE
 751+	        hidden = FALSE
 752+	        AND publish_at::date <= CURRENT_DATE
 753+	        AND cur_space = $3
 754+	    ORDER BY posts.user_id, publish_at DESC
 755+	) AS latest_posts
 756+	ORDER BY publish_at DESC
 757+	LIMIT $1 OFFSET $2`
 758+	rs, err := me.Db.Queryx(query, page.Num, page.Num*page.Page, space)
 759 	if err != nil {
 760 		return nil, err
 761 	}
 762+	defer func() { _ = rs.Close() }()
 763 	return me.postPager(rs, page.Num, space, "")
 764 }
 765 
 766 func (me *PsqlDB) InsertPost(post *db.Post) (*db.Post, error) {
 767 	var id string
 768+	query := `
 769+	INSERT INTO posts
 770+		(user_id, filename, slug, title, text, description, publish_at, hidden, cur_space,
 771+		file_size, mime_type, shasum, data, expires_at, updated_at)
 772+	VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
 773+	RETURNING id`
 774 	err := me.Db.QueryRow(
 775-		sqlInsertPost,
 776+		query,
 777 		post.UserID,
 778 		post.Filename,
 779 		post.Slug,
 780@@ -830,8 +536,13 @@ func (me *PsqlDB) InsertPost(post *db.Post) (*db.Post, error) {
 781 }
 782 
 783 func (me *PsqlDB) UpdatePost(post *db.Post) (*db.Post, error) {
 784+	query := `
 785+	UPDATE posts
 786+	SET slug = $1, title = $2, text = $3, description = $4, updated_at = $5, publish_at = $6,
 787+		file_size = $7, shasum = $8, data = $9, hidden = $11, expires_at = $12
 788+	WHERE id = $10`
 789 	_, err := me.Db.Exec(
 790-		sqlUpdatePost,
 791+		query,
 792 		post.Slug,
 793 		post.Title,
 794 		post.Text,
 795@@ -854,14 +565,27 @@ func (me *PsqlDB) UpdatePost(post *db.Post) (*db.Post, error) {
 796 
 797 func (me *PsqlDB) RemovePosts(postIDs []string) error {
 798 	param := "{" + strings.Join(postIDs, ",") + "}"
 799-	_, err := me.Db.Exec(sqlRemovePosts, param)
 800+	_, err := me.Db.Exec(`DELETE FROM posts WHERE id = ANY($1::uuid[])`, param)
 801 	return err
 802 }
 803 
 804-func (me *PsqlDB) FindPostsForUser(page *db.Pager, userID string, space string) (*db.Paginate[*db.Post], error) {
 805+func (me *PsqlDB) FindPostsByUser(page *db.Pager, userID string, space string) (*db.Paginate[*db.Post], error) {
 806 	var posts []*db.Post
 807-	rs, err := me.Db.Query(
 808-		sqlSelectPostsForUser,
 809+	query := fmt.Sprintf(`
 810+	SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
 811+	FROM posts
 812+	LEFT JOIN app_users ON app_users.id = posts.user_id
 813+	LEFT JOIN post_tags ON post_tags.post_id = posts.id
 814+	WHERE
 815+		hidden = FALSE AND
 816+		user_id = $1 AND
 817+		publish_at::date <= CURRENT_DATE AND
 818+		cur_space = $2
 819+	GROUP BY %s
 820+	ORDER BY publish_at DESC, slug DESC
 821+	LIMIT $3 OFFSET $4`, SelectPost, SelectPost)
 822+	rs, err := me.Db.Queryx(
 823+		query,
 824 		userID,
 825 		space,
 826 		page.Num,
 827@@ -870,8 +594,9 @@ func (me *PsqlDB) FindPostsForUser(page *db.Pager, userID string, space string)
 828 	if err != nil {
 829 		return nil, err
 830 	}
 831+	defer func() { _ = rs.Close() }()
 832 	for rs.Next() {
 833-		post, err := CreatePostWithTagsFromRow(rs)
 834+		post, err := CreatePostWithTagsByRow(rs)
 835 		if err != nil {
 836 			return nil, err
 837 		}
 838@@ -884,7 +609,7 @@ func (me *PsqlDB) FindPostsForUser(page *db.Pager, userID string, space string)
 839 	}
 840 
 841 	var count int
 842-	err = me.Db.QueryRow(sqlSelectPostCount, space).Scan(&count)
 843+	err = me.Db.QueryRow(`SELECT count(id) FROM posts WHERE hidden = FALSE AND cur_space=$1`, space).Scan(&count)
 844 	if err != nil {
 845 		return nil, err
 846 	}
 847@@ -896,82 +621,49 @@ func (me *PsqlDB) FindPostsForUser(page *db.Pager, userID string, space string)
 848 	return pager, nil
 849 }
 850 
 851-func (me *PsqlDB) FindAllPostsForUser(userID string, space string) ([]*db.Post, error) {
 852-	var posts []*db.Post
 853-	rs, err := me.Db.Query(sqlSelectAllPostsForUser, userID, space)
 854-	if err != nil {
 855-		return posts, err
 856-	}
 857-	for rs.Next() {
 858-		post, err := CreatePostFromRow(rs)
 859-		if err != nil {
 860-			return nil, err
 861-		}
 862-
 863-		posts = append(posts, post)
 864-	}
 865-	if rs.Err() != nil {
 866-		return posts, rs.Err()
 867-	}
 868-	return posts, nil
 869-}
 870-
 871-func (me *PsqlDB) FindPosts() ([]*db.Post, error) {
 872+func (me *PsqlDB) FindAllPostsByUser(userID string, space string) ([]*db.Post, error) {
 873 	var posts []*db.Post
 874-	rs, err := me.Db.Query(sqlSelectPosts)
 875-	if err != nil {
 876-		return posts, err
 877-	}
 878-	for rs.Next() {
 879-		post, err := CreatePostFromRow(rs)
 880-		if err != nil {
 881-			return nil, err
 882-		}
 883-
 884-		posts = append(posts, post)
 885-	}
 886-	if rs.Err() != nil {
 887-		return posts, rs.Err()
 888+	query := fmt.Sprintf(`
 889+	SELECT %s
 890+	FROM posts
 891+	LEFT JOIN app_users ON app_users.id = posts.user_id
 892+	WHERE
 893+		user_id = $1 AND
 894+		cur_space = $2
 895+	ORDER BY publish_at DESC`, SelectPost)
 896+	err := me.Db.Select(&posts, query, userID, space)
 897+	if err != nil {
 898+		return nil, err
 899 	}
 900 	return posts, nil
 901 }
 902 
 903-func (me *PsqlDB) FindExpiredPosts(space string) ([]*db.Post, error) {
 904+func (me *PsqlDB) FindPosts() ([]*db.Post, error) {
 905 	var posts []*db.Post
 906-	rs, err := me.Db.Query(sqlSelectExpiredPosts, space)
 907+	query := fmt.Sprintf(`
 908+	SELECT %s
 909+	FROM posts
 910+	LEFT JOIN app_users ON app_users.id = posts.user_id`, SelectPost)
 911+	err := me.Db.Select(&posts, query)
 912 	if err != nil {
 913-		return posts, err
 914-	}
 915-	for rs.Next() {
 916-		post, err := CreatePostFromRow(rs)
 917-		if err != nil {
 918-			return nil, err
 919-		}
 920-
 921-		posts = append(posts, post)
 922-	}
 923-	if rs.Err() != nil {
 924-		return posts, rs.Err()
 925+		return nil, err
 926 	}
 927 	return posts, nil
 928 }
 929 
 930-func (me *PsqlDB) FindUpdatedPostsForUser(userID string, space string) ([]*db.Post, error) {
 931+func (me *PsqlDB) FindExpiredPosts(space string) ([]*db.Post, error) {
 932 	var posts []*db.Post
 933-	rs, err := me.Db.Query(sqlSelectUpdatedPostsForUser, userID, space)
 934+	query := fmt.Sprintf(`
 935+		SELECT %s
 936+		FROM posts
 937+		LEFT JOIN app_users ON app_users.id = posts.user_id
 938+		WHERE
 939+			cur_space = $1 AND
 940+			expires_at <= now();
 941+	`, SelectPost)
 942+	err := me.Db.Select(&posts, query, space)
 943 	if err != nil {
 944-		return posts, err
 945-	}
 946-	for rs.Next() {
 947-		post, err := CreatePostFromRow(rs)
 948-		if err != nil {
 949-			return nil, err
 950-		}
 951-
 952-		posts = append(posts, post)
 953-	}
 954-	if rs.Err() != nil {
 955-		return posts, rs.Err()
 956+		return nil, err
 957 	}
 958 	return posts, nil
 959 }
 960@@ -1033,10 +725,11 @@ func (me *PsqlDB) visitUnique(opts *db.SummaryOpts) ([]*db.VisitInterval, error)
 961 	GROUP BY interval_start`, opts.Interval, where)
 962 
 963 	intervals := []*db.VisitInterval{}
 964-	rs, err := me.Db.Query(uniqueVisitors, opts.Origin, with, opts.UserID)
 965+	rs, err := me.Db.Queryx(uniqueVisitors, opts.Origin, with, opts.UserID)
 966 	if err != nil {
 967 		return nil, err
 968 	}
 969+	defer func() { _ = rs.Close() }()
 970 
 971 	for rs.Next() {
 972 		interval := &db.VisitInterval{}
 973@@ -1068,10 +761,11 @@ func (me *PsqlDB) visitReferer(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
 974 	LIMIT 10`, where)
 975 
 976 	intervals := []*db.VisitUrl{}
 977-	rs, err := me.Db.Query(topUrls, opts.Origin, with, opts.UserID)
 978+	rs, err := me.Db.Queryx(topUrls, opts.Origin, with, opts.UserID)
 979 	if err != nil {
 980 		return nil, err
 981 	}
 982+	defer func() { _ = rs.Close() }()
 983 
 984 	for rs.Next() {
 985 		interval := &db.VisitUrl{}
 986@@ -1103,10 +797,11 @@ func (me *PsqlDB) visitUrl(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
 987 	LIMIT 10`, where)
 988 
 989 	intervals := []*db.VisitUrl{}
 990-	rs, err := me.Db.Query(topUrls, opts.Origin, with, opts.UserID)
 991+	rs, err := me.Db.Queryx(topUrls, opts.Origin, with, opts.UserID)
 992 	if err != nil {
 993 		return nil, err
 994 	}
 995+	defer func() { _ = rs.Close() }()
 996 
 997 	for rs.Next() {
 998 		interval := &db.VisitUrl{}
 999@@ -1142,10 +837,11 @@ func (me *PsqlDB) VisitUrlNotFound(opts *db.SummaryOpts) ([]*db.VisitUrl, error)
1000 	LIMIT %d`, where, limit)
1001 
1002 	intervals := []*db.VisitUrl{}
1003-	rs, err := me.Db.Query(topUrls, opts.Origin, with, opts.UserID)
1004+	rs, err := me.Db.Queryx(topUrls, opts.Origin, with, opts.UserID)
1005 	if err != nil {
1006 		return nil, err
1007 	}
1008+	defer func() { _ = rs.Close() }()
1009 
1010 	for rs.Next() {
1011 		interval := &db.VisitUrl{}
1012@@ -1175,10 +871,11 @@ func (me *PsqlDB) visitHost(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
1013 	ORDER BY host_count DESC`
1014 
1015 	intervals := []*db.VisitUrl{}
1016-	rs, err := me.Db.Query(topUrls, opts.UserID)
1017+	rs, err := me.Db.Queryx(topUrls, opts.UserID)
1018 	if err != nil {
1019 		return nil, err
1020 	}
1021+	defer func() { _ = rs.Close() }()
1022 
1023 	for rs.Next() {
1024 		interval := &db.VisitUrl{}
1025@@ -1233,41 +930,23 @@ func (me *PsqlDB) FindVisitSiteList(opts *db.SummaryOpts) ([]*db.VisitUrl, error
1026 
1027 func (me *PsqlDB) FindUsers() ([]*db.User, error) {
1028 	var users []*db.User
1029-	rs, err := me.Db.Query(sqlSelectUsers)
1030+	err := me.Db.Select(&users, `SELECT id, COALESCE(name, '') as name, created_at FROM app_users ORDER BY name ASC`)
1031 	if err != nil {
1032-		return users, err
1033-	}
1034-	for rs.Next() {
1035-		var name sql.NullString
1036-		user := &db.User{}
1037-		err := rs.Scan(
1038-			&user.ID,
1039-			&name,
1040-			&user.CreatedAt,
1041-		)
1042-		if err != nil {
1043-			return users, err
1044-		}
1045-		user.Name = name.String
1046-
1047-		users = append(users, user)
1048-	}
1049-	if rs.Err() != nil {
1050-		return users, rs.Err()
1051+		return nil, err
1052 	}
1053 	return users, nil
1054 }
1055 
1056-func (me *PsqlDB) removeTagsForPost(tx *sql.Tx, postID string) error {
1057-	_, err := tx.Exec(sqlRemoveTagsByPost, postID)
1058+func (me *PsqlDB) removeTagsByPost(tx *sqlx.Tx, postID string) error {
1059+	_, err := tx.Exec(`DELETE FROM post_tags WHERE post_id = $1`, postID)
1060 	return err
1061 }
1062 
1063-func (me *PsqlDB) insertTagsForPost(tx *sql.Tx, tags []string, postID string) ([]string, error) {
1064+func (me *PsqlDB) insertTagsByPost(tx *sqlx.Tx, tags []string, postID string) ([]string, error) {
1065 	ids := make([]string, 0)
1066 	for _, tag := range tags {
1067 		id := ""
1068-		err := tx.QueryRow(sqlInsertTag, postID, tag).Scan(&id)
1069+		err := tx.QueryRow(`INSERT INTO post_tags (post_id, name) VALUES($1, $2) RETURNING id;`, postID, tag).Scan(&id)
1070 		if err != nil {
1071 			return nil, err
1072 		}
1073@@ -1277,22 +956,21 @@ func (me *PsqlDB) insertTagsForPost(tx *sql.Tx, tags []string, postID string) ([
1074 	return ids, nil
1075 }
1076 
1077-func (me *PsqlDB) ReplaceTagsForPost(tags []string, postID string) error {
1078-	ctx := context.Background()
1079-	tx, err := me.Db.BeginTx(ctx, nil)
1080+func (me *PsqlDB) ReplaceTagsByPost(tags []string, postID string) error {
1081+	tx, err := me.Db.Beginx()
1082 	if err != nil {
1083 		return err
1084 	}
1085 	defer func() {
1086-		err = tx.Rollback()
1087+		_ = tx.Rollback()
1088 	}()
1089 
1090-	err = me.removeTagsForPost(tx, postID)
1091+	err = me.removeTagsByPost(tx, postID)
1092 	if err != nil {
1093 		return err
1094 	}
1095 
1096-	_, err = me.insertTagsForPost(tx, tags, postID)
1097+	_, err = me.insertTagsByPost(tx, tags, postID)
1098 	if err != nil {
1099 		return err
1100 	}
1101@@ -1301,12 +979,12 @@ func (me *PsqlDB) ReplaceTagsForPost(tags []string, postID string) error {
1102 	return err
1103 }
1104 
1105-func (me *PsqlDB) removeAliasesForPost(tx *sql.Tx, postID string) error {
1106-	_, err := tx.Exec(sqlRemoveAliasesByPost, postID)
1107+func (me *PsqlDB) removeAliasesByPost(tx *sqlx.Tx, postID string) error {
1108+	_, err := tx.Exec(`DELETE FROM post_aliases WHERE post_id = $1`, postID)
1109 	return err
1110 }
1111 
1112-func (me *PsqlDB) insertAliasesForPost(tx *sql.Tx, aliases []string, postID string) ([]string, error) {
1113+func (me *PsqlDB) insertAliasesByPost(tx *sqlx.Tx, aliases []string, postID string) ([]string, error) {
1114 	// hardcoded
1115 	denyList := []string{
1116 		"rss",
1117@@ -1337,7 +1015,7 @@ func (me *PsqlDB) insertAliasesForPost(tx *sql.Tx, aliases []string, postID stri
1118 			continue
1119 		}
1120 		id := ""
1121-		err := tx.QueryRow(sqlInsertAliases, postID, alias).Scan(&id)
1122+		err := tx.QueryRow(`INSERT INTO post_aliases (post_id, slug) VALUES($1, $2) RETURNING id;`, postID, alias).Scan(&id)
1123 		if err != nil {
1124 			return nil, err
1125 		}
1126@@ -1347,22 +1025,21 @@ func (me *PsqlDB) insertAliasesForPost(tx *sql.Tx, aliases []string, postID stri
1127 	return ids, nil
1128 }
1129 
1130-func (me *PsqlDB) ReplaceAliasesForPost(aliases []string, postID string) error {
1131-	ctx := context.Background()
1132-	tx, err := me.Db.BeginTx(ctx, nil)
1133+func (me *PsqlDB) ReplaceAliasesByPost(aliases []string, postID string) error {
1134+	tx, err := me.Db.Beginx()
1135 	if err != nil {
1136 		return err
1137 	}
1138 	defer func() {
1139-		err = tx.Rollback()
1140+		_ = tx.Rollback()
1141 	}()
1142 
1143-	err = me.removeAliasesForPost(tx, postID)
1144+	err = me.removeAliasesByPost(tx, postID)
1145 	if err != nil {
1146 		return err
1147 	}
1148 
1149-	_, err = me.insertAliasesForPost(tx, aliases, postID)
1150+	_, err = me.insertAliasesByPost(tx, aliases, postID)
1151 	if err != nil {
1152 		return err
1153 	}
1154@@ -1373,8 +1050,22 @@ func (me *PsqlDB) ReplaceAliasesForPost(aliases []string, postID string) error {
1155 
1156 func (me *PsqlDB) FindUserPostsByTag(page *db.Pager, tag, userID, space string) (*db.Paginate[*db.Post], error) {
1157 	var posts []*db.Post
1158-	rs, err := me.Db.Query(
1159-		sqlSelectUserPostsByTag,
1160+	query := fmt.Sprintf(`
1161+	SELECT %s
1162+	FROM posts
1163+	LEFT JOIN app_users ON app_users.id = posts.user_id
1164+	LEFT JOIN post_tags ON post_tags.post_id = posts.id
1165+	WHERE
1166+		hidden = FALSE AND
1167+		user_id = $1 AND
1168+		(post_tags.name = $2 OR hidden = true) AND
1169+		publish_at::date <= CURRENT_DATE AND
1170+		cur_space = $3
1171+	ORDER BY publish_at DESC
1172+	LIMIT $4 OFFSET $5`, SelectPost)
1173+	err := me.Db.Select(
1174+		&posts,
1175+		query,
1176 		userID,
1177 		tag,
1178 		space,
1179@@ -1384,21 +1075,9 @@ func (me *PsqlDB) FindUserPostsByTag(page *db.Pager, tag, userID, space string)
1180 	if err != nil {
1181 		return nil, err
1182 	}
1183-	for rs.Next() {
1184-		post, err := CreatePostFromRow(rs)
1185-		if err != nil {
1186-			return nil, err
1187-		}
1188-
1189-		posts = append(posts, post)
1190-	}
1191-
1192-	if rs.Err() != nil {
1193-		return nil, rs.Err()
1194-	}
1195 
1196 	var count int
1197-	err = me.Db.QueryRow(sqlSelectPostCount, space).Scan(&count)
1198+	err = me.Db.QueryRow(`SELECT count(id) FROM posts WHERE hidden = FALSE AND cur_space=$1`, space).Scan(&count)
1199 	if err != nil {
1200 		return nil, err
1201 	}
1202@@ -1411,8 +1090,30 @@ func (me *PsqlDB) FindUserPostsByTag(page *db.Pager, tag, userID, space string)
1203 }
1204 
1205 func (me *PsqlDB) FindPostsByTag(pager *db.Pager, tag, space string) (*db.Paginate[*db.Post], error) {
1206-	rs, err := me.Db.Query(
1207-		sqlSelectPostsByTag,
1208+	query := `
1209+	SELECT
1210+		posts.id,
1211+		user_id,
1212+		filename,
1213+		slug,
1214+		title,
1215+		text,
1216+		description,
1217+		publish_at,
1218+		app_users.name as username,
1219+		posts.updated_at,
1220+		posts.mime_type
1221+	FROM posts
1222+	LEFT JOIN app_users ON app_users.id = posts.user_id
1223+	LEFT JOIN post_tags ON post_tags.post_id = posts.id
1224+	WHERE
1225+		post_tags.name = $3 AND
1226+		publish_at::date <= CURRENT_DATE AND
1227+		cur_space = $4
1228+	ORDER BY publish_at DESC
1229+	LIMIT $1 OFFSET $2`
1230+	rs, err := me.Db.Queryx(
1231+		query,
1232 		pager.Num,
1233 		pager.Num*pager.Page,
1234 		tag,
1235@@ -1421,16 +1122,26 @@ func (me *PsqlDB) FindPostsByTag(pager *db.Pager, tag, space string) (*db.Pagina
1236 	if err != nil {
1237 		return nil, err
1238 	}
1239+	defer func() { _ = rs.Close() }()
1240 
1241 	return me.postPager(rs, pager.Num, space, tag)
1242 }
1243 
1244 func (me *PsqlDB) FindPopularTags(space string) ([]string, error) {
1245 	tags := make([]string, 0)
1246-	rs, err := me.Db.Query(sqlSelectPopularTags, space)
1247+	query := `
1248+	SELECT name, count(post_id) as "tally"
1249+	FROM post_tags
1250+	LEFT JOIN posts ON posts.id = post_id
1251+	WHERE posts.cur_space = $1
1252+	GROUP BY name
1253+	ORDER BY tally DESC
1254+	LIMIT 5`
1255+	rs, err := me.Db.Queryx(query, space)
1256 	if err != nil {
1257 		return tags, err
1258 	}
1259+	defer func() { _ = rs.Close() }()
1260 	for rs.Next() {
1261 		name := ""
1262 		tally := 0
1263@@ -1447,112 +1158,30 @@ func (me *PsqlDB) FindPopularTags(space string) ([]string, error) {
1264 	return tags, nil
1265 }
1266 
1267-func (me *PsqlDB) FindTagsForUser(userID string, space string) ([]string, error) {
1268-	tags := []string{}
1269-	rs, err := me.Db.Query(sqlSelectTagsForUser, userID, space)
1270-	if err != nil {
1271-		return tags, err
1272-	}
1273-	for rs.Next() {
1274-		name := ""
1275-		err := rs.Scan(&name)
1276-		if err != nil {
1277-			return tags, err
1278-		}
1279-
1280-		tags = append(tags, name)
1281-	}
1282-	if rs.Err() != nil {
1283-		return tags, rs.Err()
1284-	}
1285-	return tags, nil
1286-}
1287-
1288-func (me *PsqlDB) FindTagsForPost(postID string) ([]string, error) {
1289-	tags := make([]string, 0)
1290-	rs, err := me.Db.Query(sqlSelectTagsForPost, postID)
1291-	if err != nil {
1292-		return tags, err
1293-	}
1294-
1295-	for rs.Next() {
1296-		name := ""
1297-		err := rs.Scan(&name)
1298-		if err != nil {
1299-			return tags, err
1300-		}
1301-
1302-		tags = append(tags, name)
1303-	}
1304-
1305-	if rs.Err() != nil {
1306-		return tags, rs.Err()
1307-	}
1308-
1309-	return tags, nil
1310-}
1311-
1312 func (me *PsqlDB) FindFeature(userID string, feature string) (*db.FeatureFlag, error) {
1313 	ff := &db.FeatureFlag{}
1314-	// payment history is allowed to be null
1315-	// https://devtidbits.com/2020/08/03/go-sql-error-converting-null-to-string-is-unsupported/
1316-	var paymentHistoryID sql.NullString
1317-	err := me.Db.QueryRow(sqlSelectFeatureForUser, userID, feature).Scan(
1318-		&ff.ID,
1319-		&ff.UserID,
1320-		&paymentHistoryID,
1321-		&ff.Name,
1322-		&ff.Data,
1323-		&ff.CreatedAt,
1324-		&ff.ExpiresAt,
1325-	)
1326+	err := me.Db.Get(ff, `SELECT * FROM feature_flags WHERE user_id = $1 AND name = $2 ORDER BY expires_at DESC LIMIT 1`, userID, feature)
1327 	if err != nil {
1328 		return nil, err
1329 	}
1330-
1331-	ff.PaymentHistoryID = paymentHistoryID
1332-
1333 	return ff, nil
1334 }
1335 
1336-func (me *PsqlDB) FindFeaturesForUser(userID string) ([]*db.FeatureFlag, error) {
1337+func (me *PsqlDB) FindFeaturesByUser(userID string) ([]*db.FeatureFlag, error) {
1338 	var features []*db.FeatureFlag
1339 	// https://stackoverflow.com/a/16920077
1340-	query := `SELECT DISTINCT ON (name)
1341-			id, user_id, payment_history_id, name, data, created_at, expires_at
1342+	query := `SELECT DISTINCT ON (name) *
1343 		FROM feature_flags
1344 		WHERE user_id=$1
1345 		ORDER BY name, expires_at DESC;`
1346-	rs, err := me.Db.Query(query, userID)
1347+	err := me.Db.Select(&features, query, userID)
1348 	if err != nil {
1349-		return features, err
1350-	}
1351-	for rs.Next() {
1352-		var paymentHistoryID sql.NullString
1353-		ff := &db.FeatureFlag{}
1354-		err := rs.Scan(
1355-			&ff.ID,
1356-			&ff.UserID,
1357-			&paymentHistoryID,
1358-			&ff.Name,
1359-			&ff.Data,
1360-			&ff.CreatedAt,
1361-			&ff.ExpiresAt,
1362-		)
1363-		if err != nil {
1364-			return features, err
1365-		}
1366-		ff.PaymentHistoryID = paymentHistoryID
1367-
1368-		features = append(features, ff)
1369-	}
1370-	if rs.Err() != nil {
1371-		return features, rs.Err()
1372+		return nil, err
1373 	}
1374 	return features, nil
1375 }
1376 
1377-func (me *PsqlDB) HasFeatureForUser(userID string, feature string) bool {
1378+func (me *PsqlDB) HasFeatureByUser(userID string, feature string) bool {
1379 	ff, err := me.FindFeature(userID, feature)
1380 	if err != nil {
1381 		return false
1382@@ -1560,28 +1189,18 @@ func (me *PsqlDB) HasFeatureForUser(userID string, feature string) bool {
1383 	return ff.IsValid()
1384 }
1385 
1386-func (me *PsqlDB) FindTotalSizeForUser(userID string) (int, error) {
1387-	var fileSize int
1388-	err := me.Db.QueryRow(sqlSelectSizeForUser, userID).Scan(&fileSize)
1389-	if err != nil {
1390-		return 0, err
1391-	}
1392-	return fileSize, nil
1393-}
1394-
1395 func (me *PsqlDB) InsertFeedItems(postID string, items []*db.FeedItem) error {
1396-	ctx := context.Background()
1397-	tx, err := me.Db.BeginTx(ctx, nil)
1398+	tx, err := me.Db.Beginx()
1399 	if err != nil {
1400 		return err
1401 	}
1402 	defer func() {
1403-		err = tx.Rollback()
1404+		_ = tx.Rollback()
1405 	}()
1406 
1407 	for _, item := range items {
1408 		_, err := tx.Exec(
1409-			sqlInsertFeedItems,
1410+			`INSERT INTO feed_items (post_id, guid, data) VALUES ($1, $2, $3) RETURNING id;`,
1411 			item.PostID,
1412 			item.GUID,
1413 			item.Data,
1414@@ -1599,33 +1218,11 @@ func (me *PsqlDB) InsertFeedItems(postID string, items []*db.FeedItem) error {
1415 }
1416 
1417 func (me *PsqlDB) FindFeedItemsByPostID(postID string) ([]*db.FeedItem, error) {
1418-	// sqlSelectFeedItemsByPost
1419-	items := make([]*db.FeedItem, 0)
1420-	rs, err := me.Db.Query(sqlSelectFeedItemsByPost, postID)
1421+	var items []*db.FeedItem
1422+	err := me.Db.Select(&items, `SELECT * FROM feed_items WHERE post_id=$1`, postID)
1423 	if err != nil {
1424-		return items, err
1425-	}
1426-
1427-	for rs.Next() {
1428-		item := &db.FeedItem{}
1429-		err := rs.Scan(
1430-			&item.ID,
1431-			&item.PostID,
1432-			&item.GUID,
1433-			&item.Data,
1434-			&item.CreatedAt,
1435-		)
1436-		if err != nil {
1437-			return items, err
1438-		}
1439-
1440-		items = append(items, item)
1441-	}
1442-
1443-	if rs.Err() != nil {
1444-		return items, rs.Err()
1445+		return nil, err
1446 	}
1447-
1448 	return items, nil
1449 }
1450 
1451@@ -1635,7 +1232,7 @@ func (me *PsqlDB) InsertProject(userID, name, projectDir string) (string, error)
1452 	}
1453 
1454 	var id string
1455-	err := me.Db.QueryRow(sqlInsertProject, userID, name, projectDir).Scan(&id)
1456+	err := me.Db.QueryRow(`INSERT INTO projects (user_id, name, project_dir) VALUES ($1, $2, $3) RETURNING id;`, userID, name, projectDir).Scan(&id)
1457 	if err != nil {
1458 		return "", err
1459 	}
1460@@ -1643,33 +1240,22 @@ func (me *PsqlDB) InsertProject(userID, name, projectDir string) (string, error)
1461 }
1462 
1463 func (me *PsqlDB) UpdateProject(userID, name string) error {
1464-	_, err := me.Db.Exec(sqlUpdateProject, userID, name, time.Now())
1465+	_, err := me.Db.Exec(`UPDATE projects SET updated_at = $3 WHERE user_id = $1 AND name = $2;`, userID, name, time.Now())
1466 	return err
1467 }
1468 
1469 func (me *PsqlDB) FindProjectByName(userID, name string) (*db.Project, error) {
1470 	project := &db.Project{}
1471-	r := me.Db.QueryRow(sqlFindProjectByName, userID, name)
1472-	err := r.Scan(
1473-		&project.ID,
1474-		&project.UserID,
1475-		&project.Name,
1476-		&project.ProjectDir,
1477-		&project.Acl,
1478-		&project.Blocked,
1479-		&project.CreatedAt,
1480-		&project.UpdatedAt,
1481-	)
1482+	err := me.Db.Get(project, `SELECT * FROM projects WHERE user_id = $1 AND name = $2;`, userID, name)
1483 	if err != nil {
1484 		return nil, err
1485 	}
1486-
1487 	return project, nil
1488 }
1489 
1490 func (me *PsqlDB) InsertToken(userID, name string) (string, error) {
1491 	var token string
1492-	err := me.Db.QueryRow(sqlInsertToken, userID, name).Scan(&token)
1493+	err := me.Db.QueryRow(`INSERT INTO tokens (user_id, name) VALUES($1, $2) RETURNING token;`, userID, name).Scan(&token)
1494 	if err != nil {
1495 		return "", err
1496 	}
1497@@ -1677,7 +1263,7 @@ func (me *PsqlDB) InsertToken(userID, name string) (string, error) {
1498 }
1499 
1500 func (me *PsqlDB) UpsertToken(userID, name string) (string, error) {
1501-	token, _ := me.FindTokenByName(userID, name)
1502+	token, _ := me.findTokenByName(userID, name)
1503 	if token != "" {
1504 		return token, nil
1505 	}
1506@@ -1686,9 +1272,9 @@ func (me *PsqlDB) UpsertToken(userID, name string) (string, error) {
1507 	return token, err
1508 }
1509 
1510-func (me *PsqlDB) FindTokenByName(userID, name string) (string, error) {
1511+func (me *PsqlDB) findTokenByName(userID, name string) (string, error) {
1512 	var token string
1513-	err := me.Db.QueryRow(sqlSelectTokenByNameForUser, userID, name).Scan(&token)
1514+	err := me.Db.QueryRow(`SELECT token FROM tokens WHERE user_id = $1 AND name = $2`, userID, name).Scan(&token)
1515 	if err != nil {
1516 		return "", err
1517 	}
1518@@ -1696,29 +1282,17 @@ func (me *PsqlDB) FindTokenByName(userID, name string) (string, error) {
1519 }
1520 
1521 func (me *PsqlDB) RemoveToken(tokenID string) error {
1522-	_, err := me.Db.Exec(sqlRemoveToken, tokenID)
1523+	_, err := me.Db.Exec(`DELETE FROM tokens WHERE id = $1`, tokenID)
1524 	return err
1525 }
1526 
1527-func (me *PsqlDB) FindTokensForUser(userID string) ([]*db.Token, error) {
1528-	var keys []*db.Token
1529-	rs, err := me.Db.Query(sqlSelectTokensForUser, userID)
1530+func (me *PsqlDB) FindTokensByUser(userID string) ([]*db.Token, error) {
1531+	var tokens []*db.Token
1532+	err := me.Db.Select(&tokens, `SELECT * FROM tokens WHERE user_id = $1`, userID)
1533 	if err != nil {
1534-		return keys, err
1535-	}
1536-	for rs.Next() {
1537-		pk := &db.Token{}
1538-		err := rs.Scan(&pk.ID, &pk.UserID, &pk.Name, &pk.CreatedAt, &pk.ExpiresAt)
1539-		if err != nil {
1540-			return keys, err
1541-		}
1542-
1543-		keys = append(keys, pk)
1544-	}
1545-	if rs.Err() != nil {
1546-		return keys, rs.Err()
1547+		return nil, err
1548 	}
1549-	return keys, nil
1550+	return tokens, nil
1551 }
1552 
1553 func (me *PsqlDB) InsertFeature(userID, name string, expiresAt time.Time) (*db.FeatureFlag, error) {
1554@@ -1763,13 +1337,12 @@ func (me *PsqlDB) AddPicoPlusUser(username, email, paymentType, txId string) err
1555 		return err
1556 	}
1557 
1558-	ctx := context.Background()
1559-	tx, err := me.Db.BeginTx(ctx, nil)
1560+	tx, err := me.Db.Beginx()
1561 	if err != nil {
1562 		return err
1563 	}
1564 	defer func() {
1565-		err = tx.Rollback()
1566+		_ = tx.Rollback()
1567 	}()
1568 
1569 	var paymentHistoryId sql.NullString
1570@@ -1849,69 +1422,32 @@ func (me *PsqlDB) InsertTunsEventLog(log *db.TunsEventLog) error {
1571 }
1572 
1573 func (me *PsqlDB) FindTunsEventLogsByAddr(userID, addr string) ([]*db.TunsEventLog, error) {
1574-	logs := []*db.TunsEventLog{}
1575-	rs, err := me.Db.Query(
1576-		`SELECT id, user_id, server_id, remote_addr, event_type, tunnel_type, connection_type, tunnel_id, created_at
1577-		FROM tuns_event_logs WHERE user_id=$1 AND tunnel_id=$2 ORDER BY created_at DESC`, userID, addr)
1578+	var logs []*db.TunsEventLog
1579+	err := me.Db.Select(&logs,
1580+		`SELECT * FROM tuns_event_logs WHERE user_id=$1 AND tunnel_id=$2 ORDER BY created_at DESC`, userID, addr)
1581 	if err != nil {
1582 		return nil, err
1583 	}
1584-
1585-	for rs.Next() {
1586-		log := db.TunsEventLog{}
1587-		err := rs.Scan(
1588-			&log.ID, &log.UserId, &log.ServerID, &log.RemoteAddr,
1589-			&log.EventType, &log.TunnelType, &log.ConnectionType,
1590-			&log.TunnelID, &log.CreatedAt,
1591-		)
1592-		if err != nil {
1593-			return nil, err
1594-		}
1595-		logs = append(logs, &log)
1596-	}
1597-
1598-	if rs.Err() != nil {
1599-		return nil, rs.Err()
1600-	}
1601-
1602 	return logs, nil
1603 }
1604 
1605 func (me *PsqlDB) FindTunsEventLogs(userID string) ([]*db.TunsEventLog, error) {
1606-	logs := []*db.TunsEventLog{}
1607-	rs, err := me.Db.Query(
1608-		`SELECT id, user_id, server_id, remote_addr, event_type, tunnel_type, connection_type, tunnel_id, created_at
1609-		FROM tuns_event_logs WHERE user_id=$1 ORDER BY created_at DESC`, userID)
1610+	var logs []*db.TunsEventLog
1611+	err := me.Db.Select(&logs,
1612+		`SELECT * FROM tuns_event_logs WHERE user_id=$1 ORDER BY created_at DESC`, userID)
1613 	if err != nil {
1614 		return nil, err
1615 	}
1616-
1617-	for rs.Next() {
1618-		log := db.TunsEventLog{}
1619-		err := rs.Scan(
1620-			&log.ID, &log.UserId, &log.ServerID, &log.RemoteAddr,
1621-			&log.EventType, &log.TunnelType, &log.ConnectionType,
1622-			&log.TunnelID, &log.CreatedAt,
1623-		)
1624-		if err != nil {
1625-			return nil, err
1626-		}
1627-		logs = append(logs, &log)
1628-	}
1629-
1630-	if rs.Err() != nil {
1631-		return nil, rs.Err()
1632-	}
1633-
1634 	return logs, nil
1635 }
1636 
1637 func (me *PsqlDB) FindUserStats(userID string) (*db.UserStats, error) {
1638 	stats := db.UserStats{}
1639-	rs, err := me.Db.Query(`SELECT cur_space, count(id), min(created_at), max(created_at), max(updated_at) FROM posts WHERE user_id=$1 GROUP BY cur_space`, userID)
1640+	rs, err := me.Db.Queryx(`SELECT cur_space, count(id), min(created_at), max(created_at), max(updated_at) FROM posts WHERE user_id=$1 GROUP BY cur_space`, userID)
1641 	if err != nil {
1642 		return nil, err
1643 	}
1644+	defer func() { _ = rs.Close() }()
1645 
1646 	for rs.Next() {
1647 		stat := db.UserServiceStats{}
1648@@ -1942,79 +1478,29 @@ func (me *PsqlDB) FindUserStats(userID string) (*db.UserStats, error) {
1649 }
1650 
1651 func (me *PsqlDB) FindAccessLogs(userID string, fromDate *time.Time) ([]*db.AccessLog, error) {
1652-	logs := []*db.AccessLog{}
1653-	rs, err := me.Db.Query(
1654-		`SELECT id, user_id, service, pubkey, identity, created_at FROM access_logs WHERE user_id=$1 AND created_at >= $2 ORDER BY created_at DESC`, userID, fromDate)
1655+	var logs []*db.AccessLog
1656+	err := me.Db.Select(&logs, `SELECT * FROM access_logs WHERE user_id=$1 AND created_at >= $2 ORDER BY created_at DESC`, userID, fromDate)
1657 	if err != nil {
1658 		return nil, err
1659 	}
1660-
1661-	for rs.Next() {
1662-		log := db.AccessLog{}
1663-		err := rs.Scan(
1664-			&log.ID, &log.UserID, &log.Service, &log.Pubkey, &log.Identity, &log.CreatedAt,
1665-		)
1666-		if err != nil {
1667-			return nil, err
1668-		}
1669-		logs = append(logs, &log)
1670-	}
1671-
1672-	if rs.Err() != nil {
1673-		return nil, rs.Err()
1674-	}
1675-
1676 	return logs, nil
1677 }
1678 
1679 func (me *PsqlDB) FindAccessLogsByPubkey(pubkey string, fromDate *time.Time) ([]*db.AccessLog, error) {
1680-	logs := []*db.AccessLog{}
1681-	rs, err := me.Db.Query(
1682-		`SELECT id, user_id, service, pubkey, identity, created_at FROM access_logs WHERE pubkey=$1 AND created_at >= $2 ORDER BY created_at DESC`, pubkey, fromDate)
1683+	var logs []*db.AccessLog
1684+	err := me.Db.Select(&logs, `SELECT * FROM access_logs WHERE pubkey=$1 AND created_at >= $2 ORDER BY created_at DESC`, pubkey, fromDate)
1685 	if err != nil {
1686 		return nil, err
1687 	}
1688-
1689-	for rs.Next() {
1690-		log := db.AccessLog{}
1691-		err := rs.Scan(
1692-			&log.ID, &log.UserID, &log.Service, &log.Pubkey, &log.Identity, &log.CreatedAt,
1693-		)
1694-		if err != nil {
1695-			return nil, err
1696-		}
1697-		logs = append(logs, &log)
1698-	}
1699-
1700-	if rs.Err() != nil {
1701-		return nil, rs.Err()
1702-	}
1703-
1704 	return logs, nil
1705 }
1706 
1707 func (me *PsqlDB) FindPubkeysInAccessLogs(userID string) ([]string, error) {
1708-	pubkeys := []string{}
1709-	rs, err := me.Db.Query(
1710-		`SELECT DISTINCT(pubkey) FROM access_logs WHERE user_id=$1`, userID,
1711-	)
1712+	var pubkeys []string
1713+	err := me.Db.Select(&pubkeys, `SELECT DISTINCT(pubkey) FROM access_logs WHERE user_id=$1`, userID)
1714 	if err != nil {
1715 		return nil, err
1716 	}
1717-
1718-	for rs.Next() {
1719-		pubkey := ""
1720-		err := rs.Scan(&pubkey)
1721-		if err != nil {
1722-			return nil, err
1723-		}
1724-		pubkeys = append(pubkeys, pubkey)
1725-	}
1726-
1727-	if rs.Err() != nil {
1728-		return nil, rs.Err()
1729-	}
1730-
1731 	return pubkeys, nil
1732 }
1733 
A pkg/db/postgres/storage_test.go
+1472, -0
   1@@ -0,0 +1,1472 @@
   2+package postgres
   3+
   4+import (
   5+	"context"
   6+	"fmt"
   7+	"log/slog"
   8+	"os"
   9+	"os/exec"
  10+	"path/filepath"
  11+	"runtime"
  12+	"sort"
  13+	"testing"
  14+	"time"
  15+
  16+	"github.com/jmoiron/sqlx"
  17+	_ "github.com/lib/pq"
  18+	"github.com/picosh/pico/pkg/db"
  19+	"github.com/testcontainers/testcontainers-go"
  20+	"github.com/testcontainers/testcontainers-go/modules/postgres"
  21+	"github.com/testcontainers/testcontainers-go/wait"
  22+)
  23+
  24+var testDB *PsqlDB
  25+var testLogger *slog.Logger
  26+var skipTests bool
  27+
  28+func setupContainerRuntime() bool {
  29+	// Check if DATABASE_URL is set for external postgres (CI/CD or manual testing)
  30+	if os.Getenv("TEST_DATABASE_URL") != "" {
  31+		return true
  32+	}
  33+
  34+	// Try podman first
  35+	if cmd := exec.Command("podman", "info"); cmd.Run() == nil {
  36+		// For podman, we need to ensure the socket is running
  37+		// User should run: systemctl --user start podman.socket
  38+		_ = os.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true")
  39+
  40+		// Check if socket exists and is accessible
  41+		xdgRuntime := os.Getenv("XDG_RUNTIME_DIR")
  42+		if xdgRuntime != "" {
  43+			socketPath := xdgRuntime + "/podman/podman.sock"
  44+			if _, err := os.Stat(socketPath); err == nil {
  45+				_ = os.Setenv("DOCKER_HOST", "unix://"+socketPath)
  46+				return true
  47+			}
  48+		}
  49+		// Socket not available, need to start it
  50+		fmt.Println("Podman detected but socket not running. Run: systemctl --user start podman.socket")
  51+		return false
  52+	}
  53+
  54+	// Try docker
  55+	if cmd := exec.Command("docker", "info"); cmd.Run() == nil {
  56+		return true
  57+	}
  58+
  59+	return false
  60+}
  61+
  62+func TestMain(m *testing.M) {
  63+	ctx := context.Background()
  64+	testLogger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
  65+
  66+	// Check for external database URL first (for CI/CD or manual testing)
  67+	if dbURL := os.Getenv("TEST_DATABASE_URL"); dbURL != "" {
  68+		testDB = NewDB(dbURL, testLogger)
  69+		if err := setupTestSchema(testDB.Db); err != nil {
  70+			panic(fmt.Sprintf("failed to setup schema: %s", err))
  71+		}
  72+		code := m.Run()
  73+		_ = testDB.Close()
  74+		os.Exit(code)
  75+	}
  76+
  77+	if !setupContainerRuntime() {
  78+		fmt.Println("Container runtime not available, skipping integration tests")
  79+		fmt.Println("To run tests, either:")
  80+		fmt.Println("  - Set TEST_DATABASE_URL to a postgres connection string")
  81+		fmt.Println("  - Start podman socket: systemctl --user start podman.socket")
  82+		fmt.Println("  - Start docker daemon")
  83+		skipTests = true
  84+		os.Exit(0)
  85+	}
  86+
  87+	pgContainer, err := postgres.Run(ctx,
  88+		"postgres:14",
  89+		postgres.WithDatabase("pico_test"),
  90+		postgres.WithUsername("postgres"),
  91+		postgres.WithPassword("postgres"),
  92+		testcontainers.WithWaitStrategy(
  93+			wait.ForLog("database system is ready to accept connections").
  94+				WithOccurrence(2).
  95+				WithStartupTimeout(30*time.Second)),
  96+	)
  97+	if err != nil {
  98+		fmt.Printf("Failed to start postgres container (Docker may not be running): %s\n", err)
  99+		skipTests = true
 100+		os.Exit(0)
 101+	}
 102+
 103+	connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
 104+	if err != nil {
 105+		panic(fmt.Sprintf("failed to get connection string: %s", err))
 106+	}
 107+
 108+	testDB = NewDB(connStr, testLogger)
 109+
 110+	if err := setupTestSchema(testDB.Db); err != nil {
 111+		panic(fmt.Sprintf("failed to setup schema: %s", err))
 112+	}
 113+
 114+	code := m.Run()
 115+
 116+	_ = testDB.Close()
 117+	_ = pgContainer.Terminate(ctx)
 118+
 119+	os.Exit(code)
 120+}
 121+
 122+func getProjectRoot() string {
 123+	_, filename, _, ok := runtime.Caller(0)
 124+	if !ok {
 125+		panic("failed to get current file path")
 126+	}
 127+	// storage_test.go is in pkg/db/postgres/, so go up 4 levels to get project root
 128+	return filepath.Join(filepath.Dir(filename), "..", "..", "..")
 129+}
 130+
 131+func setupTestSchema(db *sqlx.DB) error {
 132+	projectRoot := getProjectRoot()
 133+	migrationsDir := filepath.Join(projectRoot, "sql", "migrations")
 134+
 135+	// Read all migration files
 136+	entries, err := os.ReadDir(migrationsDir)
 137+	if err != nil {
 138+		return fmt.Errorf("failed to read migrations directory: %w", err)
 139+	}
 140+
 141+	// Sort by filename (they're date-prefixed, so alphabetical order is correct)
 142+	var migrationFiles []string
 143+	for _, entry := range entries {
 144+		if !entry.IsDir() && filepath.Ext(entry.Name()) == ".sql" {
 145+			migrationFiles = append(migrationFiles, entry.Name())
 146+		}
 147+	}
 148+	sort.Strings(migrationFiles)
 149+
 150+	// Execute each migration in order
 151+	for _, filename := range migrationFiles {
 152+		migrationPath := filepath.Join(migrationsDir, filename)
 153+		content, err := os.ReadFile(migrationPath)
 154+		if err != nil {
 155+			return fmt.Errorf("failed to read migration %s: %w", filename, err)
 156+		}
 157+
 158+		_, err = db.Exec(string(content))
 159+		if err != nil {
 160+			return fmt.Errorf("failed to execute migration %s: %w", filename, err)
 161+		}
 162+	}
 163+
 164+	return nil
 165+}
 166+
 167+func cleanupTestData(t *testing.T) {
 168+	t.Helper()
 169+	tables := []string{
 170+		"access_logs", "tuns_event_logs", "analytics_visits",
 171+		"feed_items", "post_aliases", "post_tags", "posts",
 172+		"projects", "feature_flags", "payment_history", "tokens",
 173+		"public_keys", "app_users",
 174+	}
 175+	for _, table := range tables {
 176+		_, err := testDB.Db.Exec(fmt.Sprintf("DELETE FROM %s", table))
 177+		if err != nil {
 178+			t.Fatalf("failed to clean up %s: %v", table, err)
 179+		}
 180+	}
 181+}
 182+
 183+func mustInsertPost(t *testing.T, post *db.Post) *db.Post {
 184+	t.Helper()
 185+	now := time.Now()
 186+	if post.UpdatedAt == nil {
 187+		post.UpdatedAt = &now
 188+	}
 189+	if post.PublishAt == nil {
 190+		post.PublishAt = &now
 191+	}
 192+	created, err := testDB.InsertPost(post)
 193+	if err != nil {
 194+		t.Fatalf("InsertPost failed: %v", err)
 195+	}
 196+	return created
 197+}
 198+
 199+// ============ User Management Tests ============
 200+
 201+func TestRegisterUser_Success(t *testing.T) {
 202+	cleanupTestData(t)
 203+
 204+	user, err := testDB.RegisterUser("testuser", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI testkey", "test comment")
 205+	if err != nil {
 206+		t.Fatalf("RegisterUser failed: %v", err)
 207+	}
 208+	if user == nil {
 209+		t.Fatal("expected user, got nil")
 210+	}
 211+	if user.Name != "testuser" {
 212+		t.Errorf("expected name 'testuser', got '%s'", user.Name)
 213+	}
 214+	if user.PublicKey == nil {
 215+		t.Fatal("expected public key, got nil")
 216+	}
 217+}
 218+
 219+func TestRegisterUser_DuplicateName(t *testing.T) {
 220+	cleanupTestData(t)
 221+
 222+	_, err := testDB.RegisterUser("duplicateuser", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI key1", "comment1")
 223+	if err != nil {
 224+		t.Fatalf("first RegisterUser failed: %v", err)
 225+	}
 226+
 227+	_, err = testDB.RegisterUser("duplicateuser", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI key2", "comment2")
 228+	if err == nil {
 229+		t.Error("expected error for duplicate name, got nil")
 230+	}
 231+}
 232+
 233+func TestRegisterUser_DeniedName(t *testing.T) {
 234+	cleanupTestData(t)
 235+
 236+	_, err := testDB.RegisterUser("admin", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI adminkey", "comment")
 237+	if err == nil {
 238+		t.Error("expected error for denied name 'admin', got nil")
 239+	}
 240+}
 241+
 242+func TestRegisterUser_InvalidName(t *testing.T) {
 243+	cleanupTestData(t)
 244+
 245+	_, err := testDB.RegisterUser("user@invalid", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI invalidkey", "comment")
 246+	if err == nil {
 247+		t.Error("expected error for invalid name, got nil")
 248+	}
 249+}
 250+
 251+func TestFindUser(t *testing.T) {
 252+	cleanupTestData(t)
 253+
 254+	created, err := testDB.RegisterUser("findme", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findmekey", "comment")
 255+	if err != nil {
 256+		t.Fatalf("RegisterUser failed: %v", err)
 257+	}
 258+
 259+	found, err := testDB.FindUser(created.ID)
 260+	if err != nil {
 261+		t.Fatalf("FindUser failed: %v", err)
 262+	}
 263+	if found.Name != "findme" {
 264+		t.Errorf("expected name 'findme', got '%s'", found.Name)
 265+	}
 266+}
 267+
 268+func TestFindUserByName(t *testing.T) {
 269+	cleanupTestData(t)
 270+
 271+	_, err := testDB.RegisterUser("nameduser", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI namedkey", "comment")
 272+	if err != nil {
 273+		t.Fatalf("RegisterUser failed: %v", err)
 274+	}
 275+
 276+	found, err := testDB.FindUserByName("nameduser")
 277+	if err != nil {
 278+		t.Fatalf("FindUserByName failed: %v", err)
 279+	}
 280+	if found.Name != "nameduser" {
 281+		t.Errorf("expected name 'nameduser', got '%s'", found.Name)
 282+	}
 283+}
 284+
 285+func TestFindUserByPubkey(t *testing.T) {
 286+	cleanupTestData(t)
 287+
 288+	pubkey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pubkeyuser"
 289+	_, err := testDB.RegisterUser("pubkeyuser", pubkey, "comment")
 290+	if err != nil {
 291+		t.Fatalf("RegisterUser failed: %v", err)
 292+	}
 293+
 294+	found, err := testDB.FindUserByPubkey(pubkey)
 295+	if err != nil {
 296+		t.Fatalf("FindUserByPubkey failed: %v", err)
 297+	}
 298+	if found.Name != "pubkeyuser" {
 299+		t.Errorf("expected name 'pubkeyuser', got '%s'", found.Name)
 300+	}
 301+}
 302+
 303+func TestFindUserByKey(t *testing.T) {
 304+	cleanupTestData(t)
 305+
 306+	pubkey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI forkeyfind"
 307+	_, err := testDB.RegisterUser("forkeyfind", pubkey, "comment")
 308+	if err != nil {
 309+		t.Fatalf("RegisterUser failed: %v", err)
 310+	}
 311+
 312+	found, err := testDB.FindUserByKey("forkeyfind", pubkey)
 313+	if err != nil {
 314+		t.Fatalf("FindUserByKey failed: %v", err)
 315+	}
 316+	if found.Name != "forkeyfind" {
 317+		t.Errorf("expected name 'forkeyfind', got '%s'", found.Name)
 318+	}
 319+}
 320+
 321+func TestFindUsers(t *testing.T) {
 322+	cleanupTestData(t)
 323+
 324+	_, _ = testDB.RegisterUser("user1", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI user1key", "comment")
 325+	_, _ = testDB.RegisterUser("user2", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI user2key", "comment")
 326+
 327+	users, err := testDB.FindUsers()
 328+	if err != nil {
 329+		t.Fatalf("FindUsers failed: %v", err)
 330+	}
 331+	if len(users) != 2 {
 332+		t.Errorf("expected 2 users, got %d", len(users))
 333+	}
 334+}
 335+
 336+// ============ Public Key Management Tests ============
 337+
 338+func TestInsertPublicKey_Success(t *testing.T) {
 339+	cleanupTestData(t)
 340+
 341+	user, err := testDB.RegisterUser("keyowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI keyowner1", "comment")
 342+	if err != nil {
 343+		t.Fatalf("RegisterUser failed: %v", err)
 344+	}
 345+
 346+	err = testDB.InsertPublicKey(user.ID, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI secondkey", "second key")
 347+	if err != nil {
 348+		t.Fatalf("InsertPublicKey failed: %v", err)
 349+	}
 350+
 351+	keys, err := testDB.FindKeysByUser(user)
 352+	if err != nil {
 353+		t.Fatalf("FindKeysByUser failed: %v", err)
 354+	}
 355+	if len(keys) != 2 {
 356+		t.Errorf("expected 2 keys, got %d", len(keys))
 357+	}
 358+}
 359+
 360+func TestInsertPublicKey_Duplicate(t *testing.T) {
 361+	cleanupTestData(t)
 362+
 363+	user, _ := testDB.RegisterUser("dupkeyowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI dupkey", "comment")
 364+
 365+	err := testDB.InsertPublicKey(user.ID, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI dupkey", "same key")
 366+	if err == nil {
 367+		t.Error("expected error for duplicate key, got nil")
 368+	}
 369+}
 370+
 371+func TestUpdatePublicKey(t *testing.T) {
 372+	cleanupTestData(t)
 373+
 374+	user, _ := testDB.RegisterUser("updatekeyowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI updatekeyowner", "original")
 375+
 376+	updated, err := testDB.UpdatePublicKey(user.PublicKey.ID, "new-name")
 377+	if err != nil {
 378+		t.Fatalf("UpdatePublicKey failed: %v", err)
 379+	}
 380+	if updated.Name != "new-name" {
 381+		t.Errorf("expected name 'new-name', got '%s'", updated.Name)
 382+	}
 383+}
 384+
 385+func TestFindKeysByUser(t *testing.T) {
 386+	cleanupTestData(t)
 387+
 388+	user, _ := testDB.RegisterUser("multikeyowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI multikeyowner1", "key1")
 389+	_ = testDB.InsertPublicKey(user.ID, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI multikeyowner2", "key2")
 390+
 391+	keys, err := testDB.FindKeysByUser(user)
 392+	if err != nil {
 393+		t.Fatalf("FindKeysByUser failed: %v", err)
 394+	}
 395+	if len(keys) != 2 {
 396+		t.Errorf("expected 2 keys, got %d", len(keys))
 397+	}
 398+}
 399+
 400+func TestRemoveKeys(t *testing.T) {
 401+	cleanupTestData(t)
 402+
 403+	user, _ := testDB.RegisterUser("removekeyowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI removekeyowner", "key1")
 404+	_ = testDB.InsertPublicKey(user.ID, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI removekeyowner2", "key2")
 405+
 406+	keys, _ := testDB.FindKeysByUser(user)
 407+	if len(keys) != 2 {
 408+		t.Fatalf("expected 2 keys before removal, got %d", len(keys))
 409+	}
 410+
 411+	err := testDB.RemoveKeys([]string{keys[1].ID})
 412+	if err != nil {
 413+		t.Fatalf("RemoveKeys failed: %v", err)
 414+	}
 415+
 416+	keys, _ = testDB.FindKeysByUser(user)
 417+	if len(keys) != 1 {
 418+		t.Errorf("expected 1 key after removal, got %d", len(keys))
 419+	}
 420+}
 421+
 422+// ============ Token Management Tests ============
 423+
 424+func TestInsertToken(t *testing.T) {
 425+	cleanupTestData(t)
 426+
 427+	user, _ := testDB.RegisterUser("tokenowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tokenowner", "comment")
 428+
 429+	token, err := testDB.InsertToken(user.ID, "my-token")
 430+	if err != nil {
 431+		t.Fatalf("InsertToken failed: %v", err)
 432+	}
 433+	if token == "" {
 434+		t.Error("expected token string, got empty")
 435+	}
 436+}
 437+
 438+func TestUpsertToken(t *testing.T) {
 439+	cleanupTestData(t)
 440+
 441+	user, _ := testDB.RegisterUser("upserttokenowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI upserttokenowner", "comment")
 442+
 443+	token1, err := testDB.UpsertToken(user.ID, "upsert-token")
 444+	if err != nil {
 445+		t.Fatalf("first UpsertToken failed: %v", err)
 446+	}
 447+
 448+	token2, err := testDB.UpsertToken(user.ID, "upsert-token")
 449+	if err != nil {
 450+		t.Fatalf("second UpsertToken failed: %v", err)
 451+	}
 452+
 453+	if token1 != token2 {
 454+		t.Errorf("expected same token, got different: %s vs %s", token1, token2)
 455+	}
 456+}
 457+
 458+func TestFindTokensByUser(t *testing.T) {
 459+	cleanupTestData(t)
 460+
 461+	user, _ := testDB.RegisterUser("tokensowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tokensowner", "comment")
 462+	_, _ = testDB.InsertToken(user.ID, "token1")
 463+	_, _ = testDB.InsertToken(user.ID, "token2")
 464+
 465+	tokens, err := testDB.FindTokensByUser(user.ID)
 466+	if err != nil {
 467+		t.Fatalf("FindTokensByUser failed: %v", err)
 468+	}
 469+	if len(tokens) != 2 {
 470+		t.Errorf("expected 2 tokens, got %d", len(tokens))
 471+	}
 472+}
 473+
 474+func TestFindUserByToken_Valid(t *testing.T) {
 475+	cleanupTestData(t)
 476+
 477+	user, _ := testDB.RegisterUser("validtokenowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI validtokenowner", "comment")
 478+	token, _ := testDB.InsertToken(user.ID, "valid-token")
 479+
 480+	found, err := testDB.FindUserByToken(token)
 481+	if err != nil {
 482+		t.Fatalf("FindUserByToken failed: %v", err)
 483+	}
 484+	if found.Name != "validtokenowner" {
 485+		t.Errorf("expected name 'validtokenowner', got '%s'", found.Name)
 486+	}
 487+}
 488+
 489+func TestFindUserByToken_Expired(t *testing.T) {
 490+	cleanupTestData(t)
 491+
 492+	user, _ := testDB.RegisterUser("expiredtokenowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI expiredtokenowner", "comment")
 493+	token, _ := testDB.InsertToken(user.ID, "expired-token")
 494+
 495+	_, err := testDB.Db.Exec("UPDATE tokens SET expires_at = NOW() - INTERVAL '1 day' WHERE token = $1", token)
 496+	if err != nil {
 497+		t.Fatalf("failed to expire token: %v", err)
 498+	}
 499+
 500+	_, err = testDB.FindUserByToken(token)
 501+	if err == nil {
 502+		t.Error("expected error for expired token, got nil")
 503+	}
 504+}
 505+
 506+func TestRemoveToken(t *testing.T) {
 507+	cleanupTestData(t)
 508+
 509+	user, _ := testDB.RegisterUser("removetokenowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI removetokenowner", "comment")
 510+	_, _ = testDB.InsertToken(user.ID, "remove-token")
 511+
 512+	tokens, _ := testDB.FindTokensByUser(user.ID)
 513+	if len(tokens) != 1 {
 514+		t.Fatalf("expected 1 token, got %d", len(tokens))
 515+	}
 516+
 517+	err := testDB.RemoveToken(tokens[0].ID)
 518+	if err != nil {
 519+		t.Fatalf("RemoveToken failed: %v", err)
 520+	}
 521+
 522+	tokens, _ = testDB.FindTokensByUser(user.ID)
 523+	if len(tokens) != 0 {
 524+		t.Errorf("expected 0 tokens after removal, got %d", len(tokens))
 525+	}
 526+}
 527+
 528+// ============ Post CRUD Tests ============
 529+
 530+func TestInsertPost(t *testing.T) {
 531+	cleanupTestData(t)
 532+
 533+	user, _ := testDB.RegisterUser("postowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI postowner", "comment")
 534+
 535+	now := time.Now()
 536+	post := &db.Post{
 537+		UserID:      user.ID,
 538+		Filename:    "test.md",
 539+		Slug:        "test-post",
 540+		Title:       "Test Post",
 541+		Text:        "Post content",
 542+		Description: "A test post",
 543+		PublishAt:   &now,
 544+		UpdatedAt:   &now,
 545+		Space:       "prose",
 546+		MimeType:    "text/markdown",
 547+	}
 548+
 549+	created, err := testDB.InsertPost(post)
 550+	if err != nil {
 551+		t.Fatalf("InsertPost failed: %v", err)
 552+	}
 553+	if created.ID == "" {
 554+		t.Error("expected post ID, got empty")
 555+	}
 556+	if created.Title != "Test Post" {
 557+		t.Errorf("expected title 'Test Post', got '%s'", created.Title)
 558+	}
 559+}
 560+
 561+func TestUpdatePost(t *testing.T) {
 562+	cleanupTestData(t)
 563+
 564+	user, _ := testDB.RegisterUser("updatepostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI updatepostowner", "comment")
 565+
 566+	now := time.Now()
 567+	post := mustInsertPost(t, &db.Post{
 568+		UserID:   user.ID,
 569+		Filename: "update.md",
 570+		Slug:     "update-post",
 571+		Title:    "Original Title",
 572+		Text:     "Original content",
 573+		Space:    "prose",
 574+	})
 575+
 576+	post.Title = "Updated Title"
 577+	post.Text = "Updated content"
 578+	post.UpdatedAt = &now
 579+
 580+	updated, err := testDB.UpdatePost(post)
 581+	if err != nil {
 582+		t.Fatalf("UpdatePost failed: %v", err)
 583+	}
 584+	if updated.Title != "Updated Title" {
 585+		t.Errorf("expected title 'Updated Title', got '%s'", updated.Title)
 586+	}
 587+}
 588+
 589+func TestRemovePosts(t *testing.T) {
 590+	cleanupTestData(t)
 591+
 592+	user, _ := testDB.RegisterUser("removepostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI removepostowner", "comment")
 593+
 594+	post := mustInsertPost(t, &db.Post{
 595+		UserID:   user.ID,
 596+		Filename: "remove.md",
 597+		Slug:     "remove-post",
 598+		Title:    "To Remove",
 599+		Space:    "prose",
 600+	})
 601+
 602+	err := testDB.RemovePosts([]string{post.ID})
 603+	if err != nil {
 604+		t.Fatalf("RemovePosts failed: %v", err)
 605+	}
 606+
 607+	_, err = testDB.FindPost(post.ID)
 608+	if err == nil {
 609+		t.Error("expected error finding removed post, got nil")
 610+	}
 611+}
 612+
 613+func TestFindPost(t *testing.T) {
 614+	cleanupTestData(t)
 615+
 616+	user, _ := testDB.RegisterUser("findpostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findpostowner", "comment")
 617+
 618+	created := mustInsertPost(t, &db.Post{
 619+		UserID:   user.ID,
 620+		Filename: "find.md",
 621+		Slug:     "find-post",
 622+		Title:    "Find Me",
 623+		Space:    "prose",
 624+	})
 625+
 626+	found, err := testDB.FindPost(created.ID)
 627+	if err != nil {
 628+		t.Fatalf("FindPost failed: %v", err)
 629+	}
 630+	if found.Title != "Find Me" {
 631+		t.Errorf("expected title 'Find Me', got '%s'", found.Title)
 632+	}
 633+}
 634+
 635+func TestFindPostWithFilename(t *testing.T) {
 636+	cleanupTestData(t)
 637+
 638+	user, _ := testDB.RegisterUser("filenamepostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI filenamepostowner", "comment")
 639+
 640+	_ = mustInsertPost(t, &db.Post{
 641+		UserID:   user.ID,
 642+		Filename: "byfilename.md",
 643+		Slug:     "byfilename-post",
 644+		Title:    "By Filename",
 645+		Space:    "prose",
 646+	})
 647+
 648+	found, err := testDB.FindPostWithFilename("byfilename.md", user.ID, "prose")
 649+	if err != nil {
 650+		t.Fatalf("FindPostWithFilename failed: %v", err)
 651+	}
 652+	if found.Title != "By Filename" {
 653+		t.Errorf("expected title 'By Filename', got '%s'", found.Title)
 654+	}
 655+}
 656+
 657+func TestFindPostWithSlug(t *testing.T) {
 658+	cleanupTestData(t)
 659+
 660+	user, _ := testDB.RegisterUser("slugpostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI slugpostowner", "comment")
 661+
 662+	_ = mustInsertPost(t, &db.Post{
 663+		UserID:   user.ID,
 664+		Filename: "byslug.md",
 665+		Slug:     "byslug-post",
 666+		Title:    "By Slug",
 667+		Space:    "prose",
 668+	})
 669+
 670+	found, err := testDB.FindPostWithSlug("byslug-post", user.ID, "prose")
 671+	if err != nil {
 672+		t.Fatalf("FindPostWithSlug failed: %v", err)
 673+	}
 674+	if found.Title != "By Slug" {
 675+		t.Errorf("expected title 'By Slug', got '%s'", found.Title)
 676+	}
 677+}
 678+
 679+func TestFindPosts(t *testing.T) {
 680+	cleanupTestData(t)
 681+
 682+	user, _ := testDB.RegisterUser("postsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI postsowner", "comment")
 683+
 684+	_ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "post1.md", Slug: "post1", Title: "Post 1", Space: "prose"})
 685+	_ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "post2.md", Slug: "post2", Title: "Post 2", Space: "prose"})
 686+
 687+	posts, err := testDB.FindPosts()
 688+	if err != nil {
 689+		t.Fatalf("FindPosts failed: %v", err)
 690+	}
 691+	if len(posts) != 2 {
 692+		t.Errorf("expected 2 posts, got %d", len(posts))
 693+	}
 694+}
 695+
 696+func TestFindPostsByUser(t *testing.T) {
 697+	cleanupTestData(t)
 698+
 699+	user, _ := testDB.RegisterUser("userpostsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI userpostsowner", "comment")
 700+	now := time.Now()
 701+
 702+	_ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "userpost1.md", Slug: "userpost1", Title: "User Post 1", Space: "prose", PublishAt: &now})
 703+	_ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "userpost2.md", Slug: "userpost2", Title: "User Post 2", Space: "prose", PublishAt: &now})
 704+
 705+	pager := &db.Pager{Num: 10, Page: 0}
 706+	result, err := testDB.FindPostsByUser(pager, user.ID, "prose")
 707+	if err != nil {
 708+		t.Fatalf("FindPostsByUser failed: %v", err)
 709+	}
 710+	if len(result.Data) != 2 {
 711+		t.Errorf("expected 2 posts, got %d", len(result.Data))
 712+	}
 713+}
 714+
 715+func TestFindAllPostsByUser(t *testing.T) {
 716+	cleanupTestData(t)
 717+
 718+	user, _ := testDB.RegisterUser("allpostsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI allpostsowner", "comment")
 719+
 720+	_ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "allpost1.md", Slug: "allpost1", Title: "All Post 1", Space: "prose"})
 721+	_ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "allpost2.md", Slug: "allpost2", Title: "All Post 2", Space: "prose"})
 722+
 723+	posts, err := testDB.FindAllPostsByUser(user.ID, "prose")
 724+	if err != nil {
 725+		t.Fatalf("FindAllPostsByUser failed: %v", err)
 726+	}
 727+	if len(posts) != 2 {
 728+		t.Errorf("expected 2 posts, got %d", len(posts))
 729+	}
 730+}
 731+
 732+func TestFindUsersWithPost(t *testing.T) {
 733+	cleanupTestData(t)
 734+
 735+	user, _ := testDB.RegisterUser("feedspostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feedspostowner", "comment")
 736+
 737+	_ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feed.txt", Slug: "feed", Title: "Feed", Space: "feeds"})
 738+
 739+	users, err := testDB.FindUsersWithPost("feeds")
 740+	if err != nil {
 741+		t.Fatalf("FindUsersWithPost failed: %v", err)
 742+	}
 743+	if len(users) != 1 {
 744+		t.Errorf("expected 1 user, got %d", len(users))
 745+	}
 746+}
 747+
 748+func TestFindExpiredPosts(t *testing.T) {
 749+	cleanupTestData(t)
 750+
 751+	user, _ := testDB.RegisterUser("expiredpostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI expiredpostowner", "comment")
 752+
 753+	expired := time.Now().Add(-24 * time.Hour)
 754+	_ = mustInsertPost(t, &db.Post{
 755+		UserID:    user.ID,
 756+		Filename:  "expired.txt",
 757+		Slug:      "expired",
 758+		Title:     "Expired",
 759+		Space:     "pastes",
 760+		ExpiresAt: &expired,
 761+	})
 762+
 763+	posts, err := testDB.FindExpiredPosts("pastes")
 764+	if err != nil {
 765+		t.Fatalf("FindExpiredPosts failed: %v", err)
 766+	}
 767+	if len(posts) != 1 {
 768+		t.Errorf("expected 1 expired post, got %d", len(posts))
 769+	}
 770+}
 771+
 772+func TestFindPostsByFeed(t *testing.T) {
 773+	cleanupTestData(t)
 774+
 775+	user, _ := testDB.RegisterUser("feedowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feedowner", "comment")
 776+
 777+	now := time.Now()
 778+	_ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedpost.md", Slug: "feedpost", Title: "Feed Post", Space: "prose", PublishAt: &now})
 779+
 780+	pager := &db.Pager{Num: 10, Page: 0}
 781+	result, err := testDB.FindPostsByFeed(pager, "prose")
 782+	if err != nil {
 783+		t.Fatalf("FindPostsByFeed failed: %v", err)
 784+	}
 785+	if len(result.Data) < 1 {
 786+		t.Errorf("expected at least 1 post in feed, got %d", len(result.Data))
 787+	}
 788+}
 789+
 790+// ============ Tags Tests ============
 791+
 792+func TestReplaceTagsByPost(t *testing.T) {
 793+	cleanupTestData(t)
 794+
 795+	user, _ := testDB.RegisterUser("tagspostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tagspostowner", "comment")
 796+	post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "tagged.md", Slug: "tagged", Title: "Tagged", Space: "prose"})
 797+
 798+	err := testDB.ReplaceTagsByPost([]string{"tag1", "tag2"}, post.ID)
 799+	if err != nil {
 800+		t.Fatalf("ReplaceTagsByPost failed: %v", err)
 801+	}
 802+
 803+	found, _ := testDB.FindPostWithFilename("tagged.md", user.ID, "prose")
 804+	if len(found.Tags) != 2 {
 805+		t.Errorf("expected 2 tags, got %d", len(found.Tags))
 806+	}
 807+}
 808+
 809+func TestFindUserPostsByTag(t *testing.T) {
 810+	cleanupTestData(t)
 811+
 812+	user, _ := testDB.RegisterUser("usertagowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI usertagowner", "comment")
 813+	now := time.Now()
 814+	post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "usertag.md", Slug: "usertag", Title: "User Tag", Space: "prose", PublishAt: &now})
 815+	_ = testDB.ReplaceTagsByPost([]string{"mytag"}, post.ID)
 816+
 817+	pager := &db.Pager{Num: 10, Page: 0}
 818+	result, err := testDB.FindUserPostsByTag(pager, "mytag", user.ID, "prose")
 819+	if err != nil {
 820+		t.Fatalf("FindUserPostsByTag failed: %v", err)
 821+	}
 822+	if len(result.Data) != 1 {
 823+		t.Errorf("expected 1 post with tag, got %d", len(result.Data))
 824+	}
 825+}
 826+
 827+func TestFindPostsByTag(t *testing.T) {
 828+	cleanupTestData(t)
 829+
 830+	user, _ := testDB.RegisterUser("tagsearchowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tagsearchowner", "comment")
 831+	now := time.Now()
 832+	post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "tagsearch.md", Slug: "tagsearch", Title: "Tag Search", Space: "prose", PublishAt: &now})
 833+	_ = testDB.ReplaceTagsByPost([]string{"searchtag"}, post.ID)
 834+
 835+	pager := &db.Pager{Num: 10, Page: 0}
 836+	result, err := testDB.FindPostsByTag(pager, "searchtag", "prose")
 837+	if err != nil {
 838+		t.Fatalf("FindPostsByTag failed: %v", err)
 839+	}
 840+	if len(result.Data) != 1 {
 841+		t.Errorf("expected 1 post with tag, got %d", len(result.Data))
 842+	}
 843+}
 844+
 845+func TestFindPopularTags(t *testing.T) {
 846+	cleanupTestData(t)
 847+
 848+	user, _ := testDB.RegisterUser("populartagowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI populartagowner", "comment")
 849+	post1 := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "pop1.md", Slug: "pop1", Title: "Pop 1", Space: "prose"})
 850+	post2 := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "pop2.md", Slug: "pop2", Title: "Pop 2", Space: "prose"})
 851+	_ = testDB.ReplaceTagsByPost([]string{"popular"}, post1.ID)
 852+	_ = testDB.ReplaceTagsByPost([]string{"popular"}, post2.ID)
 853+
 854+	tags, err := testDB.FindPopularTags("prose")
 855+	if err != nil {
 856+		t.Fatalf("FindPopularTags failed: %v", err)
 857+	}
 858+	if len(tags) < 1 {
 859+		t.Errorf("expected at least 1 popular tag, got %d", len(tags))
 860+	}
 861+}
 862+
 863+// ============ Aliases Tests ============
 864+
 865+func TestReplaceAliasesByPost(t *testing.T) {
 866+	cleanupTestData(t)
 867+
 868+	user, _ := testDB.RegisterUser("aliasowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI aliasowner", "comment")
 869+	post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "aliased.md", Slug: "aliased", Title: "Aliased", Space: "prose"})
 870+
 871+	err := testDB.ReplaceAliasesByPost([]string{"alias1", "alias2"}, post.ID)
 872+	if err != nil {
 873+		t.Fatalf("ReplaceAliasesByPost failed: %v", err)
 874+	}
 875+}
 876+
 877+func TestFindPostWithSlug_Alias(t *testing.T) {
 878+	cleanupTestData(t)
 879+
 880+	user, _ := testDB.RegisterUser("aliassearchowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI aliassearchowner", "comment")
 881+	post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "original.md", Slug: "original", Title: "Original", Space: "prose"})
 882+	_ = testDB.ReplaceAliasesByPost([]string{"my-alias"}, post.ID)
 883+
 884+	found, err := testDB.FindPostWithSlug("my-alias", user.ID, "prose")
 885+	if err != nil {
 886+		t.Fatalf("FindPostWithSlug for alias failed: %v", err)
 887+	}
 888+	if found.Title != "Original" {
 889+		t.Errorf("expected title 'Original', got '%s'", found.Title)
 890+	}
 891+}
 892+
 893+// ============ Analytics Tests ============
 894+
 895+func TestInsertVisit(t *testing.T) {
 896+	cleanupTestData(t)
 897+
 898+	user, _ := testDB.RegisterUser("visitowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI visitowner", "comment")
 899+
 900+	visit := &db.AnalyticsVisits{
 901+		UserID:    user.ID,
 902+		Host:      "example.com",
 903+		Path:      "/test",
 904+		IpAddress: "192.168.1.1",
 905+		UserAgent: "TestAgent/1.0",
 906+		Referer:   "https://referrer.com",
 907+		Status:    200,
 908+	}
 909+
 910+	err := testDB.InsertVisit(visit)
 911+	if err != nil {
 912+		t.Fatalf("InsertVisit failed: %v", err)
 913+	}
 914+}
 915+
 916+func TestVisitSummary(t *testing.T) {
 917+	cleanupTestData(t)
 918+
 919+	user, _ := testDB.RegisterUser("summaryowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI summaryowner", "comment")
 920+
 921+	visit := &db.AnalyticsVisits{
 922+		UserID:    user.ID,
 923+		Host:      "summary.com",
 924+		Path:      "/page",
 925+		IpAddress: "192.168.1.2",
 926+		Status:    200,
 927+	}
 928+	_ = testDB.InsertVisit(visit)
 929+
 930+	opts := &db.SummaryOpts{
 931+		Interval: "day",
 932+		Origin:   time.Now().Add(-24 * time.Hour),
 933+		Host:     "summary.com",
 934+		UserID:   user.ID,
 935+	}
 936+
 937+	summary, err := testDB.VisitSummary(opts)
 938+	if err != nil {
 939+		t.Fatalf("VisitSummary failed: %v", err)
 940+	}
 941+	if summary == nil {
 942+		t.Error("expected summary, got nil")
 943+	}
 944+}
 945+
 946+func TestFindVisitSiteList(t *testing.T) {
 947+	cleanupTestData(t)
 948+
 949+	user, _ := testDB.RegisterUser("sitelistowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI sitelistowner", "comment")
 950+
 951+	_ = testDB.InsertVisit(&db.AnalyticsVisits{
 952+		UserID:    user.ID,
 953+		Host:      "site1.com",
 954+		Path:      "/",
 955+		IpAddress: "192.168.1.3",
 956+		Status:    200,
 957+	})
 958+
 959+	opts := &db.SummaryOpts{UserID: user.ID}
 960+	sites, err := testDB.FindVisitSiteList(opts)
 961+	if err != nil {
 962+		t.Fatalf("FindVisitSiteList failed: %v", err)
 963+	}
 964+	if len(sites) < 1 {
 965+		t.Errorf("expected at least 1 site, got %d", len(sites))
 966+	}
 967+}
 968+
 969+func TestVisitUrlNotFound(t *testing.T) {
 970+	cleanupTestData(t)
 971+
 972+	user, _ := testDB.RegisterUser("notfoundowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI notfoundowner", "comment")
 973+
 974+	_ = testDB.InsertVisit(&db.AnalyticsVisits{
 975+		UserID:    user.ID,
 976+		Host:      "notfound.com",
 977+		Path:      "/missing",
 978+		IpAddress: "192.168.1.4",
 979+		Status:    404,
 980+	})
 981+
 982+	opts := &db.SummaryOpts{
 983+		Origin: time.Now().Add(-24 * time.Hour),
 984+		Host:   "notfound.com",
 985+		UserID: user.ID,
 986+	}
 987+	notFound, err := testDB.VisitUrlNotFound(opts)
 988+	if err != nil {
 989+		t.Fatalf("VisitUrlNotFound failed: %v", err)
 990+	}
 991+	if len(notFound) < 1 {
 992+		t.Errorf("expected at least 1 not found URL, got %d", len(notFound))
 993+	}
 994+}
 995+
 996+// ============ Features Tests ============
 997+
 998+func TestInsertFeature(t *testing.T) {
 999+	cleanupTestData(t)
1000+
1001+	user, _ := testDB.RegisterUser("featureowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI featureowner", "comment")
1002+
1003+	expiresAt := time.Now().Add(365 * 24 * time.Hour)
1004+	feature, err := testDB.InsertFeature(user.ID, "plus", expiresAt)
1005+	if err != nil {
1006+		t.Fatalf("InsertFeature failed: %v", err)
1007+	}
1008+	if feature.Name != "plus" {
1009+		t.Errorf("expected feature name 'plus', got '%s'", feature.Name)
1010+	}
1011+}
1012+
1013+func TestFindFeature(t *testing.T) {
1014+	cleanupTestData(t)
1015+
1016+	user, _ := testDB.RegisterUser("findfeatureowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findfeatureowner", "comment")
1017+	expiresAt := time.Now().Add(365 * 24 * time.Hour)
1018+	_, _ = testDB.InsertFeature(user.ID, "plus", expiresAt)
1019+
1020+	feature, err := testDB.FindFeature(user.ID, "plus")
1021+	if err != nil {
1022+		t.Fatalf("FindFeature failed: %v", err)
1023+	}
1024+	if feature.Name != "plus" {
1025+		t.Errorf("expected feature name 'plus', got '%s'", feature.Name)
1026+	}
1027+}
1028+
1029+func TestFindFeaturesByUser(t *testing.T) {
1030+	cleanupTestData(t)
1031+
1032+	user, _ := testDB.RegisterUser("featuresowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI featuresowner", "comment")
1033+	expiresAt := time.Now().Add(365 * 24 * time.Hour)
1034+	_, _ = testDB.InsertFeature(user.ID, "plus", expiresAt)
1035+	_, _ = testDB.InsertFeature(user.ID, "pro", expiresAt)
1036+
1037+	features, err := testDB.FindFeaturesByUser(user.ID)
1038+	if err != nil {
1039+		t.Fatalf("FindFeaturesByUser failed: %v", err)
1040+	}
1041+	if len(features) != 2 {
1042+		t.Errorf("expected 2 features, got %d", len(features))
1043+	}
1044+}
1045+
1046+func TestHasFeatureByUser(t *testing.T) {
1047+	cleanupTestData(t)
1048+
1049+	user, _ := testDB.RegisterUser("hasfeatureowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI hasfeatureowner", "comment")
1050+	expiresAt := time.Now().Add(365 * 24 * time.Hour)
1051+	_, _ = testDB.InsertFeature(user.ID, "plus", expiresAt)
1052+
1053+	has := testDB.HasFeatureByUser(user.ID, "plus")
1054+	if !has {
1055+		t.Error("expected HasFeatureByUser to return true")
1056+	}
1057+
1058+	hasNot := testDB.HasFeatureByUser(user.ID, "nonexistent")
1059+	if hasNot {
1060+		t.Error("expected HasFeatureByUser to return false for nonexistent feature")
1061+	}
1062+}
1063+
1064+func TestRemoveFeature(t *testing.T) {
1065+	cleanupTestData(t)
1066+
1067+	user, _ := testDB.RegisterUser("removefeatureowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI removefeatureowner", "comment")
1068+	expiresAt := time.Now().Add(365 * 24 * time.Hour)
1069+	_, _ = testDB.InsertFeature(user.ID, "plus", expiresAt)
1070+
1071+	err := testDB.RemoveFeature(user.ID, "plus")
1072+	if err != nil {
1073+		t.Fatalf("RemoveFeature failed: %v", err)
1074+	}
1075+
1076+	_, err = testDB.FindFeature(user.ID, "plus")
1077+	if err == nil {
1078+		t.Error("expected error finding removed feature, got nil")
1079+	}
1080+}
1081+
1082+func TestAddPicoPlusUser(t *testing.T) {
1083+	cleanupTestData(t)
1084+
1085+	_, _ = testDB.RegisterUser("picoplusowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI picoplusowner", "comment")
1086+
1087+	err := testDB.AddPicoPlusUser("picoplusowner", "test@example.com", "stripe", "tx123")
1088+	if err != nil {
1089+		t.Fatalf("AddPicoPlusUser failed: %v", err)
1090+	}
1091+}
1092+
1093+// ============ Feed Items Tests ============
1094+
1095+func TestInsertFeedItems(t *testing.T) {
1096+	cleanupTestData(t)
1097+
1098+	user, _ := testDB.RegisterUser("feeditemsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feeditemsowner", "comment")
1099+	post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feed.txt", Slug: "feed", Title: "Feed", Space: "feeds"})
1100+
1101+	items := []*db.FeedItem{
1102+		{PostID: post.ID, GUID: "guid-1", Data: db.FeedItemData{Title: "Item 1", Link: "http://example.com/1"}},
1103+		{PostID: post.ID, GUID: "guid-2", Data: db.FeedItemData{Title: "Item 2", Link: "http://example.com/2"}},
1104+	}
1105+
1106+	err := testDB.InsertFeedItems(post.ID, items)
1107+	if err != nil {
1108+		t.Fatalf("InsertFeedItems failed: %v", err)
1109+	}
1110+}
1111+
1112+func TestFindFeedItemsByPostID(t *testing.T) {
1113+	cleanupTestData(t)
1114+
1115+	user, _ := testDB.RegisterUser("findfeeditemsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findfeeditemsowner", "comment")
1116+	post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "findfeed.txt", Slug: "findfeed", Title: "Find Feed", Space: "feeds"})
1117+
1118+	items := []*db.FeedItem{
1119+		{PostID: post.ID, GUID: "find-guid-1", Data: db.FeedItemData{Title: "Find Item 1"}},
1120+	}
1121+	_ = testDB.InsertFeedItems(post.ID, items)
1122+
1123+	found, err := testDB.FindFeedItemsByPostID(post.ID)
1124+	if err != nil {
1125+		t.Fatalf("FindFeedItemsByPostID failed: %v", err)
1126+	}
1127+	if len(found) != 1 {
1128+		t.Errorf("expected 1 feed item, got %d", len(found))
1129+	}
1130+}
1131+
1132+// ============ Projects Tests ============
1133+
1134+func TestUpsertProject(t *testing.T) {
1135+	cleanupTestData(t)
1136+
1137+	user, _ := testDB.RegisterUser("projectowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI projectowner", "comment")
1138+
1139+	project, err := testDB.UpsertProject(user.ID, "my-project", "my-project")
1140+	if err != nil {
1141+		t.Fatalf("UpsertProject failed: %v", err)
1142+	}
1143+	if project.Name != "my-project" {
1144+		t.Errorf("expected project name 'my-project', got '%s'", project.Name)
1145+	}
1146+
1147+	project2, err := testDB.UpsertProject(user.ID, "my-project", "my-project")
1148+	if err != nil {
1149+		t.Fatalf("UpsertProject (update) failed: %v", err)
1150+	}
1151+	if project2.ID != project.ID {
1152+		t.Error("expected same project ID on upsert")
1153+	}
1154+}
1155+
1156+func TestFindProjectByName(t *testing.T) {
1157+	cleanupTestData(t)
1158+
1159+	user, _ := testDB.RegisterUser("findprojectowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findprojectowner", "comment")
1160+	_, _ = testDB.UpsertProject(user.ID, "findme-project", "findme-project")
1161+
1162+	project, err := testDB.FindProjectByName(user.ID, "findme-project")
1163+	if err != nil {
1164+		t.Fatalf("FindProjectByName failed: %v", err)
1165+	}
1166+	if project.Name != "findme-project" {
1167+		t.Errorf("expected project name 'findme-project', got '%s'", project.Name)
1168+	}
1169+}
1170+
1171+// ============ User Stats Tests ============
1172+
1173+func TestFindUserStats(t *testing.T) {
1174+	cleanupTestData(t)
1175+
1176+	user, _ := testDB.RegisterUser("statsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI statsowner", "comment")
1177+	_ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "stat.md", Slug: "stat", Title: "Stat", Space: "prose"})
1178+	_, _ = testDB.UpsertProject(user.ID, "stat-project", "stat-project")
1179+
1180+	stats, err := testDB.FindUserStats(user.ID)
1181+	if err != nil {
1182+		t.Fatalf("FindUserStats failed: %v", err)
1183+	}
1184+	if stats.Prose.Num != 1 {
1185+		t.Errorf("expected 1 prose post, got %d", stats.Prose.Num)
1186+	}
1187+	if stats.Pages.Num != 1 {
1188+		t.Errorf("expected 1 project, got %d", stats.Pages.Num)
1189+	}
1190+}
1191+
1192+// ============ Tuns Event Logs Tests ============
1193+
1194+func TestInsertTunsEventLog(t *testing.T) {
1195+	cleanupTestData(t)
1196+
1197+	user, _ := testDB.RegisterUser("tunsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tunsowner", "comment")
1198+
1199+	log := &db.TunsEventLog{
1200+		UserId:         user.ID,
1201+		ServerID:       "server-1",
1202+		RemoteAddr:     "192.168.1.1:1234",
1203+		EventType:      "connect",
1204+		TunnelType:     "http",
1205+		ConnectionType: "tcp",
1206+		TunnelID:       "tunnel-123",
1207+	}
1208+
1209+	err := testDB.InsertTunsEventLog(log)
1210+	if err != nil {
1211+		t.Fatalf("InsertTunsEventLog failed: %v", err)
1212+	}
1213+}
1214+
1215+func TestFindTunsEventLogs(t *testing.T) {
1216+	cleanupTestData(t)
1217+
1218+	user, _ := testDB.RegisterUser("findtunsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findtunsowner", "comment")
1219+	_ = testDB.InsertTunsEventLog(&db.TunsEventLog{
1220+		UserId:         user.ID,
1221+		ServerID:       "server-1",
1222+		RemoteAddr:     "192.168.1.1:1234",
1223+		EventType:      "connect",
1224+		TunnelType:     "http",
1225+		ConnectionType: "tcp",
1226+		TunnelID:       "tunnel-456",
1227+	})
1228+
1229+	logs, err := testDB.FindTunsEventLogs(user.ID)
1230+	if err != nil {
1231+		t.Fatalf("FindTunsEventLogs failed: %v", err)
1232+	}
1233+	if len(logs) != 1 {
1234+		t.Errorf("expected 1 log, got %d", len(logs))
1235+	}
1236+}
1237+
1238+func TestFindTunsEventLogsByAddr(t *testing.T) {
1239+	cleanupTestData(t)
1240+
1241+	user, _ := testDB.RegisterUser("tunsaddrowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tunsaddrowner", "comment")
1242+	_ = testDB.InsertTunsEventLog(&db.TunsEventLog{
1243+		UserId:         user.ID,
1244+		ServerID:       "server-1",
1245+		RemoteAddr:     "192.168.1.1:1234",
1246+		EventType:      "connect",
1247+		TunnelType:     "http",
1248+		ConnectionType: "tcp",
1249+		TunnelID:       "tunnel-789",
1250+	})
1251+
1252+	logs, err := testDB.FindTunsEventLogsByAddr(user.ID, "tunnel-789")
1253+	if err != nil {
1254+		t.Fatalf("FindTunsEventLogsByAddr failed: %v", err)
1255+	}
1256+	if len(logs) != 1 {
1257+		t.Errorf("expected 1 log, got %d", len(logs))
1258+	}
1259+}
1260+
1261+// ============ Access Logs Tests ============
1262+
1263+func TestInsertAccessLog(t *testing.T) {
1264+	cleanupTestData(t)
1265+
1266+	user, _ := testDB.RegisterUser("accesslogowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI accesslogowner", "comment")
1267+
1268+	log := &db.AccessLog{
1269+		UserID:   user.ID,
1270+		Service:  "pgs",
1271+		Pubkey:   "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI accesslogowner",
1272+		Identity: "accesslogowner",
1273+	}
1274+
1275+	err := testDB.InsertAccessLog(log)
1276+	if err != nil {
1277+		t.Fatalf("InsertAccessLog failed: %v", err)
1278+	}
1279+}
1280+
1281+func TestFindAccessLogs(t *testing.T) {
1282+	cleanupTestData(t)
1283+
1284+	user, _ := testDB.RegisterUser("findaccesslogowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findaccesslogowner", "comment")
1285+	_ = testDB.InsertAccessLog(&db.AccessLog{
1286+		UserID:   user.ID,
1287+		Service:  "pgs",
1288+		Pubkey:   "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findaccesslogowner",
1289+		Identity: "findaccesslogowner",
1290+	})
1291+
1292+	fromDate := time.Now().Add(-24 * time.Hour)
1293+	logs, err := testDB.FindAccessLogs(user.ID, &fromDate)
1294+	if err != nil {
1295+		t.Fatalf("FindAccessLogs failed: %v", err)
1296+	}
1297+	if len(logs) != 1 {
1298+		t.Errorf("expected 1 log, got %d", len(logs))
1299+	}
1300+}
1301+
1302+func TestFindPubkeysInAccessLogs(t *testing.T) {
1303+	cleanupTestData(t)
1304+
1305+	user, _ := testDB.RegisterUser("pubkeylogowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pubkeylogowner", "comment")
1306+	_ = testDB.InsertAccessLog(&db.AccessLog{
1307+		UserID:   user.ID,
1308+		Service:  "pgs",
1309+		Pubkey:   "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pubkeylogowner",
1310+		Identity: "pubkeylogowner",
1311+	})
1312+
1313+	pubkeys, err := testDB.FindPubkeysInAccessLogs(user.ID)
1314+	if err != nil {
1315+		t.Fatalf("FindPubkeysInAccessLogs failed: %v", err)
1316+	}
1317+	if len(pubkeys) != 1 {
1318+		t.Errorf("expected 1 pubkey, got %d", len(pubkeys))
1319+	}
1320+}
1321+
1322+func TestFindAccessLogsByPubkey(t *testing.T) {
1323+	cleanupTestData(t)
1324+
1325+	user, _ := testDB.RegisterUser("accessbypubkeyowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI accessbypubkeyowner", "comment")
1326+	pubkey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI accessbypubkeyowner"
1327+	_ = testDB.InsertAccessLog(&db.AccessLog{
1328+		UserID:   user.ID,
1329+		Service:  "pgs",
1330+		Pubkey:   pubkey,
1331+		Identity: "accessbypubkeyowner",
1332+	})
1333+
1334+	fromDate := time.Now().Add(-24 * time.Hour)
1335+	logs, err := testDB.FindAccessLogsByPubkey(pubkey, &fromDate)
1336+	if err != nil {
1337+		t.Fatalf("FindAccessLogsByPubkey failed: %v", err)
1338+	}
1339+	if len(logs) != 1 {
1340+		t.Errorf("expected 1 log, got %d", len(logs))
1341+	}
1342+}
1343+
1344+// ============ JSONB Roundtrip Tests ============
1345+
1346+func TestPostData_JSONBRoundtrip(t *testing.T) {
1347+	cleanupTestData(t)
1348+
1349+	user, _ := testDB.RegisterUser("postdataowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI postdataowner", "comment")
1350+
1351+	now := time.Now().Truncate(time.Second)
1352+	postData := db.PostData{
1353+		ImgPath:    "/images/test.png",
1354+		LastDigest: &now,
1355+		Attempts:   5,
1356+	}
1357+
1358+	post := mustInsertPost(t, &db.Post{
1359+		UserID:   user.ID,
1360+		Filename: "jsonb.md",
1361+		Slug:     "jsonb",
1362+		Title:    "JSONB Test",
1363+		Space:    "prose",
1364+		Data:     postData,
1365+	})
1366+
1367+	found, err := testDB.FindPost(post.ID)
1368+	if err != nil {
1369+		t.Fatalf("FindPost failed: %v", err)
1370+	}
1371+	if found.Data.ImgPath != "/images/test.png" {
1372+		t.Errorf("expected ImgPath '/images/test.png', got '%s'", found.Data.ImgPath)
1373+	}
1374+	if found.Data.Attempts != 5 {
1375+		t.Errorf("expected Attempts 5, got %d", found.Data.Attempts)
1376+	}
1377+}
1378+
1379+func TestProjectAcl_JSONBRoundtrip(t *testing.T) {
1380+	cleanupTestData(t)
1381+
1382+	user, _ := testDB.RegisterUser("aclowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI aclowner", "comment")
1383+
1384+	project, err := testDB.UpsertProject(user.ID, "acl-project", "acl-project")
1385+	if err != nil {
1386+		t.Fatalf("UpsertProject failed: %v", err)
1387+	}
1388+
1389+	found, err := testDB.FindProjectByName(user.ID, "acl-project")
1390+	if err != nil {
1391+		t.Fatalf("FindProjectByName failed: %v", err)
1392+	}
1393+	if found.Acl.Type != "public" {
1394+		t.Errorf("expected Acl.Type 'public', got '%s'", found.Acl.Type)
1395+	}
1396+	_ = project
1397+}
1398+
1399+func TestFeedItemData_JSONBRoundtrip(t *testing.T) {
1400+	cleanupTestData(t)
1401+
1402+	user, _ := testDB.RegisterUser("feedjsonbowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feedjsonbowner", "comment")
1403+	post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedjsonb.txt", Slug: "feedjsonb", Title: "Feed JSONB", Space: "feeds"})
1404+
1405+	now := time.Now().Truncate(time.Second)
1406+	items := []*db.FeedItem{
1407+		{
1408+			PostID: post.ID,
1409+			GUID:   "jsonb-guid",
1410+			Data: db.FeedItemData{
1411+				Title:       "JSONB Item",
1412+				Description: "Description",
1413+				Content:     "Content",
1414+				Link:        "http://example.com",
1415+				PublishedAt: &now,
1416+			},
1417+		},
1418+	}
1419+	_ = testDB.InsertFeedItems(post.ID, items)
1420+
1421+	found, err := testDB.FindFeedItemsByPostID(post.ID)
1422+	if err != nil {
1423+		t.Fatalf("FindFeedItemsByPostID failed: %v", err)
1424+	}
1425+	if len(found) != 1 {
1426+		t.Fatalf("expected 1 item, got %d", len(found))
1427+	}
1428+	if found[0].Data.Title != "JSONB Item" {
1429+		t.Errorf("expected title 'JSONB Item', got '%s'", found[0].Data.Title)
1430+	}
1431+}
1432+
1433+func TestFeatureFlagData_JSONBRoundtrip(t *testing.T) {
1434+	cleanupTestData(t)
1435+
1436+	user, _ := testDB.RegisterUser("featurejsonbowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI featurejsonbowner", "comment")
1437+
1438+	err := testDB.AddPicoPlusUser("featurejsonbowner", "test@example.com", "stripe", "tx456")
1439+	if err != nil {
1440+		t.Fatalf("AddPicoPlusUser failed: %v", err)
1441+	}
1442+
1443+	feature, err := testDB.FindFeature(user.ID, "plus")
1444+	if err != nil {
1445+		t.Fatalf("FindFeature failed: %v", err)
1446+	}
1447+	if feature.Data.StorageMax != 10000000000 {
1448+		t.Errorf("expected StorageMax 10000000000, got %d", feature.Data.StorageMax)
1449+	}
1450+	if feature.Data.FileMax != 50000000 {
1451+		t.Errorf("expected FileMax 50000000, got %d", feature.Data.FileMax)
1452+	}
1453+}
1454+
1455+func TestPaymentHistoryData_JSONBRoundtrip(t *testing.T) {
1456+	cleanupTestData(t)
1457+
1458+	user, _ := testDB.RegisterUser("paymentjsonbowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI paymentjsonbowner", "comment")
1459+
1460+	err := testDB.AddPicoPlusUser("paymentjsonbowner", "payment@example.com", "stripe", "tx789")
1461+	if err != nil {
1462+		t.Fatalf("AddPicoPlusUser failed: %v", err)
1463+	}
1464+
1465+	var txId string
1466+	err = testDB.Db.QueryRow("SELECT data->>'tx_id' FROM payment_history WHERE user_id = $1", user.ID).Scan(&txId)
1467+	if err != nil {
1468+		t.Fatalf("failed to query payment history: %v", err)
1469+	}
1470+	if txId != "tx789" {
1471+		t.Errorf("expected tx_id 'tx789', got '%s'", txId)
1472+	}
1473+}
M pkg/db/stub/stub.go
+13, -74
  1@@ -1,7 +1,6 @@
  2 package stub
  3 
  4 import (
  5-	"database/sql"
  6 	"fmt"
  7 	"log/slog"
  8 	"time"
  9@@ -29,27 +28,15 @@ func (me *StubDB) RegisterUser(username, pubkey, comment string) (*db.User, erro
 10 	return nil, errNotImpl
 11 }
 12 
 13-func (me *StubDB) RemoveUsers(userIDs []string) error {
 14-	return errNotImpl
 15-}
 16-
 17-func (me *StubDB) InsertPublicKey(userID, key, name string, tx *sql.Tx) error {
 18-	return errNotImpl
 19-}
 20-
 21 func (me *StubDB) UpdatePublicKey(pubkeyID, name string) (*db.PublicKey, error) {
 22 	return nil, errNotImpl
 23 }
 24 
 25-func (me *StubDB) FindPublicKeyForKey(key string) (*db.PublicKey, error) {
 26-	return nil, errNotImpl
 27-}
 28-
 29-func (me *StubDB) FindPublicKey(pubkeyID string) (*db.PublicKey, error) {
 30-	return nil, errNotImpl
 31+func (me *StubDB) InsertPublicKey(userID, key, name string) error {
 32+	return errNotImpl
 33 }
 34 
 35-func (me *StubDB) FindKeysForUser(user *db.User) ([]*db.PublicKey, error) {
 36+func (me *StubDB) FindKeysByUser(user *db.User) ([]*db.PublicKey, error) {
 37 	return []*db.PublicKey{}, errNotImpl
 38 }
 39 
 40@@ -57,11 +44,7 @@ func (me *StubDB) RemoveKeys(keyIDs []string) error {
 41 	return errNotImpl
 42 }
 43 
 44-func (me *StubDB) FindPostsBeforeDate(date *time.Time, space string) ([]*db.Post, error) {
 45-	return []*db.Post{}, errNotImpl
 46-}
 47-
 48-func (me *StubDB) FindUserForKey(username string, key string) (*db.User, error) {
 49+func (me *StubDB) FindUserByKey(username string, key string) (*db.User, error) {
 50 	return nil, errNotImpl
 51 }
 52 
 53@@ -73,26 +56,14 @@ func (me *StubDB) FindUser(userID string) (*db.User, error) {
 54 	return nil, errNotImpl
 55 }
 56 
 57-func (me *StubDB) ValidateName(name string) (bool, error) {
 58-	return false, errNotImpl
 59-}
 60-
 61 func (me *StubDB) FindUserByName(name string) (*db.User, error) {
 62 	return nil, errNotImpl
 63 }
 64 
 65-func (me *StubDB) FindUserForNameAndKey(name string, key string) (*db.User, error) {
 66+func (me *StubDB) FindUserByToken(token string) (*db.User, error) {
 67 	return nil, errNotImpl
 68 }
 69 
 70-func (me *StubDB) FindUserForToken(token string) (*db.User, error) {
 71-	return nil, errNotImpl
 72-}
 73-
 74-func (me *StubDB) SetUserName(userID string, name string) error {
 75-	return errNotImpl
 76-}
 77-
 78 func (me *StubDB) FindPostWithFilename(filename string, persona_id string, space string) (*db.Post, error) {
 79 	return nil, errNotImpl
 80 }
 81@@ -105,11 +76,7 @@ func (me *StubDB) FindPost(postID string) (*db.Post, error) {
 82 	return nil, errNotImpl
 83 }
 84 
 85-func (me *StubDB) FindPostsForFeed(page *db.Pager, space string) (*db.Paginate[*db.Post], error) {
 86-	return &db.Paginate[*db.Post]{}, errNotImpl
 87-}
 88-
 89-func (me *StubDB) FindAllUpdatedPosts(page *db.Pager, space string) (*db.Paginate[*db.Post], error) {
 90+func (me *StubDB) FindPostsByFeed(page *db.Pager, space string) (*db.Paginate[*db.Post], error) {
 91 	return &db.Paginate[*db.Post]{}, errNotImpl
 92 }
 93 
 94@@ -125,11 +92,11 @@ func (me *StubDB) RemovePosts(postIDs []string) error {
 95 	return errNotImpl
 96 }
 97 
 98-func (me *StubDB) FindPostsForUser(page *db.Pager, userID string, space string) (*db.Paginate[*db.Post], error) {
 99+func (me *StubDB) FindPostsByUser(page *db.Pager, userID string, space string) (*db.Paginate[*db.Post], error) {
100 	return &db.Paginate[*db.Post]{}, errNotImpl
101 }
102 
103-func (me *StubDB) FindAllPostsForUser(userID string, space string) ([]*db.Post, error) {
104+func (me *StubDB) FindAllPostsByUser(userID string, space string) ([]*db.Post, error) {
105 	return []*db.Post{}, errNotImpl
106 }
107 
108@@ -141,10 +108,6 @@ func (me *StubDB) FindExpiredPosts(space string) ([]*db.Post, error) {
109 	return []*db.Post{}, errNotImpl
110 }
111 
112-func (me *StubDB) FindUpdatedPostsForUser(userID string, space string) ([]*db.Post, error) {
113-	return []*db.Post{}, errNotImpl
114-}
115-
116 func (me *StubDB) Close() error {
117 	return errNotImpl
118 }
119@@ -165,11 +128,11 @@ func (me *StubDB) FindUsers() ([]*db.User, error) {
120 	return []*db.User{}, errNotImpl
121 }
122 
123-func (me *StubDB) ReplaceTagsForPost(tags []string, postID string) error {
124+func (me *StubDB) ReplaceTagsByPost(tags []string, postID string) error {
125 	return errNotImpl
126 }
127 
128-func (me *StubDB) ReplaceAliasesForPost(aliases []string, postID string) error {
129+func (me *StubDB) ReplaceAliasesByPost(aliases []string, postID string) error {
130 	return errNotImpl
131 }
132 
133@@ -185,26 +148,18 @@ func (me *StubDB) FindPopularTags(space string) ([]string, error) {
134 	return []string{}, errNotImpl
135 }
136 
137-func (me *StubDB) FindTagsForPost(postID string) ([]string, error) {
138-	return []string{}, errNotImpl
139-}
140-
141 func (me *StubDB) FindFeature(userID string, feature string) (*db.FeatureFlag, error) {
142 	return nil, errNotImpl
143 }
144 
145-func (me *StubDB) FindFeaturesForUser(userID string) ([]*db.FeatureFlag, error) {
146+func (me *StubDB) FindFeaturesByUser(userID string) ([]*db.FeatureFlag, error) {
147 	return []*db.FeatureFlag{}, errNotImpl
148 }
149 
150-func (me *StubDB) HasFeatureForUser(userID string, feature string) bool {
151+func (me *StubDB) HasFeatureByUser(userID string, feature string) bool {
152 	return false
153 }
154 
155-func (me *StubDB) FindTotalSizeForUser(userID string) (int, error) {
156-	return 0, errNotImpl
157-}
158-
159 func (me *StubDB) InsertFeedItems(postID string, items []*db.FeedItem) error {
160 	return errNotImpl
161 }
162@@ -217,14 +172,6 @@ func (me *StubDB) UpsertProject(userID, name, projectDir string) (*db.Project, e
163 	return nil, errNotImpl
164 }
165 
166-func (me *StubDB) InsertProject(userID, name, projectDir string) (string, error) {
167-	return "", errNotImpl
168-}
169-
170-func (me *StubDB) UpdateProject(userID, name string) error {
171-	return errNotImpl
172-}
173-
174 func (me *StubDB) FindProjectByName(userID, name string) (*db.Project, error) {
175 	return &db.Project{}, errNotImpl
176 }
177@@ -237,15 +184,11 @@ func (me *StubDB) UpsertToken(userID, name string) (string, error) {
178 	return "", errNotImpl
179 }
180 
181-func (me *StubDB) FindTokenByName(userID, name string) (string, error) {
182-	return "", errNotImpl
183-}
184-
185 func (me *StubDB) RemoveToken(tokenID string) error {
186 	return errNotImpl
187 }
188 
189-func (me *StubDB) FindTokensForUser(userID string) ([]*db.Token, error) {
190+func (me *StubDB) FindTokensByUser(userID string) ([]*db.Token, error) {
191 	return []*db.Token{}, errNotImpl
192 }
193 
194@@ -261,10 +204,6 @@ func (me *StubDB) AddPicoPlusUser(username, email, paymentType, txId string) err
195 	return errNotImpl
196 }
197 
198-func (me *StubDB) FindTagsForUser(userID string, tag string) ([]string, error) {
199-	return []string{}, errNotImpl
200-}
201-
202 func (me *StubDB) FindUserStats(userID string) (*db.UserStats, error) {
203 	return nil, errNotImpl
204 }
M pkg/filehandlers/post_handler.go
+4, -4
 1@@ -197,7 +197,7 @@ func (h *ScpUploadHandler) Write(s *pssh.SSHServerConnSession, entry *sendutils.
 2 				"aliases",
 3 				strings.Join(metadata.Aliases, ","),
 4 			)
 5-			err = h.DBPool.ReplaceAliasesForPost(metadata.Aliases, post.ID)
 6+			err = h.DBPool.ReplaceAliasesByPost(metadata.Aliases, post.ID)
 7 			if err != nil {
 8 				logger.Error("post could not replace aliases", "err", err.Error())
 9 				return "", fmt.Errorf("error for %s: %v", filename, err)
10@@ -209,7 +209,7 @@ func (h *ScpUploadHandler) Write(s *pssh.SSHServerConnSession, entry *sendutils.
11 				"found post tags, replacing with old tags",
12 				"tags", strings.Join(metadata.Tags, ","),
13 			)
14-			err = h.DBPool.ReplaceTagsForPost(metadata.Tags, post.ID)
15+			err = h.DBPool.ReplaceTagsByPost(metadata.Tags, post.ID)
16 			if err != nil {
17 				logger.Error("post could not replace tags", "err", err.Error())
18 				return "", fmt.Errorf("error for %s: %v", filename, err)
19@@ -249,7 +249,7 @@ func (h *ScpUploadHandler) Write(s *pssh.SSHServerConnSession, entry *sendutils.
20 			"found post tags, replacing with old tags",
21 			"tags", strings.Join(metadata.Tags, ","),
22 		)
23-		err = h.DBPool.ReplaceTagsForPost(metadata.Tags, post.ID)
24+		err = h.DBPool.ReplaceTagsByPost(metadata.Tags, post.ID)
25 		if err != nil {
26 			logger.Error("post could not replace tags", "err", err.Error())
27 			return "", fmt.Errorf("error for %s: %v", filename, err)
28@@ -259,7 +259,7 @@ func (h *ScpUploadHandler) Write(s *pssh.SSHServerConnSession, entry *sendutils.
29 			"found post aliases, replacing with old aliases",
30 			"aliases", strings.Join(metadata.Aliases, ","),
31 		)
32-		err = h.DBPool.ReplaceAliasesForPost(metadata.Aliases, post.ID)
33+		err = h.DBPool.ReplaceAliasesByPost(metadata.Aliases, post.ID)
34 		if err != nil {
35 			logger.Error("post could not replace aliases", "err", err.Error())
36 			return "", fmt.Errorf("error for %s: %v", filename, err)
M pkg/filehandlers/router_handler.go
+1, -1
1@@ -151,7 +151,7 @@ func BaseList(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive
2 		})
3 
4 		for _, space := range spaces {
5-			curPosts, e := dbpool.FindAllPostsForUser(user.ID, space)
6+			curPosts, e := dbpool.FindAllPostsByUser(user.ID, space)
7 			if e != nil {
8 				err = e
9 				break
M pkg/shared/analytics.go
+2, -2
 1@@ -155,7 +155,7 @@ func CleanHost(raw string) (string, error) {
 2 var ErrAnalyticsDisabled = errors.New("owner does not have site analytics enabled")
 3 
 4 func AnalyticsVisitFromVisit(visit *db.AnalyticsVisits, dbpool db.DB, secret string) error {
 5-	if !dbpool.HasFeatureForUser(visit.UserID, "analytics") {
 6+	if !dbpool.HasFeatureByUser(visit.UserID, "analytics") {
 7 		return ErrAnalyticsDisabled
 8 	}
 9 
10@@ -206,7 +206,7 @@ func ipFromRequest(r *http.Request) string {
11 }
12 
13 func AnalyticsVisitFromRequest(r *http.Request, dbpool db.DB, userID string) (*db.AnalyticsVisits, error) {
14-	if !dbpool.HasFeatureForUser(userID, "analytics") {
15+	if !dbpool.HasFeatureByUser(userID, "analytics") {
16 		return nil, ErrAnalyticsDisabled
17 	}
18 
M pkg/shared/router.go
+3, -3
 1@@ -77,15 +77,15 @@ type ApiConfig struct {
 2 }
 3 
 4 func (hc *ApiConfig) HasPrivilegedAccess(apiToken string) bool {
 5-	user, err := hc.Dbpool.FindUserForToken(apiToken)
 6+	user, err := hc.Dbpool.FindUserByToken(apiToken)
 7 	if err != nil {
 8 		return false
 9 	}
10-	return hc.Dbpool.HasFeatureForUser(user.ID, "auth")
11+	return hc.Dbpool.HasFeatureByUser(user.ID, "auth")
12 }
13 
14 func (hc *ApiConfig) HasPlusOrSpace(user *db.User, space string) bool {
15-	return hc.Dbpool.HasFeatureForUser(user.ID, "plus") || hc.Dbpool.HasFeatureForUser(user.ID, space)
16+	return hc.Dbpool.HasFeatureByUser(user.ID, "plus") || hc.Dbpool.HasFeatureByUser(user.ID, space)
17 }
18 
19 func (hc *ApiConfig) CreateCtx(prevCtx context.Context, subdomain string) context.Context {
M pkg/tui/analytics.go
+1, -1
1@@ -393,7 +393,7 @@ func (m *AnalyticsPage) fetchSiteStats(site string, interval string) {
2 }
3 
4 func (m *AnalyticsPage) fetchFeatures() error {
5-	features, err := m.shared.Dbpool.FindFeaturesForUser(m.shared.User.ID)
6+	features, err := m.shared.Dbpool.FindFeaturesByUser(m.shared.User.ID)
7 	m.features = features
8 	return err
9 }
M pkg/tui/info.go
+1, -1
1@@ -127,7 +127,7 @@ func (m *FeaturesList) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
2 }
3 
4 func (m *FeaturesList) fetchFeatures() error {
5-	features, err := m.shared.Dbpool.FindFeaturesForUser(m.shared.User.ID)
6+	features, err := m.shared.Dbpool.FindFeaturesByUser(m.shared.User.ID)
7 	m.features = features
8 	return err
9 }
M pkg/tui/pubkeys.go
+2, -2
 1@@ -43,7 +43,7 @@ func (m *PubkeysPage) Footer() []Shortcut {
 2 }
 3 
 4 func (m *PubkeysPage) fetchKeys() error {
 5-	keys, err := m.shared.Dbpool.FindKeysForUser(m.shared.User)
 6+	keys, err := m.shared.Dbpool.FindKeysByUser(m.shared.User)
 7 	if err != nil {
 8 		return err
 9 
10@@ -256,7 +256,7 @@ func (m *AddKeyPage) addPubkey(pubkey string) error {
11 	key := utils.KeyForKeyText(pk)
12 
13 	return m.shared.Dbpool.InsertPublicKey(
14-		m.shared.User.ID, key, comment, nil,
15+		m.shared.User.ID, key, comment,
16 	)
17 }
18 
M pkg/tui/tokens.go
+1, -1
1@@ -41,7 +41,7 @@ func (m *TokensPage) Footer() []Shortcut {
2 }
3 
4 func (m *TokensPage) fetchTokens() error {
5-	tokens, err := m.shared.Dbpool.FindTokensForUser(m.shared.User.ID)
6+	tokens, err := m.shared.Dbpool.FindTokensByUser(m.shared.User.ID)
7 	if err != nil {
8 		return err
9 
M pkg/tui/ui.go
+2, -2
 1@@ -289,7 +289,7 @@ func FindUser(shrd *SharedModel) (*db.User, error) {
 2 
 3 	key := utils.KeyForKeyText(shrd.Session.PublicKey())
 4 
 5-	user, err := shrd.Dbpool.FindUserForKey(usr, key)
 6+	user, err := shrd.Dbpool.FindUserByKey(usr, key)
 7 	if err != nil {
 8 		logger.Error("no user found for public key", "err", err.Error())
 9 		// we only want to throw an error for specific cases
10@@ -304,7 +304,7 @@ func FindUser(shrd *SharedModel) (*db.User, error) {
11 
12 	// impersonation
13 	if strings.HasPrefix(usr, adminPrefix) {
14-		hasFeature := shrd.Dbpool.HasFeatureForUser(user.ID, "admin")
15+		hasFeature := shrd.Dbpool.HasFeatureByUser(user.ID, "admin")
16 		if hasFeature {
17 			impersonate := strings.TrimPrefix(usr, adminPrefix)
18 			user, err = shrd.Dbpool.FindUserByName(impersonate)