feat(metadata): SQLite backend, storage reconciliation and local defaults

- pluggable per-domain Repository interfaces; PostgreSQL and SQLite impls
- SQLite on modernc.org/sqlite (WAL, busy_timeout, foreign_keys, immediate tx)
- separate migrations/sqlite and db.MigrateSQLite; store.NewPostgres/NewSQLite
- cmd/reconcile compares object storage with the journal (report, then --delete)
- storage.Backend.List and photos.Repository.AllStorageKeys
- DB_BACKEND/SQLITE_PATH and STORAGE_BACKEND/FS_ROOT selectable; local defaults
  (sqlite ./galo.db, fs ./data)
- Makefile reconcile/reconcile-delete; docs and .env.example updated
This commit is contained in:
iu
2026-10-11 10:08:31 +03:00
parent ec8d907565
commit 176a8d8ced
69 changed files with 4174 additions and 95 deletions
+9 -4
View File
@@ -6,6 +6,11 @@ PG_PASSWORD=change_me
PG_DATABASE=shared_album
PG_SSLMODE=disable
# Metadata backend: sqlite (default, embedded single instance) or postgres
DB_BACKEND=sqlite
# Path to the SQLite database file (used when DB_BACKEND=sqlite)
SQLITE_PATH=./galo.db
# S3 (Garage on s3.localnet) - internal storage, never exposed to clients
S3_ENDPOINT=http://s3.localnet:3900
S3_REGION=garage
@@ -14,11 +19,11 @@ S3_SECRET_KEY=change_me
S3_BUCKET=galo-data
S3_USE_PATH_STYLE=true
# Storage backend: s3 (default, S3-compatible/Garage) or fs (local filesystem).
# Storage backend: fs (default, local filesystem) or s3 (S3-compatible/Garage).
# Blobs are always delivered through the backend via /api/objects/<key>.
STORAGE_BACKEND=s3
# Required when STORAGE_BACKEND=fs: root directory for object blobs
FS_ROOT=/var/lib/galo/objects
STORAGE_BACKEND=fs
# Root directory for object blobs (STORAGE_BACKEND=fs)
FS_ROOT=./data
# Signs object URLs; falls back to JWT_SECRET when empty
URL_SECRET=
# Lifetime of signed object URLs
+5
View File
@@ -7,3 +7,8 @@ frontend/build/
frontend/.svelte-kit/
node_modules/
*.log
# Local data backends (defaults)
galo.db
galo.db-*
data/
+10 -1
View File
@@ -1,4 +1,4 @@
.PHONY: build frontend backend run migrate tidy test clean
.PHONY: build frontend backend run migrate reconcile reconcile-delete tidy test clean
frontend:
cd frontend && npm ci && npm run build
@@ -20,6 +20,15 @@ run: backend
migrate:
cd backend && go run ./cmd/migrate
# Reconcile object storage against the metadata journal (report only).
reconcile:
cd backend && go run ./cmd/reconcile
# Reconcile and delete orphaned blobs older than --min-age (default 1h).
# Override the grace period when needed: make reconcile-delete MIN_AGE=1m
reconcile-delete:
cd backend && go run ./cmd/reconcile --delete $(if $(MIN_AGE),--min-age $(MIN_AGE),)
tidy:
cd backend && go mod tidy
+25
View File
@@ -33,6 +33,15 @@ cp .env.example .env
`garage`), `JWT_SECRET`, `JWT_ACCESS_TTL`, `JWT_REFRESH_TTL`, `HTTP_ADDR`,
`SSE_HEARTBEAT_INTERVAL`, `SSE_CLIENT_BUFFER`.
Слои данных выбираются параметрами (по умолчанию — локальный режим):
- `STORAGE_BACKEND=fs|s3` — где лежат блобы: `fs` (по умолчанию, `FS_ROOT=./data`)
или `s3` (S3/Garage); наружу всегда отдаётся бэкенд (`/api/objects/<key>`).
- `DB_BACKEND=sqlite|postgres` — где лежат метаданные: `sqlite` (по умолчанию,
`SQLITE_PATH=./galo.db`) или `postgres`. SQLite — встроенный режим без внешней БД,
**только для одного экземпляра приложения на файл** (без горизонтального
масштабирования).
## Сборка
```bash
@@ -94,6 +103,21 @@ TEST_DATABASE_URL='postgres://user:pass@pg.localnet:5432/galo?sslmode=disable' \
cd backend && go test ./internal/repo/ -v
```
## Реконсиляция хранилища
Сборка мусора воркера управляется журналом `objects` и не сканирует хранилище, поэтому
блобы без строки в журнале (после сброса БД в обход журнала и т.п.) накапливаются.
Ручная сверка:
```bash
go run ./cmd/reconcile # отчёт: сироты (есть в хранилище, нет в журнале) и пропавшие
go run ./cmd/reconcile --delete # удалить сироты старше --min-age (по умолчанию 1h)
```
- По умолчанию ничего не удаляется; удаление только по явному `--delete`.
- Область — префикс `objects/`; блобы старого формата `albums/` не затрагиваются.
- Пропавшие ключи (в журнале есть, в хранилище нет) только сообщаются.
## Деплой через systemd
1. Соберите бинарник: `make build`.
@@ -130,6 +154,7 @@ backend/
internal/config # чтение env
internal/db # pgx + golang-migrate
internal/storage # S3 (Garage) / локальная ФС, отдача через бэкенд
internal/store # фабрика репозиториев (postgres / sqlite)
internal/auth # JWT, bcrypt, refresh
internal/albums # альбомы
internal/members # участники, роли, инвайты
+162
View File
@@ -0,0 +1,162 @@
// Command reconcile compares the object storage with the metadata journal and
// reports orphaned blobs (present in storage, absent from the journal). With
// --delete it removes orphans older than --min-age. It never touches missing
// keys (in the journal but not in storage) and never runs automatically.
package main
import (
"bufio"
"context"
"flag"
"io/fs"
"log/slog"
"os"
"strings"
"time"
assets "shared-album"
"shared-album/internal/config"
"shared-album/internal/db"
"shared-album/internal/reconcile"
"shared-album/internal/storage"
"shared-album/internal/store"
)
const objectPrefix = "objects/"
func main() {
loadDotEnv(".env")
loadDotEnv("../.env")
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})))
deleteFlag := flag.Bool("delete", false, "delete orphaned blobs older than --min-age")
minAge := flag.Duration("min-age", time.Hour, "minimum blob age before deletion is allowed")
sample := flag.Int("sample", 20, "number of example keys to print")
flag.Parse()
cfg, err := config.Load()
if err != nil {
slog.Error("config error", "error", err)
os.Exit(1)
}
ctx := context.Background()
repos, closer, err := openRepos(ctx, cfg)
if err != nil {
slog.Error("metadata open failed", "error", err)
os.Exit(1)
}
defer closer()
blob, err := storage.Open(ctx, cfg)
if err != nil {
slog.Error("storage init failed", "error", err)
os.Exit(1)
}
objects, err := blob.List(ctx, objectPrefix)
if err != nil {
slog.Error("list storage", "error", err)
os.Exit(1)
}
journaled, err := repos.Photos.AllStorageKeys(ctx)
if err != nil {
slog.Error("list journal", "error", err)
os.Exit(1)
}
res := reconcile.Classify(objects, journaled)
slog.Info("reconciliation report",
"storage", cfg.Storage.Backend,
"storage_objects", len(objects),
"journal_keys", len(journaled),
"orphans", len(res.Orphans),
"missing", len(res.Missing))
printSamples("orphan", res.Orphans, *sample)
printSamples("missing", res.Missing, *sample)
if !*deleteFlag {
slog.Info("dry run: nothing deleted (pass --delete to remove orphans)")
return
}
targets := reconcile.Deletable(objects, res, objectPrefix, *minAge, time.Now())
if len(targets) == 0 {
slog.Info("no orphaned blobs old enough to delete", "min_age", minAge.String())
return
}
if err := blob.Delete(ctx, targets...); err != nil {
slog.Error("delete orphans", "error", err)
os.Exit(1)
}
slog.Info("deleted orphaned blobs", "count", len(targets), "min_age", minAge.String())
}
// openRepos builds the metadata repositories for the configured backend.
func openRepos(ctx context.Context, cfg *config.Config) (store.Repos, func(), error) {
if cfg.Database.Backend == "sqlite" {
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
return store.Repos{}, nil, err
}
if err := db.MigrateSQLite(cfg.Database.SQLitePath, migFS); err != nil {
return store.Repos{}, nil, err
}
database, err := db.OpenSQLite(cfg.Database.SQLitePath)
if err != nil {
return store.Repos{}, nil, err
}
return store.NewSQLite(database), func() { _ = database.Close() }, nil
}
migFS, err := fs.Sub(assets.Migrations, "migrations")
if err != nil {
return store.Repos{}, nil, err
}
if err := db.Migrate(cfg.Postgres.DSN(), migFS); err != nil {
return store.Repos{}, nil, err
}
pool, err := db.Connect(ctx, cfg.Postgres.DSN())
if err != nil {
return store.Repos{}, nil, err
}
return store.NewPostgres(pool), pool.Close, nil
}
func printSamples(label string, keys []string, n int) {
if len(keys) == 0 {
return
}
if n > len(keys) {
n = len(keys)
}
for _, k := range keys[:n] {
slog.Info("sample", "kind", label, "key", k)
}
}
func loadDotEnv(path string) {
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if _, exists := os.LookupEnv(key); !exists {
_ = os.Setenv(key, value)
}
}
}
+49 -20
View File
@@ -19,6 +19,7 @@ import (
"shared-album/internal/db"
"shared-album/internal/sse"
"shared-album/internal/storage"
"shared-album/internal/store"
)
func main() {
@@ -37,37 +38,65 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
migFS, err := fs.Sub(assets.Migrations, "migrations")
if err != nil {
slog.Error("migrations fs", "error", err)
os.Exit(1)
var (
repos store.Repos
ping func(ctx context.Context) error
closer func()
)
switch cfg.Database.Backend {
case "sqlite":
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
slog.Error("migrations fs", "error", err)
os.Exit(1)
}
if err := db.MigrateSQLite(cfg.Database.SQLitePath, migFS); err != nil {
slog.Error("migration failed", "error", err)
os.Exit(1)
}
database, err := db.OpenSQLite(cfg.Database.SQLitePath)
if err != nil {
slog.Error("database open failed", "error", err)
os.Exit(1)
}
repos = store.NewSQLite(database)
ping = database.PingContext
closer = func() { _ = database.Close() }
default:
migFS, err := fs.Sub(assets.Migrations, "migrations")
if err != nil {
slog.Error("migrations fs", "error", err)
os.Exit(1)
}
if err := db.Migrate(cfg.Postgres.DSN(), migFS); err != nil {
slog.Error("migration failed", "error", err)
os.Exit(1)
}
pool, err := db.Connect(ctx, cfg.Postgres.DSN())
if err != nil {
slog.Error("database connect failed", "error", err)
os.Exit(1)
}
repos = store.NewPostgres(pool)
ping = pool.Ping
closer = pool.Close
}
if err := db.Migrate(cfg.Postgres.DSN(), migFS); err != nil {
slog.Error("migration failed", "error", err)
os.Exit(1)
}
slog.Info("migrations applied")
defer closer()
slog.Info("migrations applied", "db_backend", cfg.Database.Backend)
pool, err := db.Connect(ctx, cfg.Postgres.DSN())
if err != nil {
slog.Error("database connect failed", "error", err)
os.Exit(1)
}
defer pool.Close()
store, err := storage.Open(ctx, cfg)
blob, err := storage.Open(ctx, cfg)
if err != nil {
slog.Error("storage init failed", "error", err)
os.Exit(1)
}
if err := store.EnsureBucket(ctx); err != nil {
if err := blob.EnsureBucket(ctx); err != nil {
slog.Warn("could not ensure bucket", "error", err)
} else {
slog.Info("storage bucket ready", "bucket", store.Bucket())
slog.Info("storage ready", "location", blob.Bucket())
}
hub := sse.NewHub(cfg.SSE.ClientBuffer)
server := app.New(cfg, pool, store, hub)
server := app.New(cfg, repos, ping, blob, hub)
go server.StartWorker(ctx)
+2 -2
View File
@@ -7,7 +7,7 @@ import "embed"
//go:embed all:static
var Static embed.FS
// Migrations holds the SQL migration files.
// Migrations holds the SQL migration files for every supported backend.
//
//go:embed migrations/*.sql
//go:embed migrations/*.sql migrations/sqlite/*.sql
var Migrations embed.FS
+8 -1
View File
@@ -17,6 +17,7 @@ require (
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.11.0
golang.org/x/crypto v0.58.0
modernc.org/sqlite v1.60.1
)
require (
@@ -39,6 +40,7 @@ require (
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
@@ -56,12 +58,14 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
@@ -73,4 +77,7 @@ require (
golang.org/x/text v0.43.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)
+44 -3
View File
@@ -85,6 +85,8 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LV
github.com/dsoprea/go-utility/v2 v2.0.0-20221003160719-7bc88537c05e/go.mod h1:VZ7cB0pTjm1ADBWhJUOHESu4ZYy9JN+ZPqjfiW09EPU=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
@@ -129,8 +131,12 @@ github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgR
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -155,8 +161,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
@@ -172,6 +178,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -187,6 +195,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -226,6 +236,8 @@ golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
@@ -241,7 +253,6 @@ golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -250,6 +261,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -262,3 +275,31 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+18
View File
@@ -0,0 +1,18 @@
package albums
import (
"context"
"shared-album/internal/models"
)
// Repository is the persistence contract for the albums domain.
type Repository interface {
Create(ctx context.Context, a *models.Album) error
OwnerID(ctx context.Context, id string) (string, error)
GetByID(ctx context.Context, id string) (*models.Album, error)
ListForUser(ctx context.Context, userID string) ([]models.Album, error)
Update(ctx context.Context, a *models.Album) error
Delete(ctx context.Context, id string) error
TrashPhotosByAlbum(ctx context.Context, albumID string) error
}
+2 -2
View File
@@ -18,13 +18,13 @@ type MemberLookup interface {
}
type Service struct {
store *Store
store Repository
members MemberLookup
hub *sse.Hub
storage storage.Backend
}
func NewService(store *Store, members MemberLookup, hub *sse.Hub, storage storage.Backend) *Service {
func NewService(store Repository, members MemberLookup, hub *sse.Hub, storage storage.Backend) *Service {
return &Service{store: store, members: members, hub: hub, storage: storage}
}
+142
View File
@@ -0,0 +1,142 @@
package albums
import (
"context"
"database/sql"
"errors"
"time"
"shared-album/internal/models"
"shared-album/internal/sqliteutil"
)
// SQLiteStore is the SQLite implementation of Repository.
type SQLiteStore struct {
db *sql.DB
}
func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
func (s *SQLiteStore) Create(ctx context.Context, a *models.Album) error {
var createdAt, updatedAt string
err := s.db.QueryRowContext(ctx,
`INSERT INTO albums (id, owner_id, title, description, cover_photo_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING created_at, updated_at`,
a.ID, a.OwnerID, a.Title, a.Description, a.CoverPhotoID,
).Scan(&createdAt, &updatedAt)
if err != nil {
return err
}
a.CreatedAt = sqliteutil.ParseTime(createdAt)
a.UpdatedAt = sqliteutil.ParseTime(updatedAt)
return nil
}
func (s *SQLiteStore) OwnerID(ctx context.Context, id string) (string, error) {
var ownerID string
err := s.db.QueryRowContext(ctx, `SELECT owner_id FROM albums WHERE id = $1`, id).Scan(&ownerID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return "", err
}
return ownerID, nil
}
const sqliteAlbumColumns = `a.id, a.owner_id, a.title, a.description, a.cover_photo_id,
a.created_at, a.updated_at, COALESCE(co.storage_key, ''),
(SELECT count(*) FROM photos WHERE album_id = a.id),
(SELECT count(*) FROM album_members WHERE album_id = a.id)`
const sqliteAlbumJoins = `
LEFT JOIN photo_objects cpo ON cpo.photo_id = a.cover_photo_id AND cpo.role = 'thumb'
LEFT JOIN objects co ON co.id = cpo.object_id`
func scanSQLiteAlbum(scan func(...any) error) (*models.Album, error) {
var a models.Album
var cover sql.NullString
var createdAt, updatedAt string
if err := scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &cover, &createdAt, &updatedAt,
&a.CoverThumbKey, &a.PhotosCount, &a.MembersCount); err != nil {
return nil, err
}
if cover.Valid {
a.CoverPhotoID = &cover.String
}
a.CreatedAt = sqliteutil.ParseTime(createdAt)
a.UpdatedAt = sqliteutil.ParseTime(updatedAt)
return &a, nil
}
func (s *SQLiteStore) GetByID(ctx context.Context, id string) (*models.Album, error) {
row := s.db.QueryRowContext(ctx,
`SELECT `+sqliteAlbumColumns+` FROM albums a`+sqliteAlbumJoins+` WHERE a.id = $1`, id)
a, err := scanSQLiteAlbum(row.Scan)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return a, nil
}
func (s *SQLiteStore) ListForUser(ctx context.Context, userID string) ([]models.Album, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT `+sqliteAlbumColumns+` FROM albums a
LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1`+sqliteAlbumJoins+`
WHERE a.owner_id = $1 OR m.user_id IS NOT NULL
ORDER BY a.created_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Album{}
for rows.Next() {
a, err := scanSQLiteAlbum(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, *a)
}
return out, rows.Err()
}
func (s *SQLiteStore) Update(ctx context.Context, a *models.Album) error {
var updatedAt string
err := s.db.QueryRowContext(ctx,
`UPDATE albums
SET title = $2, description = $3, cover_photo_id = $4, updated_at = $5
WHERE id = $1
RETURNING updated_at`,
a.ID, a.Title, a.Description, a.CoverPhotoID, sqliteutil.FormatTime(time.Now().UTC()),
).Scan(&updatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
a.UpdatedAt = sqliteutil.ParseTime(updatedAt)
return nil
}
func (s *SQLiteStore) Delete(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM albums WHERE id = $1`, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *SQLiteStore) TrashPhotosByAlbum(ctx context.Context, albumID string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE photos SET deleted_at = $2 WHERE album_id = $1 AND deleted_at IS NULL`,
albumID, sqliteutil.FormatTime(time.Now().UTC()))
return err
}
+18 -24
View File
@@ -9,7 +9,6 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
assets "shared-album"
"shared-album/internal/albums"
@@ -23,16 +22,17 @@ import (
"shared-album/internal/social"
"shared-album/internal/sse"
"shared-album/internal/storage"
"shared-album/internal/store"
)
type Server struct {
cfg *config.Config
pool *pgxpool.Pool
repos store.Repos
ping func(ctx context.Context) error
storage storage.Backend
hub *sse.Hub
albumsStore *albums.Store
albums *albums.Service
albums *albums.Service
auth *auth.Service
@@ -48,36 +48,30 @@ type Server struct {
share *sharelinks.Service
}
func New(cfg *config.Config, pool *pgxpool.Pool, st storage.Backend, hub *sse.Hub) *Server {
authStore := auth.NewStore(pool)
// New wires the domain services over the selected metadata repositories. ping
// reports the health of the metadata backend so health stays backend-agnostic.
func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) error, st storage.Backend, hub *sse.Hub) *Server {
tm := auth.NewTokenManager(cfg.Auth.JWTSecret, cfg.Auth.AccessTTL, cfg.Auth.RefreshTTL)
authSvc := auth.NewService(authStore, tm)
authSvc := auth.NewService(repos.Auth, tm)
albumsStore := albums.NewStore(pool)
membersStore := members.NewStore(pool)
notifSvc := notifications.NewService(repos.Notifications, hub)
notifStore := notifications.NewStore(pool)
notifSvc := notifications.NewService(notifStore, hub)
albumsSvc := albums.NewService(repos.Albums, repos.Members, hub, st)
membersSvc := members.NewService(repos.Members, repos.Albums, hub, notifSvc)
albumsSvc := albums.NewService(albumsStore, membersStore, hub, st)
membersSvc := members.NewService(membersStore, albumsStore, hub, notifSvc)
photosStore := photos.NewStore(pool)
photosSvc := photos.NewService(photosStore, albumsSvc, st, hub)
photosSvc := photos.NewService(repos.Photos, albumsSvc, st, hub)
photosWorker := photos.NewWorker(photosSvc, cfg.Worker.Interval, cfg.Worker.Batch, cfg.Worker.PendingTTL)
socialStore := social.NewStore(pool)
socialSvc := social.NewService(socialStore, albumsSvc, photosStore, hub, notifSvc)
socialSvc := social.NewService(repos.Social, albumsSvc, repos.Photos, hub, notifSvc)
shareStore := sharelinks.NewStore(pool)
shareSvc := sharelinks.NewService(shareStore, albumsSvc)
shareSvc := sharelinks.NewService(repos.ShareLinks, albumsSvc)
return &Server{
cfg: cfg,
pool: pool,
repos: repos,
ping: ping,
storage: st,
hub: hub,
albumsStore: albumsStore,
albums: albumsSvc,
auth: authSvc,
membersSvc: membersSvc,
@@ -183,7 +177,7 @@ func (s *Server) Router() *gin.Engine {
func (s *Server) health(c *gin.Context) {
ctx := c.Request.Context()
dbStatus := "ok"
if err := s.pool.Ping(ctx); err != nil {
if err := s.ping(ctx); err != nil {
dbStatus = "error"
}
storageLabel := s.cfg.Storage.Backend
@@ -243,7 +237,7 @@ func (s *Server) shareAlbum(c *gin.Context) {
httpx.RespondError(c, err)
return
}
album, err := s.albumsStore.GetByID(c.Request.Context(), link.AlbumID)
album, err := s.repos.Albums.GetByID(c.Request.Context(), link.AlbumID)
if err != nil {
httpx.RespondError(c, httpx.NotFound("album_not_found", "album not found"))
return
+2 -12
View File
@@ -9,7 +9,6 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/config"
"shared-album/internal/storage"
@@ -24,23 +23,14 @@ func mustSigner(t *testing.T) *storage.Signer {
return signer
}
func lazyPool(t *testing.T) *pgxpool.Pool {
t.Helper()
pool, err := pgxpool.New(context.Background(), "postgres://u:p@127.0.0.1:1/x?sslmode=disable")
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
return pool
}
func TestHealthStorageLabel(t *testing.T) {
gin.SetMode(gin.TestMode)
fs, err := storage.NewFS(t.TempDir(), mustSigner(t))
if err != nil {
t.Fatalf("fs: %v", err)
}
srv := &Server{cfg: &config.Config{}, pool: lazyPool(t), storage: fs}
ok := func(context.Context) error { return nil }
srv := &Server{cfg: &config.Config{}, ping: ok, storage: fs}
call := func() map[string]any {
rec := httptest.NewRecorder()
+3 -1
View File
@@ -15,6 +15,7 @@ import (
"shared-album/internal/config"
"shared-album/internal/sse"
"shared-album/internal/storage"
"shared-album/internal/store"
)
func newTestServerWithFS(t *testing.T) (*storage.FS, *gin.Engine) {
@@ -28,7 +29,8 @@ func newTestServerWithFS(t *testing.T) (*storage.FS, *gin.Engine) {
if err != nil {
t.Fatalf("new fs: %v", err)
}
srv := New(&config.Config{}, nil, fs, sse.NewHub(1))
noop := func(context.Context) error { return nil }
srv := New(&config.Config{}, store.Repos{}, noop, fs, sse.NewHub(1))
return fs, srv.Router()
}
+2 -1
View File
@@ -27,6 +27,7 @@ import (
"shared-album/internal/sharelinks"
"shared-album/internal/sse"
"shared-album/internal/storage"
"shared-album/internal/store"
)
func integrationPool(t *testing.T) *pgxpool.Pool {
@@ -70,7 +71,7 @@ func runShareFlow(t *testing.T, backend storage.Backend, verify func(t *testing.
gin.SetMode(gin.TestMode)
hub := sse.NewHub(1)
srv := New(&config.Config{}, pool, backend, hub)
srv := New(&config.Config{}, store.NewPostgres(pool), pool.Ping, backend, hub)
router := srv.Router()
authStore := auth.NewStore(pool)
+19
View File
@@ -0,0 +1,19 @@
package auth
import (
"context"
"shared-album/internal/models"
)
// Repository is the persistence contract for the auth domain.
type Repository interface {
CreateUser(ctx context.Context, u *models.User) error
GetUserByEmail(ctx context.Context, email string) (*models.User, error)
GetUserByID(ctx context.Context, id string) (*models.User, error)
UpdatePreferences(ctx context.Context, userID string, raw []byte) error
CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error
GetRefreshToken(ctx context.Context, hash string) (*models.RefreshToken, error)
RevokeRefreshToken(ctx context.Context, id string) error
RevokeAllUserTokens(ctx context.Context, userID string) error
}
+2 -2
View File
@@ -18,11 +18,11 @@ type TokenPair struct {
}
type Service struct {
store *Store
store Repository
tm *TokenManager
}
func NewService(store *Store, tm *TokenManager) *Service {
func NewService(store Repository, tm *TokenManager) *Service {
return &Service{store: store, tm: tm}
}
+111
View File
@@ -0,0 +1,111 @@
package auth
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
"shared-album/internal/models"
"shared-album/internal/sqliteutil"
)
// SQLiteStore is the SQLite implementation of Repository.
type SQLiteStore struct {
db *sql.DB
}
func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
func (s *SQLiteStore) CreateUser(ctx context.Context, u *models.User) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO users (id, email, password_hash, name, avatar_url)
VALUES ($1, $2, $3, $4, $5)`,
u.ID, u.Email, u.PasswordHash, u.Name, nullIfEmpty(u.AvatarURL))
return err
}
func (s *SQLiteStore) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
return s.scanUser(s.db.QueryRowContext(ctx,
`SELECT id, email, password_hash, name, COALESCE(avatar_url, ''),
COALESCE(preferences, '{}'), created_at
FROM users WHERE lower(email) = lower($1)`, email))
}
func (s *SQLiteStore) GetUserByID(ctx context.Context, id string) (*models.User, error) {
return s.scanUser(s.db.QueryRowContext(ctx,
`SELECT id, email, password_hash, name, COALESCE(avatar_url, ''),
COALESCE(preferences, '{}'), created_at
FROM users WHERE id = $1`, id))
}
func (s *SQLiteStore) scanUser(row *sql.Row) (*models.User, error) {
var u models.User
var prefs, createdAt string
if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &prefs, &createdAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
u.CreatedAt = sqliteutil.ParseTime(createdAt)
if prefs != "" && prefs != "null" {
_ = json.Unmarshal([]byte(prefs), &u.Preferences)
}
return &u, nil
}
func (s *SQLiteStore) UpdatePreferences(ctx context.Context, userID string, raw []byte) error {
res, err := s.db.ExecContext(ctx,
`UPDATE users SET preferences = $2 WHERE id = $1`, userID, string(raw))
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *SQLiteStore) CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at)
VALUES ($1, $2, $3, $4)`,
t.ID, t.UserID, t.TokenHash, sqliteutil.FormatTime(t.ExpiresAt))
return err
}
func (s *SQLiteStore) GetRefreshToken(ctx context.Context, hash string) (*models.RefreshToken, error) {
var t models.RefreshToken
var expiresAt, createdAt string
var revokedAt sql.NullString
err := s.db.QueryRowContext(ctx,
`SELECT id, user_id, token_hash, expires_at, revoked_at, created_at
FROM refresh_tokens WHERE token_hash = $1`, hash).
Scan(&t.ID, &t.UserID, &t.TokenHash, &expiresAt, &revokedAt, &createdAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
t.ExpiresAt = sqliteutil.ParseTime(expiresAt)
t.CreatedAt = sqliteutil.ParseTime(createdAt)
t.RevokedAt = sqliteutil.ParseNullTime(revokedAt)
return &t, nil
}
func (s *SQLiteStore) RevokeRefreshToken(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE refresh_tokens SET revoked_at = $2 WHERE id = $1 AND revoked_at IS NULL`,
id, sqliteutil.FormatTime(time.Now()))
return err
}
func (s *SQLiteStore) RevokeAllUserTokens(ctx context.Context, userID string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE refresh_tokens SET revoked_at = $2 WHERE user_id = $1 AND revoked_at IS NULL`,
userID, sqliteutil.FormatTime(time.Now()))
return err
}
+24 -4
View File
@@ -59,10 +59,16 @@ type Storage struct {
URLTTL time.Duration
}
type Database struct {
Backend string
SQLitePath string
}
type Config struct {
Postgres Postgres
S3 S3
Storage Storage
Database Database
Auth Auth
Server Server
SSE SSE
@@ -138,11 +144,15 @@ func Load() (*Config, error) {
UsePathStyle: pathStyle,
},
Storage: Storage{
Backend: getEnv("STORAGE_BACKEND", "s3"),
FSRoot: getEnv("FS_ROOT", ""),
Backend: getEnv("STORAGE_BACKEND", "fs"),
FSRoot: getEnv("FS_ROOT", "./data"),
URLSecret: getEnv("URL_SECRET", ""),
URLTTL: urlTTL,
},
Database: Database{
Backend: getEnv("DB_BACKEND", "sqlite"),
SQLitePath: getEnv("SQLITE_PATH", "./galo.db"),
},
Auth: Auth{
JWTSecret: getEnv("JWT_SECRET", ""),
AccessTTL: accessTTL,
@@ -166,8 +176,18 @@ func Load() (*Config, error) {
if cfg.Auth.JWTSecret == "" {
return nil, fmt.Errorf("JWT_SECRET is required")
}
if cfg.Postgres.Password == "" {
return nil, fmt.Errorf("PG_PASSWORD is required")
switch cfg.Database.Backend {
case "postgres":
if cfg.Postgres.Password == "" {
return nil, fmt.Errorf("PG_PASSWORD is required")
}
case "sqlite":
if cfg.Database.SQLitePath == "" {
return nil, fmt.Errorf("SQLITE_PATH is required when DB_BACKEND=sqlite")
}
default:
return nil, fmt.Errorf("invalid DB_BACKEND %q (want postgres or sqlite)", cfg.Database.Backend)
}
switch cfg.Storage.Backend {
+70 -6
View File
@@ -12,15 +12,15 @@ func setRequiredEnv(t *testing.T) {
}
func TestLoadStorageBackend(t *testing.T) {
t.Run("defaults to s3", func(t *testing.T) {
t.Run("defaults to fs", func(t *testing.T) {
setRequiredEnv(t)
t.Setenv("STORAGE_BACKEND", "")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Storage.Backend != "s3" {
t.Fatalf("backend = %q, want s3", cfg.Storage.Backend)
if cfg.Storage.Backend != "fs" || cfg.Storage.FSRoot != "./data" {
t.Fatalf("storage = %+v", cfg.Storage)
}
})
@@ -37,13 +37,17 @@ func TestLoadStorageBackend(t *testing.T) {
}
})
t.Run("fs without root", func(t *testing.T) {
t.Run("fs empty root falls back to default", func(t *testing.T) {
setRequiredEnv(t)
t.Setenv("STORAGE_BACKEND", "fs")
t.Setenv("FS_ROOT", "")
t.Setenv("URL_SECRET", "")
if _, err := Load(); err == nil {
t.Fatal("expected error for fs without FS_ROOT")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Storage.FSRoot != "./data" {
t.Fatalf("FSRoot = %q, want ./data", cfg.Storage.FSRoot)
}
})
@@ -68,3 +72,63 @@ func TestLoadStorageBackend(t *testing.T) {
}
})
}
func TestLoadDatabaseBackend(t *testing.T) {
t.Run("defaults to sqlite", func(t *testing.T) {
setRequiredEnv(t)
t.Setenv("DB_BACKEND", "")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Database.Backend != "sqlite" || cfg.Database.SQLitePath != "./galo.db" {
t.Fatalf("database = %+v", cfg.Database)
}
})
t.Run("postgres requires password", func(t *testing.T) {
t.Setenv("JWT_SECRET", "s")
t.Setenv("PG_PASSWORD", "")
t.Setenv("DB_BACKEND", "postgres")
if _, err := Load(); err == nil {
t.Fatal("expected error for postgres without PG_PASSWORD")
}
})
t.Run("sqlite with path", func(t *testing.T) {
t.Setenv("JWT_SECRET", "s")
t.Setenv("PG_PASSWORD", "")
t.Setenv("DB_BACKEND", "sqlite")
t.Setenv("SQLITE_PATH", "/data/galo.db")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Database.Backend != "sqlite" || cfg.Database.SQLitePath != "/data/galo.db" {
t.Fatalf("database = %+v", cfg.Database)
}
})
t.Run("sqlite empty path falls back to default", func(t *testing.T) {
t.Setenv("JWT_SECRET", "s")
t.Setenv("PG_PASSWORD", "")
t.Setenv("DB_BACKEND", "sqlite")
t.Setenv("SQLITE_PATH", "")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Database.SQLitePath != "./galo.db" {
t.Fatalf("SQLitePath = %q, want ./galo.db", cfg.Database.SQLitePath)
}
})
t.Run("invalid backend", func(t *testing.T) {
t.Setenv("JWT_SECRET", "s")
t.Setenv("PG_PASSWORD", "p")
t.Setenv("DB_BACKEND", "mysql")
if _, err := Load(); err == nil {
t.Fatal("expected error for invalid backend")
}
})
}
+31
View File
@@ -8,8 +8,10 @@ import (
"database/sql"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/pgx/v5"
sqlitemig "github.com/golang-migrate/migrate/v4/database/sqlite"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "github.com/jackc/pgx/v5/stdlib"
_ "modernc.org/sqlite"
)
// Migrate applies all up migrations from the given fs (root contains *.sql files).
@@ -39,3 +41,32 @@ func Migrate(dsn string, migrations fs.FS) error {
}
return nil
}
// MigrateSQLite applies all up migrations from the given fs to a SQLite database
// at path (creating it if needed).
func MigrateSQLite(path string, migrations fs.FS) error {
source, err := iofs.New(migrations, ".")
if err != nil {
return fmt.Errorf("migrations source: %w", err)
}
sqlDB, err := sql.Open("sqlite", path)
if err != nil {
return fmt.Errorf("open sql db: %w", err)
}
defer sqlDB.Close()
driver, err := sqlitemig.WithInstance(sqlDB, &sqlitemig.Config{})
if err != nil {
return fmt.Errorf("migrate driver: %w", err)
}
m, err := migrate.NewWithInstance("iofs", source, "sqlite", driver)
if err != nil {
return fmt.Errorf("migrate instance: %w", err)
}
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("migrate up: %w", err)
}
return nil
}
@@ -0,0 +1,37 @@
package db
import (
"io/fs"
"path/filepath"
"testing"
assets "shared-album"
)
func TestMigrateSQLite(t *testing.T) {
path := filepath.Join(t.TempDir(), "meta.db")
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
t.Fatalf("migrations fs: %v", err)
}
if err := MigrateSQLite(path, migFS); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := MigrateSQLite(path, migFS); err != nil {
t.Fatalf("migrate again: %v", err)
}
db, err := OpenSQLite(path)
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
var n int
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type='table' AND name='objects'`).Scan(&n); err != nil {
t.Fatalf("query: %v", err)
}
if n != 1 {
t.Fatalf("objects table missing after migration")
}
}
+31
View File
@@ -0,0 +1,31 @@
package db
import (
"database/sql"
"fmt"
"strings"
_ "modernc.org/sqlite"
)
// OpenSQLite opens (creating if needed) a SQLite database tuned for a single
// writer with concurrent readers: WAL journalling, a busy timeout so writers
// wait instead of failing, and enforced foreign keys.
func OpenSQLite(path string) (*sql.DB, error) {
if strings.TrimSpace(path) == "" {
return nil, fmt.Errorf("sqlite path is required")
}
dsn := fmt.Sprintf(
"file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)&_txlock=immediate",
path,
)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
return db, nil
}
+45
View File
@@ -0,0 +1,45 @@
package db
import (
"context"
"path/filepath"
"strings"
"testing"
)
func TestOpenSQLite(t *testing.T) {
ctx := context.Background()
db, err := OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
if err := db.PingContext(ctx); err != nil {
t.Fatalf("ping: %v", err)
}
var mode string
if err := db.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&mode); err != nil {
t.Fatalf("journal_mode: %v", err)
}
if !strings.EqualFold(mode, "wal") {
t.Fatalf("journal_mode = %q, want wal", mode)
}
var fk int
if err := db.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&fk); err != nil {
t.Fatalf("foreign_keys: %v", err)
}
if fk != 1 {
t.Fatalf("foreign_keys = %d, want 1", fk)
}
var busy int
if err := db.QueryRowContext(ctx, "PRAGMA busy_timeout").Scan(&busy); err != nil {
t.Fatalf("busy_timeout: %v", err)
}
if busy != 5000 {
t.Fatalf("busy_timeout = %d, want 5000", busy)
}
}
+150
View File
@@ -0,0 +1,150 @@
package e2e
import (
"bytes"
"context"
"database/sql"
"image"
"image/color"
"image/png"
"io/fs"
"path/filepath"
"testing"
"time"
assets "shared-album"
"shared-album/internal/albums"
"shared-album/internal/auth"
"shared-album/internal/db"
"shared-album/internal/photos"
"shared-album/internal/sse"
"shared-album/internal/storage"
"shared-album/internal/store"
)
func makePNG(t *testing.T) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
for x := 0; x < 4; x++ {
for y := 0; y < 4; y++ {
img.Set(x, y, color.RGBA{R: uint8(x * 40), G: uint8(y * 40), B: 128, A: 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return buf.Bytes()
}
type stack struct {
db *sql.DB
photos *photos.Service
albums *albums.Service
auth *auth.Service
}
// openStack opens a migrated SQLite database at path and builds the services.
func openStack(t *testing.T, path, blobRoot string) (*stack, func()) {
t.Helper()
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
t.Fatalf("migrations fs: %v", err)
}
if err := db.MigrateSQLite(path, migFS); err != nil {
t.Fatalf("migrate: %v", err)
}
database, err := db.OpenSQLite(path)
if err != nil {
t.Fatalf("open: %v", err)
}
repos := store.NewSQLite(database)
hub := sse.NewHub(1)
signer, err := storage.NewSigner("http://app.test", "secret", time.Hour)
if err != nil {
t.Fatalf("signer: %v", err)
}
blob, err := storage.NewFS(blobRoot, signer)
if err != nil {
t.Fatalf("fs: %v", err)
}
authSvc := auth.NewService(repos.Auth, auth.NewTokenManager("secret", 15*time.Minute, time.Hour))
albumsSvc := albums.NewService(repos.Albums, repos.Members, hub, blob)
photosSvc := photos.NewService(repos.Photos, albumsSvc, blob, hub)
return &stack{db: database, photos: photosSvc, albums: albumsSvc, auth: authSvc}, func() { database.Close() }
}
func TestSQLiteEndToEndWithRestart(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "galo.db")
blobs := t.TempDir()
st, closeDB := openStack(t, path, blobs)
worker := photos.NewWorker(st.photos, 20*time.Millisecond, 10, time.Minute)
wctx, cancel := context.WithCancel(ctx)
go worker.Run(wctx)
user, _, err := st.auth.Register(ctx, "e2e@test.local", "password123", "E2E")
if err != nil {
t.Fatalf("register: %v", err)
}
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Trip"})
if err != nil {
t.Fatalf("create album: %v", err)
}
p, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t)})
if err != nil {
t.Fatalf("upload: %v", err)
}
waitFor(t, "variants of first photo", func() bool {
got, err := st.photos.Get(ctx, p.ID, user.ID)
return err == nil && got.ThumbURL != "" && got.PreviewURL != ""
})
if err := st.photos.PermanentDelete(ctx, p.ID, user.ID); err != nil {
t.Fatalf("permanent delete: %v", err)
}
waitFor(t, "garbage collection", func() bool {
var n int
return st.db.QueryRowContext(ctx, `SELECT count(*) FROM objects`).Scan(&n) == nil && n == 0
})
// Stop the worker, then queue another upload whose variant job must survive
// the restart and be processed by a fresh worker over the same file.
cancel()
time.Sleep(50 * time.Millisecond)
p2, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: user.ID, Filename: "b.png", Data: makePNG(t)})
if err != nil {
t.Fatalf("upload p2: %v", err)
}
got2, _ := st.photos.Get(ctx, p2.ID, user.ID)
if got2.PreviewURL != "" {
t.Fatalf("variants generated before restart")
}
closeDB()
st2, close2 := openStack(t, path, blobs)
defer close2()
worker2 := photos.NewWorker(st2.photos, 20*time.Millisecond, 10, time.Minute)
wctx2, cancel2 := context.WithCancel(ctx)
defer cancel2()
go worker2.Run(wctx2)
waitFor(t, "variants of second photo after restart", func() bool {
got, err := st2.photos.Get(ctx, p2.ID, user.ID)
return err == nil && got.ThumbURL != "" && got.PreviewURL != ""
})
}
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
+19
View File
@@ -0,0 +1,19 @@
package members
import (
"context"
"shared-album/internal/models"
)
// Repository is the persistence contract for the members domain.
type Repository interface {
Role(ctx context.Context, albumID, userID string) (string, error)
List(ctx context.Context, albumID string) ([]models.AlbumMember, error)
Add(ctx context.Context, albumID, userID, role string, invitedBy *string) error
UpdateRole(ctx context.Context, albumID, userID, role string) error
Remove(ctx context.Context, albumID, userID string) error
CreateInvite(ctx context.Context, inv *models.Invite) error
GetInviteByToken(ctx context.Context, token string) (*models.Invite, error)
IncrementUses(ctx context.Context, id string) error
}
+2 -2
View File
@@ -21,13 +21,13 @@ type Notifier interface {
}
type Service struct {
store *Store
store Repository
albums AlbumReader
hub *sse.Hub
notify Notifier
}
func NewService(store *Store, albums AlbumReader, hub *sse.Hub, notify Notifier) *Service {
func NewService(store Repository, albums AlbumReader, hub *sse.Hub, notify Notifier) *Service {
return &Service{store: store, albums: albums, hub: hub, notify: notify}
}
+133
View File
@@ -0,0 +1,133 @@
package members
import (
"context"
"database/sql"
"errors"
"shared-album/internal/models"
"shared-album/internal/sqliteutil"
)
// SQLiteStore is the SQLite implementation of Repository.
type SQLiteStore struct {
db *sql.DB
}
func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
func (s *SQLiteStore) Role(ctx context.Context, albumID, userID string) (string, error) {
var role string
err := s.db.QueryRowContext(ctx,
`SELECT role FROM album_members WHERE album_id = $1 AND user_id = $2`,
albumID, userID).Scan(&role)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return "", err
}
return role, nil
}
func (s *SQLiteStore) List(ctx context.Context, albumID string) ([]models.AlbumMember, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT m.album_id, m.user_id, m.role, m.invited_by, m.joined_at,
u.id, u.email, u.name, COALESCE(u.avatar_url, '')
FROM album_members m
JOIN users u ON u.id = m.user_id
WHERE m.album_id = $1
ORDER BY m.joined_at`, albumID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.AlbumMember{}
for rows.Next() {
var m models.AlbumMember
var invitedBy sql.NullString
var joinedAt string
if err := rows.Scan(&m.AlbumID, &m.UserID, &m.Role, &invitedBy, &joinedAt,
&m.User.ID, &m.User.Email, &m.User.Name, &m.User.AvatarURL); err != nil {
return nil, err
}
if invitedBy.Valid {
m.InvitedBy = &invitedBy.String
}
m.JoinedAt = sqliteutil.ParseTime(joinedAt)
out = append(out, m)
}
return out, rows.Err()
}
func (s *SQLiteStore) Add(ctx context.Context, albumID, userID, role string, invitedBy *string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO album_members (album_id, user_id, role, invited_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (album_id, user_id) DO UPDATE SET role = excluded.role`,
albumID, userID, role, invitedBy)
return err
}
func (s *SQLiteStore) UpdateRole(ctx context.Context, albumID, userID, role string) error {
res, err := s.db.ExecContext(ctx,
`UPDATE album_members SET role = $3 WHERE album_id = $1 AND user_id = $2`,
albumID, userID, role)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *SQLiteStore) Remove(ctx context.Context, albumID, userID string) error {
res, err := s.db.ExecContext(ctx,
`DELETE FROM album_members WHERE album_id = $1 AND user_id = $2`, albumID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *SQLiteStore) CreateInvite(ctx context.Context, inv *models.Invite) error {
var createdAt string
err := s.db.QueryRowContext(ctx,
`INSERT INTO invites (id, album_id, token, role, created_by, expires_at, max_uses)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING created_at`,
inv.ID, inv.AlbumID, inv.Token, inv.Role, inv.CreatedBy, sqliteutil.FormatTime(inv.ExpiresAt), inv.MaxUses,
).Scan(&createdAt)
if err != nil {
return err
}
inv.CreatedAt = sqliteutil.ParseTime(createdAt)
return nil
}
func (s *SQLiteStore) GetInviteByToken(ctx context.Context, token string) (*models.Invite, error) {
var inv models.Invite
var expiresAt, createdAt string
err := s.db.QueryRowContext(ctx,
`SELECT id, album_id, token, role, created_by, expires_at, max_uses, uses, created_at
FROM invites WHERE token = $1`, token).
Scan(&inv.ID, &inv.AlbumID, &inv.Token, &inv.Role, &inv.CreatedBy, &expiresAt, &inv.MaxUses, &inv.Uses, &createdAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
inv.ExpiresAt = sqliteutil.ParseTime(expiresAt)
inv.CreatedAt = sqliteutil.ParseTime(createdAt)
return &inv, nil
}
func (s *SQLiteStore) IncrementUses(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx, `UPDATE invites SET uses = uses + 1 WHERE id = $1`, id)
return err
}
@@ -0,0 +1,14 @@
package notifications
import (
"context"
"shared-album/internal/models"
)
// Repository is the persistence contract for the notifications domain.
type Repository interface {
Create(ctx context.Context, n *models.Notification) error
List(ctx context.Context, userID string, limit int) ([]models.Notification, error)
MarkRead(ctx context.Context, id, userID string) error
}
+2 -2
View File
@@ -10,11 +10,11 @@ import (
)
type Service struct {
store *Store
store Repository
hub *sse.Hub
}
func NewService(store *Store, hub *sse.Hub) *Service {
func NewService(store Repository, hub *sse.Hub) *Service {
return &Service{store: store, hub: hub}
}
@@ -0,0 +1,88 @@
package notifications
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
"shared-album/internal/models"
"shared-album/internal/sqliteutil"
)
// SQLiteStore is the SQLite implementation of Repository.
type SQLiteStore struct {
db *sql.DB
}
func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
func (s *SQLiteStore) Create(ctx context.Context, n *models.Notification) error {
payload, err := json.Marshal(n.PayloadJSON)
if err != nil {
return err
}
var createdAt string
err = s.db.QueryRowContext(ctx,
`INSERT INTO notifications (id, user_id, type, payload_json)
VALUES ($1, $2, $3, $4)
RETURNING created_at`,
n.ID, n.UserID, n.Type, string(payload)).Scan(&createdAt)
if err != nil {
return err
}
n.CreatedAt = sqliteutil.ParseTime(createdAt)
return nil
}
func (s *SQLiteStore) List(ctx context.Context, userID string, limit int) ([]models.Notification, error) {
if limit <= 0 || limit > 200 {
limit = 100
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, user_id, type, payload_json, read_at, created_at
FROM notifications
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Notification{}
for rows.Next() {
var n models.Notification
var payload, createdAt string
var readAt sql.NullString
if err := rows.Scan(&n.ID, &n.UserID, &n.Type, &payload, &readAt, &createdAt); err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(payload), &n.PayloadJSON)
n.ReadAt = sqliteutil.ParseNullTime(readAt)
n.CreatedAt = sqliteutil.ParseTime(createdAt)
out = append(out, n)
}
return out, rows.Err()
}
func (s *SQLiteStore) MarkRead(ctx context.Context, id, userID string) error {
res, err := s.db.ExecContext(ctx,
`UPDATE notifications SET read_at = $3 WHERE id = $1 AND user_id = $2 AND read_at IS NULL`,
id, userID, sqliteutil.FormatTime(time.Now()))
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
var exists int
err := s.db.QueryRowContext(ctx,
`SELECT 1 FROM notifications WHERE id = $1 AND user_id = $2`, id, userID).Scan(&exists)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
}
return nil
}
+18
View File
@@ -368,3 +368,21 @@ func (s *Store) FailVariantJob(ctx context.Context, photoID, errMsg string, maxA
}
return tx.Commit(ctx)
}
// AllStorageKeys returns every key registered in the object journal.
func (s *Store) AllStorageKeys(ctx context.Context) ([]string, error) {
rows, err := s.pool.Query(ctx, `SELECT storage_key FROM objects`)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []string
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
return nil, err
}
keys = append(keys, k)
}
return keys, rows.Err()
}
+52
View File
@@ -0,0 +1,52 @@
package photos
import (
"context"
"time"
"shared-album/internal/models"
)
// Repository is the persistence contract for the photos domain, including the
// object ledger, variant queue and garbage collection.
type Repository interface {
CreateWithOriginal(ctx context.Context, p *models.Photo, obj *Object) error
DeleteUpload(ctx context.Context, photoID, objectID string) error
GetByID(ctx context.Context, id, viewerID string) (*models.Photo, error)
List(ctx context.Context, in ListParams) ([]models.Photo, error)
SetDeleted(ctx context.Context, id string, deleted bool) error
UpdateAlbum(ctx context.Context, id, albumID string) error
Clone(ctx context.Context, src *models.Photo, albumID, uploaderID, id string) error
HardDelete(ctx context.Context, id string) error
ListTrash(ctx context.Context, userID string) ([]models.Photo, error)
TrashCount(ctx context.Context, userID string) (int, error)
RestoreToAlbum(ctx context.Context, id, albumID string) error
AlbumExists(ctx context.Context, albumID string) (bool, error)
ListTags(ctx context.Context, userID, q string) ([]models.Tag, error)
EnsureTag(ctx context.Context, userID, name string) (*models.Tag, error)
AttachTag(ctx context.Context, photoID, tagID string) error
DetachTag(ctx context.Context, photoID, tagID string) error
TagOwnedBy(ctx context.Context, tagID, userID string) (bool, error)
SetCoverIfEmpty(ctx context.Context, albumID, photoID string)
UpdateFilename(ctx context.Context, id, filename string) error
BackfillEXIF(ctx context.Context, fetch func(context.Context, string) ([]byte, error)) (int, error)
CreateObject(ctx context.Context, o *Object) error
SetObjectState(ctx context.Context, id, state string) error
ActivateObject(ctx context.Context, id string) error
MarkObjectDeleting(ctx context.Context, id string) error
DeleteObjectRow(ctx context.Context, id string) error
BindObject(ctx context.Context, photoID, objectID, role string) error
PhotoObjectIDs(ctx context.Context, photoID string) ([]string, error)
ObjectRefCount(ctx context.Context, objectID string) (int, error)
OriginalObject(ctx context.Context, photoID string) (*Object, error)
PhotosByOriginalObject(ctx context.Context, originalObjectID string) ([]string, error)
ClaimGarbage(ctx context.Context, limit int) ([]Object, error)
ClaimStalePending(ctx context.Context, olderThan time.Duration, limit int) ([]Object, error)
ClaimVariantJobs(ctx context.Context, limit int, lease time.Duration) ([]string, error)
CompleteVariantJob(ctx context.Context, photoID string) error
FailVariantJob(ctx context.Context, photoID, errMsg string, maxAttempts int) error
// AllStorageKeys returns every key registered in the object journal.
AllStorageKeys(ctx context.Context) ([]string, error)
}
+2 -2
View File
@@ -22,13 +22,13 @@ type AlbumAccess interface {
}
type Service struct {
store *Store
store Repository
albums AlbumAccess
storage storage.Backend
hub *sse.Hub
}
func NewService(store *Store, albums AlbumAccess, storage storage.Backend, hub *sse.Hub) *Service {
func NewService(store Repository, albums AlbumAccess, storage storage.Backend, hub *sse.Hub) *Service {
return &Service{store: store, albums: albums, storage: storage, hub: hub}
}
+30
View File
@@ -0,0 +1,30 @@
package photos
import (
"time"
"shared-album/internal/sqliteutil"
)
func nullIfEmpty(s string) any {
if s == "" {
return nil
}
return s
}
func nullTime(t *time.Time) any {
if t == nil {
return nil
}
return sqliteutil.FormatTime(*t)
}
func nullFloat(f *float64) any {
if f == nil {
return nil
}
return *f
}
func nowUTC() time.Time { return time.Now().UTC() }
+365
View File
@@ -0,0 +1,365 @@
package photos
import (
"context"
"database/sql"
"errors"
"time"
"shared-album/internal/sqliteutil"
)
// CreateObject inserts a new object row in the pending state.
func (s *SQLiteStore) CreateObject(ctx context.Context, o *Object) error {
if o.State == "" {
o.State = ObjectPending
}
var createdAt string
err := s.db.QueryRowContext(ctx,
`INSERT INTO objects (id, storage_key, mime_type, size, width, height, checksum, state)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING created_at`,
o.ID, o.StorageKey, o.MimeType, o.Size, o.Width, o.Height, o.Checksum, o.State).Scan(&createdAt)
if err != nil {
return err
}
o.CreatedAt = sqliteutil.ParseTime(createdAt)
return nil
}
// SetObjectState updates an object's lifecycle state.
func (s *SQLiteStore) SetObjectState(ctx context.Context, id, state string) error {
res, err := s.db.ExecContext(ctx, `UPDATE objects SET state = $2 WHERE id = $1`, id, state)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrObjectNotFound
}
return nil
}
// ActivateObject marks an object ready after its bytes have been stored.
func (s *SQLiteStore) ActivateObject(ctx context.Context, id string) error {
return s.SetObjectState(ctx, id, ObjectReady)
}
// MarkObjectDeleting marks an object as pending removal from storage.
func (s *SQLiteStore) MarkObjectDeleting(ctx context.Context, id string) error {
return s.SetObjectState(ctx, id, ObjectDeleting)
}
// DeleteObjectRow permanently removes an object row (after its blob is gone).
func (s *SQLiteStore) DeleteObjectRow(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM objects WHERE id = $1`, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrObjectNotFound
}
return nil
}
// BindObject attaches a ready object to a photo under the given role. The write
// lock of the immediate transaction serializes it against garbage collection.
func (s *SQLiteStore) BindObject(ctx context.Context, photoID, objectID, role string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var state string
if err := tx.QueryRowContext(ctx, `SELECT state FROM objects WHERE id = $1`, objectID).Scan(&state); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrObjectNotFound
}
return err
}
if state != ObjectReady {
return ErrObjectNotReady
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO photo_objects (photo_id, object_id, role) VALUES ($1, $2, $3)
ON CONFLICT (photo_id, role) DO UPDATE SET object_id = excluded.object_id`,
photoID, objectID, role); err != nil {
return err
}
return tx.Commit()
}
// PhotoObjectIDs returns the object ids currently bound to a photo.
func (s *SQLiteStore) PhotoObjectIDs(ctx context.Context, photoID string) ([]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT object_id FROM photo_objects WHERE photo_id = $1`, photoID)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// ObjectRefCount counts how many photo bindings reference an object.
func (s *SQLiteStore) ObjectRefCount(ctx context.Context, objectID string) (int, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM photo_objects WHERE object_id = $1`, objectID).Scan(&n)
return n, err
}
// OriginalObject returns the original object bound to a photo.
func (s *SQLiteStore) OriginalObject(ctx context.Context, photoID string) (*Object, error) {
var o Object
var createdAt string
err := s.db.QueryRowContext(ctx,
`SELECT o.id, o.storage_key, o.mime_type, o.size, o.width, o.height, o.checksum, o.state, o.created_at
FROM photo_objects po JOIN objects o ON o.id = po.object_id
WHERE po.photo_id = $1 AND po.role = $2`, photoID, RoleOriginal).
Scan(&o.ID, &o.StorageKey, &o.MimeType, &o.Size, &o.Width, &o.Height, &o.Checksum, &o.State, &createdAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrObjectNotFound
}
return nil, err
}
o.CreatedAt = sqliteutil.ParseTime(createdAt)
return &o, nil
}
// PhotosByOriginalObject returns the ids of all photos bound to the given
// original object, so generated variants can be shared between copies.
func (s *SQLiteStore) PhotosByOriginalObject(ctx context.Context, originalObjectID string) ([]string, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT photo_id FROM photo_objects WHERE object_id = $1 AND role = $2`, originalObjectID, RoleOriginal)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// ClaimGarbage claims collectible objects: ready objects with no bindings and
// objects already marked deleting.
func (s *SQLiteStore) ClaimGarbage(ctx context.Context, limit int) ([]Object, error) {
if limit <= 0 {
limit = 10
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
rows, err := tx.QueryContext(ctx,
`SELECT o.id, o.storage_key FROM objects o
WHERE o.state IN ('ready', 'deleting')
AND NOT EXISTS (SELECT 1 FROM photo_objects po WHERE po.object_id = o.id)
ORDER BY o.created_at
LIMIT $1`, limit)
if err != nil {
return nil, err
}
objs, err := scanObjectKeys(rows)
if err != nil {
return nil, err
}
if len(objs) == 0 {
return nil, tx.Commit()
}
if err := markDeleting(ctx, tx, objs); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return objs, nil
}
// ClaimStalePending marks abandoned pending objects as deleting and returns them.
func (s *SQLiteStore) ClaimStalePending(ctx context.Context, olderThan time.Duration, limit int) ([]Object, error) {
if limit <= 0 {
limit = 10
}
threshold := sqliteutil.FormatTime(time.Now().UTC().Add(-olderThan))
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
rows, err := tx.QueryContext(ctx,
`SELECT id, storage_key FROM objects
WHERE state = 'pending' AND created_at < $1
ORDER BY created_at
LIMIT $2`, threshold, limit)
if err != nil {
return nil, err
}
objs, err := scanObjectKeys(rows)
if err != nil {
return nil, err
}
if len(objs) == 0 {
return nil, tx.Commit()
}
if err := markDeleting(ctx, tx, objs); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return objs, nil
}
// ClaimVariantJobs leases due variant jobs and returns their photo ids.
func (s *SQLiteStore) ClaimVariantJobs(ctx context.Context, limit int, lease time.Duration) ([]string, error) {
if limit <= 0 {
limit = 10
}
now := sqliteutil.FormatTime(time.Now().UTC())
next := sqliteutil.FormatTime(time.Now().UTC().Add(lease))
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
rows, err := tx.QueryContext(ctx,
`SELECT photo_id FROM variant_jobs
WHERE next_attempt_at <= $1
ORDER BY next_attempt_at
LIMIT $2`, now, limit)
if err != nil {
return nil, err
}
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return nil, err
}
ids = append(ids, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
if len(ids) == 0 {
return nil, tx.Commit()
}
args := make([]any, 0, len(ids)+1)
args = append(args, next)
for _, id := range ids {
args = append(args, id)
}
if _, err := tx.ExecContext(ctx,
`UPDATE variant_jobs SET next_attempt_at = $1 WHERE photo_id IN (`+sqliteutil.Placeholders(2, len(ids))+`)`,
args...); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return ids, nil
}
// CompleteVariantJob removes a finished job.
func (s *SQLiteStore) CompleteVariantJob(ctx context.Context, photoID string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM variant_jobs WHERE photo_id = $1`, photoID)
return err
}
// FailVariantJob records a failure and schedules a retry with exponential
// backoff, abandoning the job after maxAttempts.
func (s *SQLiteStore) FailVariantJob(ctx context.Context, photoID, errMsg string, maxAttempts int) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var attempts int
err = tx.QueryRowContext(ctx, `SELECT attempts FROM variant_jobs WHERE photo_id = $1`, photoID).Scan(&attempts)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return tx.Commit()
}
return err
}
newAttempts := attempts + 1
if maxAttempts > 0 && newAttempts >= maxAttempts {
if _, err := tx.ExecContext(ctx, `DELETE FROM variant_jobs WHERE photo_id = $1`, photoID); err != nil {
return err
}
} else {
secs := 10 * (1 << attempts)
if secs > 300 {
secs = 300
}
next := sqliteutil.FormatTime(time.Now().UTC().Add(time.Duration(secs) * time.Second))
if _, err := tx.ExecContext(ctx,
`UPDATE variant_jobs SET attempts = $2, next_attempt_at = $3, last_error = $4 WHERE photo_id = $1`,
photoID, newAttempts, next, errMsg); err != nil {
return err
}
}
return tx.Commit()
}
func scanObjectKeys(rows *sql.Rows) ([]Object, error) {
var objs []Object
for rows.Next() {
var o Object
if err := rows.Scan(&o.ID, &o.StorageKey); err != nil {
rows.Close()
return nil, err
}
objs = append(objs, o)
}
rows.Close()
return objs, rows.Err()
}
func markDeleting(ctx context.Context, tx *sql.Tx, objs []Object) error {
args := make([]any, len(objs))
for i, o := range objs {
args[i] = o.ID
}
_, err := tx.ExecContext(ctx,
`UPDATE objects SET state = 'deleting' WHERE id IN (`+sqliteutil.Placeholders(1, len(objs))+`)`, args...)
return err
}
// AllStorageKeys returns every key registered in the object journal.
func (s *SQLiteStore) AllStorageKeys(ctx context.Context) ([]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT storage_key FROM objects`)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []string
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
return nil, err
}
keys = append(keys, k)
}
return keys, rows.Err()
}
+470
View File
@@ -0,0 +1,470 @@
package photos
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"shared-album/internal/auth"
"shared-album/internal/models"
"shared-album/internal/sqliteutil"
)
// SQLiteStore is the SQLite implementation of Repository.
type SQLiteStore struct {
db *sql.DB
}
func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
const sqlitePhotoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id,
COALESCE(o_o.storage_key, ''),
COALESCE(o_t.storage_key, ''),
COALESCE(o_p.storage_key, ''),
p.original_filename, COALESCE(o_o.size, 0), COALESCE(o_o.width, 0), COALESCE(o_o.height, 0), COALESCE(o_o.mime_type, ''),
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
u.id, u.name, COALESCE(u.avatar_url, ''),
(SELECT count(*) FROM likes l WHERE l.photo_id = p.id),
(SELECT count(*) FROM comments c WHERE c.photo_id = p.id),
EXISTS(SELECT 1 FROM likes l WHERE l.photo_id = p.id AND l.user_id = $2),
COALESCE(p.exif, '{}'), p.deleted_at,
COALESCE((SELECT json_group_array(json_object('id', t.id, 'name', t.name))
FROM (SELECT t.id, t.name FROM photo_tags pt JOIN tags t ON t.id = pt.tag_id
WHERE pt.photo_id = p.id AND t.user_id = $2
ORDER BY lower(t.name)) t), '[]')`
const sqlitePhotoJoins = `
LEFT JOIN photo_objects po_o ON po_o.photo_id = p.id AND po_o.role = 'original'
LEFT JOIN objects o_o ON o_o.id = po_o.object_id
LEFT JOIN photo_objects po_t ON po_t.photo_id = p.id AND po_t.role = 'thumb'
LEFT JOIN objects o_t ON o_t.id = po_t.object_id
LEFT JOIN photo_objects po_p ON po_p.photo_id = p.id AND po_p.role = 'preview'
LEFT JOIN objects o_p ON o_p.id = po_p.object_id`
func scanSQLitePhoto(scan func(...any) error) (*models.Photo, error) {
var p models.Photo
var exifText, tagsText, createdAt string
var takenAt, deletedAt sql.NullString
var lat, long sql.NullFloat64
var liked int
if err := scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.PreviewKey,
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
&takenAt, &lat, &long, &p.Caption, &createdAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &tagsText); err != nil {
return nil, err
}
p.TakenAt = sqliteutil.ParseNullTime(takenAt)
if lat.Valid {
p.Latitude = &lat.Float64
}
if long.Valid {
p.Longitude = &long.Float64
}
p.CreatedAt = sqliteutil.ParseTime(createdAt)
p.DeletedAt = sqliteutil.ParseNullTime(deletedAt)
p.LikedByMe = liked != 0
setExif(&p, exifText)
setTags(&p, tagsText)
return &p, nil
}
func (s *SQLiteStore) CreateWithOriginal(ctx context.Context, p *models.Photo, obj *Object) error {
exifJSON, err := json.Marshal(p.EXIF)
if err != nil || len(exifJSON) == 0 {
exifJSON = []byte("{}")
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`INSERT INTO objects (id, storage_key, mime_type, size, width, height, checksum, state)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
obj.ID, obj.StorageKey, obj.MimeType, obj.Size, obj.Width, obj.Height, obj.Checksum, ObjectPending); err != nil {
return err
}
var createdAt string
if err := tx.QueryRowContext(ctx,
`INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING created_at`,
p.ID, nullIfEmpty(p.AlbumID), p.UploaderID, p.OriginalFilename,
nullTime(p.TakenAt), nullFloat(p.Latitude), nullFloat(p.Longitude), p.Caption, string(exifJSON),
).Scan(&createdAt); err != nil {
return err
}
p.CreatedAt = sqliteutil.ParseTime(createdAt)
if _, err := tx.ExecContext(ctx,
`INSERT INTO photo_objects (photo_id, object_id, role) VALUES ($1, $2, $3)`,
p.ID, obj.ID, RoleOriginal); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO variant_jobs (photo_id) VALUES ($1) ON CONFLICT (photo_id) DO NOTHING`, p.ID); err != nil {
return err
}
return tx.Commit()
}
func (s *SQLiteStore) DeleteUpload(ctx context.Context, photoID, objectID string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `DELETE FROM photos WHERE id = $1`, photoID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM objects WHERE id = $1`, objectID); err != nil {
return err
}
return tx.Commit()
}
func (s *SQLiteStore) GetByID(ctx context.Context, id, viewerID string) (*models.Photo, error) {
row := s.db.QueryRowContext(ctx,
`SELECT `+sqlitePhotoColumns+`
FROM photos p JOIN users u ON u.id = p.uploader_id`+sqlitePhotoJoins+`
WHERE p.id = $1`, id, viewerID)
p, err := scanSQLitePhoto(row.Scan)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return p, nil
}
func (s *SQLiteStore) List(ctx context.Context, in ListParams) ([]models.Photo, error) {
if in.Limit <= 0 || in.Limit > 100 {
in.Limit = 50
}
var since any
if in.Since != nil {
since = sqliteutil.FormatTime(*in.Since)
}
rows, err := s.db.QueryContext(ctx,
`SELECT `+sqlitePhotoColumns+`
FROM photos p JOIN users u ON u.id = p.uploader_id`+sqlitePhotoJoins+`
WHERE p.album_id = $1
AND p.deleted_at IS NULL
AND ($3 = '' OR (p.created_at, p.id) < (SELECT created_at, id FROM photos WHERE id = $3))
AND ($4 IS NULL OR p.created_at > $4)
ORDER BY p.created_at DESC, p.id DESC
LIMIT $5`,
in.AlbumID, in.Viewer, in.Cursor, since, in.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Photo{}
for rows.Next() {
p, err := scanSQLitePhoto(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, *p)
}
return out, rows.Err()
}
func (s *SQLiteStore) SetDeleted(ctx context.Context, id string, deleted bool) error {
var res sql.Result
var err error
if deleted {
res, err = s.db.ExecContext(ctx,
`UPDATE photos SET deleted_at = $2 WHERE id = $1 AND deleted_at IS NULL`,
id, sqliteutil.FormatTime(nowUTC()))
} else {
res, err = s.db.ExecContext(ctx, `UPDATE photos SET deleted_at = NULL WHERE id = $1`, id)
}
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *SQLiteStore) UpdateAlbum(ctx context.Context, id, albumID string) error {
res, err := s.db.ExecContext(ctx,
`UPDATE photos SET album_id = $2 WHERE id = $1 AND deleted_at IS NULL`, id, albumID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *SQLiteStore) Clone(ctx context.Context, src *models.Photo, albumID, uploaderID, id string) error {
exifJSON, err := json.Marshal(src.EXIF)
if err != nil || len(exifJSON) == 0 {
exifJSON = []byte("{}")
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
id, nullIfEmpty(albumID), uploaderID, src.OriginalFilename,
nullTime(src.TakenAt), nullFloat(src.Latitude), nullFloat(src.Longitude), src.Caption, string(exifJSON)); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO photo_objects (photo_id, object_id, role)
SELECT $1, object_id, role FROM photo_objects WHERE photo_id = $2`,
id, src.ID); err != nil {
return err
}
return tx.Commit()
}
func (s *SQLiteStore) HardDelete(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM photos WHERE id = $1`, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *SQLiteStore) ListTrash(ctx context.Context, userID string) ([]models.Photo, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT `+sqlitePhotoColumns+`, COALESCE(a.title, '')
FROM photos p
JOIN users u ON u.id = p.uploader_id
LEFT JOIN albums a ON a.id = p.album_id`+sqlitePhotoJoins+`
WHERE p.deleted_at IS NOT NULL
AND (
(a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS(
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1)))
OR (p.album_id IS NULL AND p.uploader_id = $1)
)
ORDER BY p.deleted_at DESC
LIMIT 200`,
userID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Photo{}
for rows.Next() {
var p models.Photo
var exifText, tagsText, createdAt, albumTitle string
var takenAt, deletedAt sql.NullString
var lat, long sql.NullFloat64
var liked int
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.PreviewKey,
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
&takenAt, &lat, &long, &p.Caption, &createdAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &albumTitle, &tagsText); err != nil {
return nil, err
}
p.TakenAt = sqliteutil.ParseNullTime(takenAt)
if lat.Valid {
p.Latitude = &lat.Float64
}
if long.Valid {
p.Longitude = &long.Float64
}
p.CreatedAt = sqliteutil.ParseTime(createdAt)
p.DeletedAt = sqliteutil.ParseNullTime(deletedAt)
p.AlbumTitle = albumTitle
p.LikedByMe = liked != 0
setExif(&p, exifText)
setTags(&p, tagsText)
out = append(out, p)
}
return out, rows.Err()
}
func (s *SQLiteStore) TrashCount(ctx context.Context, userID string) (int, error) {
var n int
err := s.db.QueryRowContext(ctx,
`SELECT count(*) FROM photos p
LEFT JOIN albums a ON a.id = p.album_id
WHERE p.deleted_at IS NOT NULL
AND (
(a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS(
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1)))
OR (p.album_id IS NULL AND p.uploader_id = $1)
)`, userID).Scan(&n)
return n, err
}
func (s *SQLiteStore) RestoreToAlbum(ctx context.Context, id, albumID string) error {
res, err := s.db.ExecContext(ctx,
`UPDATE photos SET album_id = $2, deleted_at = NULL WHERE id = $1`, id, albumID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *SQLiteStore) AlbumExists(ctx context.Context, albumID string) (bool, error) {
var ok int
err := s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM albums WHERE id = $1)`, albumID).Scan(&ok)
return ok != 0, err
}
func (s *SQLiteStore) ListTags(ctx context.Context, userID, q string) ([]models.Tag, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, name FROM tags
WHERE user_id = $1 AND ($2 = '' OR name LIKE '%' || $2 || '%')
ORDER BY lower(name) LIMIT 50`, userID, q)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Tag{}
for rows.Next() {
var t models.Tag
if err := rows.Scan(&t.ID, &t.Name); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
func (s *SQLiteStore) EnsureTag(ctx context.Context, userID, name string) (*models.Tag, error) {
name = strings.TrimSpace(name)
if name == "" || len(name) > 64 {
return nil, ErrNotFound
}
var t models.Tag
err := s.db.QueryRowContext(ctx,
`SELECT id, name FROM tags WHERE user_id = $1 AND lower(name) = lower($2)`, userID, name).
Scan(&t.ID, &t.Name)
if err == nil {
return &t, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
id := auth.NewID()
err = s.db.QueryRowContext(ctx,
`INSERT INTO tags (id, user_id, name) VALUES ($1, $2, $3) RETURNING id, name`, id, userID, name).
Scan(&t.ID, &t.Name)
if err != nil {
if e2 := s.db.QueryRowContext(ctx,
`SELECT id, name FROM tags WHERE user_id = $1 AND lower(name) = lower($2)`, userID, name).
Scan(&t.ID, &t.Name); e2 == nil {
return &t, nil
}
return nil, err
}
return &t, nil
}
func (s *SQLiteStore) AttachTag(ctx context.Context, photoID, tagID string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO photo_tags (photo_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, photoID, tagID)
return err
}
func (s *SQLiteStore) DetachTag(ctx context.Context, photoID, tagID string) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM photo_tags WHERE photo_id = $1 AND tag_id = $2`, photoID, tagID)
return err
}
func (s *SQLiteStore) TagOwnedBy(ctx context.Context, tagID, userID string) (bool, error) {
var ok int
err := s.db.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM tags WHERE id = $1 AND user_id = $2)`, tagID, userID).Scan(&ok)
return ok != 0, err
}
func (s *SQLiteStore) SetCoverIfEmpty(ctx context.Context, albumID, photoID string) {
_, _ = s.db.ExecContext(ctx,
`UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`,
albumID, photoID)
}
func (s *SQLiteStore) UpdateFilename(ctx context.Context, id, filename string) error {
res, err := s.db.ExecContext(ctx, `UPDATE photos SET original_filename = $2 WHERE id = $1`, id, filename)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// BackfillEXIF mirrors the PostgreSQL implementation, reading the original blob
// of every photo with empty exif.
func (s *SQLiteStore) BackfillEXIF(ctx context.Context, fetch func(context.Context, string) ([]byte, error)) (int, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT p.id, o.storage_key
FROM photos p
JOIN photo_objects po ON po.photo_id = p.id AND po.role = 'original'
JOIN objects o ON o.id = po.object_id
WHERE p.exif IS NULL OR p.exif = '{}'
ORDER BY p.created_at`)
if err != nil {
return 0, err
}
var pending []backfillRow
for rows.Next() {
var r backfillRow
if err := rows.Scan(&r.ID, &r.StorageKey); err != nil {
rows.Close()
return 0, err
}
pending = append(pending, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
updated := 0
for _, r := range pending {
data, err := fetch(ctx, r.StorageKey)
if err != nil {
continue
}
meta := extractEXIF(data)
if len(meta.Attributes) == 0 && meta.TakenAt == nil && meta.Latitude == nil && meta.Longitude == nil {
continue
}
attrs, err := json.Marshal(meta.Attributes)
if err != nil || len(attrs) == 0 {
attrs = []byte("{}")
}
if _, err := s.db.ExecContext(ctx,
`UPDATE photos SET exif = $2,
taken_at = COALESCE(taken_at, $3),
latitude = COALESCE(latitude, $4),
longitude = COALESCE(longitude, $5)
WHERE id = $1`,
r.ID, string(attrs), nullTime(meta.TakenAt), nullFloat(meta.Latitude), nullFloat(meta.Longitude)); err != nil {
continue
}
updated++
}
return updated, nil
}
+74
View File
@@ -0,0 +1,74 @@
// Package reconcile computes the difference between the object storage and the
// metadata journal so orphaned blobs can be reported and, on request, removed.
package reconcile
import (
"sort"
"strings"
"time"
"shared-album/internal/storage"
)
// Result holds the two kinds of divergence between storage and the journal.
type Result struct {
// Orphans are keys present in storage but absent from the journal; they are
// candidates for deletion.
Orphans []string
// Missing are keys present in the journal but absent from storage; they are
// reported only and never modified.
Missing []string
}
// Classify compares the listed storage objects with the journaled keys.
func Classify(objects []storage.ObjectInfo, journaled []string) Result {
have := make(map[string]struct{}, len(journaled))
for _, k := range journaled {
have[k] = struct{}{}
}
seen := make(map[string]struct{}, len(objects))
var orphans []string
for _, o := range objects {
if _, dup := seen[o.Key]; dup {
continue
}
seen[o.Key] = struct{}{}
if _, ok := have[o.Key]; !ok {
orphans = append(orphans, o.Key)
}
}
var missing []string
for _, k := range journaled {
if _, ok := seen[k]; !ok {
missing = append(missing, k)
}
}
sort.Strings(orphans)
sort.Strings(missing)
return Result{Orphans: orphans, Missing: missing}
}
// Deletable selects orphaned keys that are safe to remove: within prefix and
// older than minAge (a guard against racing an in-flight upload whose row is
// not written yet).
func Deletable(objects []storage.ObjectInfo, res Result, prefix string, minAge time.Duration, now time.Time) []string {
orphans := make(map[string]struct{}, len(res.Orphans))
for _, k := range res.Orphans {
orphans[k] = struct{}{}
}
var out []string
for _, o := range objects {
if _, ok := orphans[o.Key]; !ok {
continue
}
if prefix != "" && !strings.HasPrefix(o.Key, prefix) {
continue
}
if now.Sub(o.ModTime) < minAge {
continue
}
out = append(out, o.Key)
}
sort.Strings(out)
return out
}
@@ -0,0 +1,41 @@
package reconcile
import (
"reflect"
"testing"
"time"
"shared-album/internal/storage"
)
func TestClassify(t *testing.T) {
now := time.Now()
objects := []storage.ObjectInfo{
{Key: "objects/a/original.jpg", ModTime: now},
{Key: "objects/b/original.jpg", ModTime: now},
}
journaled := []string{"objects/b/original.jpg", "objects/c/original.jpg"}
got := Classify(objects, journaled)
if !reflect.DeepEqual(got.Orphans, []string{"objects/a/original.jpg"}) {
t.Fatalf("orphans = %v", got.Orphans)
}
if !reflect.DeepEqual(got.Missing, []string{"objects/c/original.jpg"}) {
t.Fatalf("missing = %v", got.Missing)
}
}
func TestDeletableRespectsAgeAndPrefix(t *testing.T) {
now := time.Now()
objects := []storage.ObjectInfo{
{Key: "objects/old/original.jpg", ModTime: now.Add(-2 * time.Hour)},
{Key: "objects/new/original.jpg", ModTime: now.Add(-1 * time.Minute)},
{Key: "misc/other.bin", ModTime: now.Add(-2 * time.Hour)},
}
res := Result{Orphans: []string{"objects/old/original.jpg", "objects/new/original.jpg", "misc/other.bin"}}
got := Deletable(objects, res, "objects/", time.Hour, now)
if !reflect.DeepEqual(got, []string{"objects/old/original.jpg"}) {
t.Fatalf("deletable = %v, want only the old in-prefix orphan", got)
}
}
+533
View File
@@ -0,0 +1,533 @@
// Package contract holds a backend-agnostic test suite for the metadata
// repositories. The same suite runs against PostgreSQL and SQLite so both
// implementations are held to identical observable behaviour.
package contract
import (
"context"
"errors"
"testing"
"time"
"shared-album/internal/albums"
"shared-album/internal/auth"
"shared-album/internal/members"
"shared-album/internal/models"
"shared-album/internal/notifications"
"shared-album/internal/photos"
"shared-album/internal/sharelinks"
"shared-album/internal/social"
)
// Repos bundles one repository per domain plus a Reset hook that clears all
// metadata so each subtest starts from an empty state.
type Repos struct {
Auth auth.Repository
Albums albums.Repository
Members members.Repository
Photos photos.Repository
Social social.Repository
Notifications notifications.Repository
ShareLinks sharelinks.Repository
Reset func(ctx context.Context) error
}
// Run executes the whole contract suite against the given repositories.
func Run(t *testing.T, r Repos) {
t.Helper()
cases := []struct {
name string
fn func(*testing.T, context.Context, Repos)
}{
{"Auth", testAuth},
{"Albums", testAlbums},
{"Members", testMembers},
{"Notifications", testNotifications},
{"ShareLinks", testShareLinks},
{"Social", testSocial},
{"Photos", testPhotos},
{"Objects", testObjects},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := r.Reset(context.Background()); err != nil {
t.Fatalf("reset: %v", err)
}
tc.fn(t, context.Background(), r)
})
}
}
func newID() string { return auth.NewID() }
func seedUser(t *testing.T, ctx context.Context, r Repos, name string) *models.User {
t.Helper()
u := &models.User{ID: newID(), Email: newID()[:10] + "@test.local", PasswordHash: "x", Name: name}
if err := r.Auth.CreateUser(ctx, u); err != nil {
t.Fatalf("create user: %v", err)
}
return u
}
func seedAlbum(t *testing.T, ctx context.Context, r Repos, ownerID string) *models.Album {
t.Helper()
a := &models.Album{ID: newID(), OwnerID: ownerID, Title: "Album"}
if err := r.Albums.Create(ctx, a); err != nil {
t.Fatalf("create album: %v", err)
}
return a
}
func seedPhoto(t *testing.T, ctx context.Context, r Repos, uploaderID, albumID string) *models.Photo {
t.Helper()
obj := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), State: photos.ObjectPending}
p := &models.Photo{ID: newID(), AlbumID: albumID, UploaderID: uploaderID, OriginalFilename: "p.jpg"}
if err := r.Photos.CreateWithOriginal(ctx, p, obj); err != nil {
t.Fatalf("create photo: %v", err)
}
if err := r.Photos.ActivateObject(ctx, obj.ID); err != nil {
t.Fatalf("activate object: %v", err)
}
return p
}
func testAuth(t *testing.T, ctx context.Context, r Repos) {
u := seedUser(t, ctx, r, "Tester")
byEmail, err := r.Auth.GetUserByEmail(ctx, u.Email)
if err != nil || byEmail.ID != u.ID {
t.Fatalf("get by email: %v id=%v", err, byEmail)
}
if _, err := r.Auth.GetUserByID(ctx, u.ID); err != nil {
t.Fatalf("get by id: %v", err)
}
if _, err := r.Auth.GetUserByEmail(ctx, "missing@test.local"); !errors.Is(err, auth.ErrNotFound) {
t.Fatalf("missing email err = %v, want ErrNotFound", err)
}
if err := r.Auth.UpdatePreferences(ctx, u.ID, []byte(`{"theme":"dark"}`)); err != nil {
t.Fatalf("update preferences: %v", err)
}
got, _ := r.Auth.GetUserByID(ctx, u.ID)
if got.Preferences["theme"] != "dark" {
t.Fatalf("preferences not applied: %+v", got.Preferences)
}
if err := r.Auth.UpdatePreferences(ctx, "missing", []byte(`{}`)); !errors.Is(err, auth.ErrNotFound) {
t.Fatalf("update missing prefs err = %v, want ErrNotFound", err)
}
hash := newID()
rt := &models.RefreshToken{ID: newID(), UserID: u.ID, TokenHash: hash, ExpiresAt: time.Now().Add(time.Hour)}
if err := r.Auth.CreateRefreshToken(ctx, rt); err != nil {
t.Fatalf("create refresh: %v", err)
}
fetched, err := r.Auth.GetRefreshToken(ctx, hash)
if err != nil || fetched.ID != rt.ID {
t.Fatalf("get refresh: %v id=%v", err, fetched)
}
if err := r.Auth.RevokeRefreshToken(ctx, rt.ID); err != nil {
t.Fatalf("revoke refresh: %v", err)
}
fetched, _ = r.Auth.GetRefreshToken(ctx, hash)
if fetched.RevokedAt == nil {
t.Fatalf("refresh not revoked")
}
if err := r.Auth.RevokeAllUserTokens(ctx, u.ID); err != nil {
t.Fatalf("revoke all: %v", err)
}
}
func testAlbums(t *testing.T, ctx context.Context, r Repos) {
owner := seedUser(t, ctx, r, "Owner")
a := seedAlbum(t, ctx, r, owner.ID)
if a.CreatedAt.IsZero() || a.UpdatedAt.IsZero() {
t.Fatalf("album timestamps not returned: %+v", a)
}
got, err := r.Albums.GetByID(ctx, a.ID)
if err != nil || got.Title != "Album" {
t.Fatalf("get: %v %+v", err, got)
}
if oid, err := r.Albums.OwnerID(ctx, a.ID); err != nil || oid != owner.ID {
t.Fatalf("owner id: %v %v", oid, err)
}
list, err := r.Albums.ListForUser(ctx, owner.ID)
if err != nil || len(list) != 1 {
t.Fatalf("list: %v len=%d", err, len(list))
}
a.Title = "Renamed"
if err := r.Albums.Update(ctx, a); err != nil {
t.Fatalf("update: %v", err)
}
got, _ = r.Albums.GetByID(ctx, a.ID)
if got.Title != "Renamed" {
t.Fatalf("update not applied: %q", got.Title)
}
if err := r.Albums.Delete(ctx, a.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := r.Albums.GetByID(ctx, a.ID); !errors.Is(err, albums.ErrNotFound) {
t.Fatalf("after delete err = %v, want ErrNotFound", err)
}
}
func testMembers(t *testing.T, ctx context.Context, r Repos) {
owner := seedUser(t, ctx, r, "Owner")
member := seedUser(t, ctx, r, "Member")
a := seedAlbum(t, ctx, r, owner.ID)
if role, _ := r.Members.Role(ctx, a.ID, member.ID); role != "" {
t.Fatalf("role before add = %q", role)
}
if err := r.Members.Add(ctx, a.ID, member.ID, "member", &owner.ID); err != nil {
t.Fatalf("add: %v", err)
}
if role, err := r.Members.Role(ctx, a.ID, member.ID); err != nil || role != "member" {
t.Fatalf("role = %q err=%v", role, err)
}
list, err := r.Members.List(ctx, a.ID)
if err != nil || len(list) != 1 || list[0].User.Name != "Member" {
t.Fatalf("list: %v %+v", err, list)
}
if err := r.Members.UpdateRole(ctx, a.ID, member.ID, "editor"); err != nil {
t.Fatalf("update role: %v", err)
}
if role, _ := r.Members.Role(ctx, a.ID, member.ID); role != "editor" {
t.Fatalf("role not updated: %q", role)
}
inv := &models.Invite{ID: newID(), AlbumID: a.ID, Token: newID(), Role: "viewer", CreatedBy: owner.ID, ExpiresAt: time.Now().Add(time.Hour), MaxUses: 2}
if err := r.Members.CreateInvite(ctx, inv); err != nil {
t.Fatalf("create invite: %v", err)
}
if inv.CreatedAt.IsZero() {
t.Fatalf("invite created_at not returned")
}
got, err := r.Members.GetInviteByToken(ctx, inv.Token)
if err != nil || got.Role != "viewer" || got.MaxUses != 2 {
t.Fatalf("get invite: %v %+v", err, got)
}
if err := r.Members.IncrementUses(ctx, inv.ID); err != nil {
t.Fatalf("increment: %v", err)
}
got, _ = r.Members.GetInviteByToken(ctx, inv.Token)
if got.Uses != 1 {
t.Fatalf("uses = %d, want 1", got.Uses)
}
if _, err := r.Members.GetInviteByToken(ctx, "missing"); !errors.Is(err, members.ErrNotFound) {
t.Fatalf("missing invite err = %v, want ErrNotFound", err)
}
if err := r.Members.Remove(ctx, a.ID, member.ID); err != nil {
t.Fatalf("remove: %v", err)
}
if role, _ := r.Members.Role(ctx, a.ID, member.ID); role != "" {
t.Fatalf("member not removed: %q", role)
}
if err := r.Members.Remove(ctx, a.ID, member.ID); !errors.Is(err, members.ErrNotFound) {
t.Fatalf("remove missing err = %v, want ErrNotFound", err)
}
}
func testNotifications(t *testing.T, ctx context.Context, r Repos) {
u := seedUser(t, ctx, r, "Notify")
n := &models.Notification{ID: newID(), UserID: u.ID, Type: "test", PayloadJSON: map[string]any{"k": "v"}}
if err := r.Notifications.Create(ctx, n); err != nil {
t.Fatalf("create: %v", err)
}
if n.CreatedAt.IsZero() {
t.Fatalf("notification created_at not returned")
}
list, err := r.Notifications.List(ctx, u.ID, 10)
if err != nil || len(list) != 1 || list[0].PayloadJSON["k"] != "v" {
t.Fatalf("list: %v %+v", err, list)
}
if err := r.Notifications.MarkRead(ctx, n.ID, u.ID); err != nil {
t.Fatalf("mark read: %v", err)
}
list, _ = r.Notifications.List(ctx, u.ID, 10)
if list[0].ReadAt == nil {
t.Fatalf("notification not marked read")
}
if err := r.Notifications.MarkRead(ctx, newID(), u.ID); !errors.Is(err, notifications.ErrNotFound) {
t.Fatalf("mark missing err = %v, want ErrNotFound", err)
}
}
func testShareLinks(t *testing.T, ctx context.Context, r Repos) {
owner := seedUser(t, ctx, r, "Owner")
a := seedAlbum(t, ctx, r, owner.ID)
l := &models.ShareLink{ID: newID(), AlbumID: a.ID, Token: newID(), CreatedBy: owner.ID, ExpiresAt: time.Now().Add(time.Hour)}
if err := r.ShareLinks.Create(ctx, l); err != nil {
t.Fatalf("create: %v", err)
}
if l.CreatedAt.IsZero() {
t.Fatalf("share link created_at not returned")
}
got, err := r.ShareLinks.GetByToken(ctx, l.Token)
if err != nil || !got.Active(time.Now()) {
t.Fatalf("get token: %v %+v", err, got)
}
list, err := r.ShareLinks.ListByAlbum(ctx, a.ID)
if err != nil || len(list) != 1 {
t.Fatalf("list: %v len=%d", err, len(list))
}
if err := r.ShareLinks.Revoke(ctx, l.ID, a.ID); err != nil {
t.Fatalf("revoke: %v", err)
}
got, _ = r.ShareLinks.GetByToken(ctx, l.Token)
if got.Active(time.Now()) {
t.Fatalf("link still active after revoke")
}
if err := r.ShareLinks.Revoke(ctx, l.ID, a.ID); !errors.Is(err, sharelinks.ErrNotFound) {
t.Fatalf("revoke again err = %v, want ErrNotFound", err)
}
if _, err := r.ShareLinks.GetByToken(ctx, "missing"); !errors.Is(err, sharelinks.ErrNotFound) {
t.Fatalf("missing token err = %v, want ErrNotFound", err)
}
}
func testSocial(t *testing.T, ctx context.Context, r Repos) {
owner := seedUser(t, ctx, r, "Owner")
a := seedAlbum(t, ctx, r, owner.ID)
p := seedPhoto(t, ctx, r, owner.ID, a.ID)
if err := r.Social.Like(ctx, p.ID, owner.ID); err != nil {
t.Fatalf("like: %v", err)
}
if err := r.Social.Like(ctx, p.ID, owner.ID); err != nil {
t.Fatalf("like twice: %v", err)
}
if err := r.Social.Unlike(ctx, p.ID, owner.ID); err != nil {
t.Fatalf("unlike: %v", err)
}
c := &models.Comment{ID: newID(), PhotoID: p.ID, UserID: owner.ID, Text: "hi"}
if err := r.Social.CreateComment(ctx, c); err != nil {
t.Fatalf("create comment: %v", err)
}
if c.CreatedAt.IsZero() || c.UpdatedAt.IsZero() {
t.Fatalf("comment timestamps not returned: %+v", c)
}
list, err := r.Social.ListComments(ctx, p.ID)
if err != nil || len(list) != 1 || list[0].Text != "hi" {
t.Fatalf("list comments: %v %+v", err, list)
}
got, err := r.Social.GetComment(ctx, c.ID)
if err != nil || got.Text != "hi" {
t.Fatalf("get comment: %v %+v", err, got)
}
if err := r.Social.DeleteComment(ctx, c.ID); err != nil {
t.Fatalf("delete comment: %v", err)
}
if _, err := r.Social.GetComment(ctx, c.ID); !errors.Is(err, social.ErrNotFound) {
t.Fatalf("get deleted err = %v, want ErrNotFound", err)
}
if err := r.Social.DeleteComment(ctx, c.ID); !errors.Is(err, social.ErrNotFound) {
t.Fatalf("delete again err = %v, want ErrNotFound", err)
}
}
func testPhotos(t *testing.T, ctx context.Context, r Repos) {
owner := seedUser(t, ctx, r, "Owner")
a := seedAlbum(t, ctx, r, owner.ID)
p := seedPhoto(t, ctx, r, owner.ID, a.ID)
if p.CreatedAt.IsZero() {
t.Fatalf("photo created_at not returned")
}
got, err := r.Photos.GetByID(ctx, p.ID, "")
if err != nil || got.StorageKey == "" {
t.Fatalf("get photo: %v %+v", err, got)
}
list, err := r.Photos.List(ctx, photos.ListParams{AlbumID: a.ID, Limit: 10})
if err != nil || len(list) != 1 {
t.Fatalf("list: %v len=%d", err, len(list))
}
tag, err := r.Photos.EnsureTag(ctx, owner.ID, "Vacation")
if err != nil || tag.Name != "Vacation" {
t.Fatalf("ensure tag: %v %+v", err, tag)
}
tag2, err := r.Photos.EnsureTag(ctx, owner.ID, "vacation")
if err != nil || tag2.ID != tag.ID {
t.Fatalf("ensure tag case-insensitive: %v %+v", err, tag2)
}
if err := r.Photos.AttachTag(ctx, p.ID, tag.ID); err != nil {
t.Fatalf("attach tag: %v", err)
}
got, _ = r.Photos.GetByID(ctx, p.ID, owner.ID)
if len(got.Tags) != 1 || got.Tags[0].Name != "Vacation" {
t.Fatalf("photo tags = %+v", got.Tags)
}
if owned, _ := r.Photos.TagOwnedBy(ctx, tag.ID, owner.ID); !owned {
t.Fatalf("tag not owned")
}
tags, err := r.Photos.ListTags(ctx, owner.ID, "vac")
if err != nil || len(tags) != 1 {
t.Fatalf("list tags: %v len=%d", err, len(tags))
}
if err := r.Photos.DetachTag(ctx, p.ID, tag.ID); err != nil {
t.Fatalf("detach tag: %v", err)
}
r.Photos.SetCoverIfEmpty(ctx, a.ID, p.ID)
al, _ := r.Albums.GetByID(ctx, a.ID)
if al.CoverPhotoID == nil || *al.CoverPhotoID != p.ID {
t.Fatalf("cover not set: %+v", al.CoverPhotoID)
}
if err := r.Photos.SetDeleted(ctx, p.ID, true); err != nil {
t.Fatalf("soft delete: %v", err)
}
if n, _ := r.Photos.TrashCount(ctx, owner.ID); n != 1 {
t.Fatalf("trash count = %d, want 1", n)
}
trash, err := r.Photos.ListTrash(ctx, owner.ID)
if err != nil || len(trash) != 1 {
t.Fatalf("trash: %v len=%d", err, len(trash))
}
if err := r.Photos.RestoreToAlbum(ctx, p.ID, a.ID); err != nil {
t.Fatalf("restore: %v", err)
}
if n, _ := r.Photos.TrashCount(ctx, owner.ID); n != 0 {
t.Fatalf("trash count after restore = %d", n)
}
if err := r.Photos.UpdateFilename(ctx, p.ID, "renamed.jpg"); err != nil {
t.Fatalf("update filename: %v", err)
}
got, _ = r.Photos.GetByID(ctx, p.ID, "")
if got.OriginalFilename != "renamed.jpg" {
t.Fatalf("filename = %q", got.OriginalFilename)
}
if ok, _ := r.Photos.AlbumExists(ctx, a.ID); !ok {
t.Fatalf("album should exist")
}
if err := r.Photos.HardDelete(ctx, p.ID); err != nil {
t.Fatalf("hard delete: %v", err)
}
if _, err := r.Photos.GetByID(ctx, p.ID, ""); !errors.Is(err, photos.ErrNotFound) {
t.Fatalf("get deleted err = %v, want ErrNotFound", err)
}
}
func testObjects(t *testing.T, ctx context.Context, r Repos) {
owner := seedUser(t, ctx, r, "Owner")
a := seedAlbum(t, ctx, r, owner.ID)
p := seedPhoto(t, ctx, r, owner.ID, a.ID)
orig, err := r.Photos.OriginalObject(ctx, p.ID)
if err != nil || orig.State != photos.ObjectReady {
t.Fatalf("original object: %v %+v", err, orig)
}
keys, err := r.Photos.AllStorageKeys(ctx)
if err != nil || !contains(keys, orig.StorageKey) {
t.Fatalf("AllStorageKeys = %v err=%v, want %s", keys, err, orig.StorageKey)
}
if refs, _ := r.Photos.ObjectRefCount(ctx, orig.ID); refs != 1 {
t.Fatalf("refs = %d, want 1", refs)
}
sibs, _ := r.Photos.PhotosByOriginalObject(ctx, orig.ID)
if len(sibs) != 1 || sibs[0] != p.ID {
t.Fatalf("siblings = %+v", sibs)
}
thumb := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectPending}
if err := r.Photos.CreateObject(ctx, thumb); err != nil {
t.Fatalf("create object: %v", err)
}
if err := r.Photos.BindObject(ctx, p.ID, thumb.ID, photos.RoleThumb); !errors.Is(err, photos.ErrObjectNotReady) {
t.Fatalf("bind pending err = %v, want ErrObjectNotReady", err)
}
if err := r.Photos.ActivateObject(ctx, thumb.ID); err != nil {
t.Fatalf("activate: %v", err)
}
if err := r.Photos.BindObject(ctx, p.ID, thumb.ID, photos.RoleThumb); err != nil {
t.Fatalf("bind thumb: %v", err)
}
if refs, _ := r.Photos.ObjectRefCount(ctx, thumb.ID); refs != 1 {
t.Fatalf("thumb refs = %d, want 1", refs)
}
// After the photo is gone, its objects have no bindings and are collectible.
if err := r.Photos.HardDelete(ctx, p.ID); err != nil {
t.Fatalf("hard delete: %v", err)
}
claimed, err := r.Photos.ClaimGarbage(ctx, 10)
if err != nil {
t.Fatalf("claim garbage: %v", err)
}
got := map[string]bool{}
for _, o := range claimed {
got[o.ID] = true
}
if !got[orig.ID] || !got[thumb.ID] {
t.Fatalf("claimed = %+v, want original and thumb", got)
}
for _, o := range claimed {
if err := r.Photos.DeleteObjectRow(ctx, o.ID); err != nil {
t.Fatalf("delete object row: %v", err)
}
}
if again, _ := r.Photos.ClaimGarbage(ctx, 10); len(again) != 0 {
t.Fatalf("garbage reclaimed: %+v", again)
}
// Stale pending objects are reaped. A negative age puts the threshold in
// the future so the mechanism is exercised without depending on clock skew
// between the test process and the database.
stale := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), State: photos.ObjectPending}
if err := r.Photos.CreateObject(ctx, stale); err != nil {
t.Fatalf("create stale: %v", err)
}
staleClaimed, err := r.Photos.ClaimStalePending(ctx, -time.Hour, 10)
if err != nil || len(staleClaimed) == 0 {
t.Fatalf("claim stale: %v %+v", err, staleClaimed)
}
// Variant queue: a fresh photo has one job.
p2 := seedPhoto(t, ctx, r, owner.ID, a.ID)
jobs, err := r.Photos.ClaimVariantJobs(ctx, 10, time.Minute)
if err != nil {
t.Fatalf("claim jobs: %v", err)
}
if !contains(jobs, p2.ID) {
t.Fatalf("jobs = %+v, want %s", jobs, p2.ID)
}
if err := r.Photos.CompleteVariantJob(ctx, p2.ID); err != nil {
t.Fatalf("complete job: %v", err)
}
if jobs, _ = r.Photos.ClaimVariantJobs(ctx, 10, time.Minute); contains(jobs, p2.ID) {
t.Fatalf("completed job still claimed: %+v", jobs)
}
// Failure schedules a retry and eventually abandons the job.
p3 := seedPhoto(t, ctx, r, owner.ID, a.ID)
if err := r.Photos.FailVariantJob(ctx, p3.ID, "boom", 3); err != nil {
t.Fatalf("fail job: %v", err)
}
if jobs, _ = r.Photos.ClaimVariantJobs(ctx, 10, time.Minute); contains(jobs, p3.ID) {
t.Fatalf("failed job retried immediately: %+v", jobs)
}
for i := 0; i < 3; i++ {
if err := r.Photos.FailVariantJob(ctx, p3.ID, "boom", 3); err != nil {
t.Fatalf("fail job again: %v", err)
}
}
if jobs, _ = r.Photos.ClaimVariantJobs(ctx, 10, time.Minute); contains(jobs, p3.ID) {
t.Fatalf("abandoned job still present: %+v", jobs)
}
}
func contains(list []string, v string) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}
@@ -0,0 +1,78 @@
package contract
import (
"context"
"fmt"
"io/fs"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
assets "shared-album"
"shared-album/internal/albums"
"shared-album/internal/auth"
"shared-album/internal/db"
"shared-album/internal/members"
"shared-album/internal/notifications"
"shared-album/internal/photos"
"shared-album/internal/sharelinks"
"shared-album/internal/social"
)
// TestPostgresContract runs the shared suite against PostgreSQL inside its own
// schema, so it never clashes with other packages that use the same database.
func TestPostgresContract(t *testing.T) {
base := os.Getenv("TEST_DATABASE_URL")
if base == "" {
t.Skip("TEST_DATABASE_URL not set")
}
ctx := context.Background()
admin, err := pgxpool.New(ctx, base)
if err != nil {
t.Skipf("cannot connect: %v", err)
}
if err := admin.Ping(ctx); err != nil {
t.Skipf("cannot ping: %v", err)
}
t.Cleanup(admin.Close)
schema := fmt.Sprintf("ct_%d", time.Now().UnixNano())
if _, err := admin.Exec(ctx, "CREATE SCHEMA "+schema); err != nil {
t.Fatalf("create schema: %v", err)
}
t.Cleanup(func() {
_, _ = admin.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE")
})
dsn := base + "&options=-c%20search_path%3D" + schema
migFS, err := fs.Sub(assets.Migrations, "migrations")
if err != nil {
t.Fatalf("migrations fs: %v", err)
}
if err := db.Migrate(dsn, migFS); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
Run(t, Repos{
Auth: auth.NewStore(pool),
Albums: albums.NewStore(pool),
Members: members.NewStore(pool),
Photos: photos.NewStore(pool),
Social: social.NewStore(pool),
Notifications: notifications.NewStore(pool),
ShareLinks: sharelinks.NewStore(pool),
Reset: func(ctx context.Context) error {
_, err := pool.Exec(ctx, `TRUNCATE users, objects CASCADE`)
return err
},
})
}
@@ -0,0 +1,57 @@
package contract
import (
"context"
"io/fs"
"path/filepath"
"testing"
assets "shared-album"
"shared-album/internal/albums"
"shared-album/internal/auth"
"shared-album/internal/db"
"shared-album/internal/members"
"shared-album/internal/notifications"
"shared-album/internal/photos"
"shared-album/internal/sharelinks"
"shared-album/internal/social"
)
func TestSQLiteContract(t *testing.T) {
path := filepath.Join(t.TempDir(), "meta.db")
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
t.Fatalf("migrations fs: %v", err)
}
if err := db.MigrateSQLite(path, migFS); err != nil {
t.Fatalf("migrate: %v", err)
}
database, err := db.OpenSQLite(path)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { database.Close() })
Run(t, Repos{
Auth: auth.NewSQLiteStore(database),
Albums: albums.NewSQLiteStore(database),
Members: members.NewSQLiteStore(database),
Photos: photos.NewSQLiteStore(database),
Social: social.NewSQLiteStore(database),
Notifications: notifications.NewSQLiteStore(database),
ShareLinks: sharelinks.NewSQLiteStore(database),
Reset: func(ctx context.Context) error {
tables := []string{
"variant_jobs", "photo_objects", "objects", "photo_tags", "tags",
"refresh_tokens", "share_links", "notifications", "likes", "comments",
"invites", "album_members", "photos", "albums", "users",
}
for _, tbl := range tables {
if _, err := database.ExecContext(ctx, "DELETE FROM "+tbl); err != nil {
return err
}
}
return nil
},
})
}
+15
View File
@@ -0,0 +1,15 @@
package sharelinks
import (
"context"
"shared-album/internal/models"
)
// Repository is the persistence contract for the share-links domain.
type Repository interface {
Create(ctx context.Context, l *models.ShareLink) error
ListByAlbum(ctx context.Context, albumID string) ([]models.ShareLink, error)
GetByToken(ctx context.Context, token string) (*models.ShareLink, error)
Revoke(ctx context.Context, id, albumID string) error
}
+2 -2
View File
@@ -16,11 +16,11 @@ type AlbumAccess interface {
}
type Service struct {
store *Store
store Repository
albums AlbumAccess
}
func NewService(store *Store, albums AlbumAccess) *Service {
func NewService(store Repository, albums AlbumAccess) *Service {
return &Service{store: store, albums: albums}
}
@@ -0,0 +1,90 @@
package sharelinks
import (
"context"
"database/sql"
"errors"
"time"
"shared-album/internal/models"
"shared-album/internal/sqliteutil"
)
// SQLiteStore is the SQLite implementation of Repository.
type SQLiteStore struct {
db *sql.DB
}
func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
func (s *SQLiteStore) Create(ctx context.Context, l *models.ShareLink) error {
var createdAt string
err := s.db.QueryRowContext(ctx,
`INSERT INTO share_links (id, album_id, token, created_by, expires_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING created_at`,
l.ID, l.AlbumID, l.Token, l.CreatedBy, sqliteutil.FormatTime(l.ExpiresAt),
).Scan(&createdAt)
if err != nil {
return err
}
l.CreatedAt = sqliteutil.ParseTime(createdAt)
return nil
}
func (s *SQLiteStore) ListByAlbum(ctx context.Context, albumID string) ([]models.ShareLink, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, album_id, token, created_by, created_at, expires_at, revoked_at
FROM share_links WHERE album_id = $1 ORDER BY created_at DESC`, albumID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.ShareLink{}
for rows.Next() {
var l models.ShareLink
var createdAt, expiresAt string
var revokedAt sql.NullString
if err := rows.Scan(&l.ID, &l.AlbumID, &l.Token, &l.CreatedBy, &createdAt, &expiresAt, &revokedAt); err != nil {
return nil, err
}
l.CreatedAt = sqliteutil.ParseTime(createdAt)
l.ExpiresAt = sqliteutil.ParseTime(expiresAt)
l.RevokedAt = sqliteutil.ParseNullTime(revokedAt)
out = append(out, l)
}
return out, rows.Err()
}
func (s *SQLiteStore) GetByToken(ctx context.Context, token string) (*models.ShareLink, error) {
var l models.ShareLink
var createdAt, expiresAt string
var revokedAt sql.NullString
err := s.db.QueryRowContext(ctx,
`SELECT id, album_id, token, created_by, created_at, expires_at, revoked_at
FROM share_links WHERE token = $1`, token).
Scan(&l.ID, &l.AlbumID, &l.Token, &l.CreatedBy, &createdAt, &expiresAt, &revokedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
l.CreatedAt = sqliteutil.ParseTime(createdAt)
l.ExpiresAt = sqliteutil.ParseTime(expiresAt)
l.RevokedAt = sqliteutil.ParseNullTime(revokedAt)
return &l, nil
}
func (s *SQLiteStore) Revoke(ctx context.Context, id, albumID string) error {
res, err := s.db.ExecContext(ctx,
`UPDATE share_links SET revoked_at = $3 WHERE id = $1 AND album_id = $2 AND revoked_at IS NULL`,
id, albumID, sqliteutil.FormatTime(time.Now()))
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
+17
View File
@@ -0,0 +1,17 @@
package social
import (
"context"
"shared-album/internal/models"
)
// Repository is the persistence contract for the social domain.
type Repository interface {
Like(ctx context.Context, photoID, userID string) error
Unlike(ctx context.Context, photoID, userID string) error
CreateComment(ctx context.Context, c *models.Comment) error
ListComments(ctx context.Context, photoID string) ([]models.Comment, error)
GetComment(ctx context.Context, id string) (*models.Comment, error)
DeleteComment(ctx context.Context, id string) error
}
+2 -2
View File
@@ -25,14 +25,14 @@ type Notifier interface {
}
type Service struct {
store *Store
store Repository
albums AlbumAccess
photos PhotoLookup
hub *sse.Hub
notify Notifier
}
func NewService(store *Store, albums AlbumAccess, photos PhotoLookup, hub *sse.Hub, notify Notifier) *Service {
func NewService(store Repository, albums AlbumAccess, photos PhotoLookup, hub *sse.Hub, notify Notifier) *Service {
return &Service{store: store, albums: albums, photos: photos, hub: hub, notify: notify}
}
+101
View File
@@ -0,0 +1,101 @@
package social
import (
"context"
"database/sql"
"errors"
"shared-album/internal/models"
"shared-album/internal/sqliteutil"
)
// SQLiteStore is the SQLite implementation of Repository.
type SQLiteStore struct {
db *sql.DB
}
func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
func (s *SQLiteStore) Like(ctx context.Context, photoID, userID string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO likes (photo_id, user_id) VALUES ($1, $2)
ON CONFLICT (photo_id, user_id) DO NOTHING`, photoID, userID)
return err
}
func (s *SQLiteStore) Unlike(ctx context.Context, photoID, userID string) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM likes WHERE photo_id = $1 AND user_id = $2`, photoID, userID)
return err
}
func (s *SQLiteStore) CreateComment(ctx context.Context, c *models.Comment) error {
var createdAt, updatedAt string
err := s.db.QueryRowContext(ctx,
`INSERT INTO comments (id, photo_id, user_id, text)
VALUES ($1, $2, $3, $4)
RETURNING created_at, updated_at`,
c.ID, c.PhotoID, c.UserID, c.Text,
).Scan(&createdAt, &updatedAt)
if err != nil {
return err
}
c.CreatedAt = sqliteutil.ParseTime(createdAt)
c.UpdatedAt = sqliteutil.ParseTime(updatedAt)
return nil
}
func (s *SQLiteStore) ListComments(ctx context.Context, photoID string) ([]models.Comment, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT c.id, c.photo_id, c.user_id, c.text, c.created_at, c.updated_at,
u.id, u.name, COALESCE(u.avatar_url, '')
FROM comments c
JOIN users u ON u.id = c.user_id
WHERE c.photo_id = $1
ORDER BY c.created_at`, photoID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Comment{}
for rows.Next() {
var c models.Comment
var createdAt, updatedAt string
if err := rows.Scan(&c.ID, &c.PhotoID, &c.UserID, &c.Text, &createdAt, &updatedAt,
&c.User.ID, &c.User.Name, &c.User.AvatarURL); err != nil {
return nil, err
}
c.CreatedAt = sqliteutil.ParseTime(createdAt)
c.UpdatedAt = sqliteutil.ParseTime(updatedAt)
out = append(out, c)
}
return out, rows.Err()
}
func (s *SQLiteStore) GetComment(ctx context.Context, id string) (*models.Comment, error) {
var c models.Comment
var createdAt, updatedAt string
err := s.db.QueryRowContext(ctx,
`SELECT id, photo_id, user_id, text, created_at, updated_at FROM comments WHERE id = $1`, id).
Scan(&c.ID, &c.PhotoID, &c.UserID, &c.Text, &createdAt, &updatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
c.CreatedAt = sqliteutil.ParseTime(createdAt)
c.UpdatedAt = sqliteutil.ParseTime(updatedAt)
return &c, nil
}
func (s *SQLiteStore) DeleteComment(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM comments WHERE id = $1`, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
+50
View File
@@ -0,0 +1,50 @@
// Package sqliteutil holds small helpers shared by the SQLite repositories:
// timestamp conversion and dynamic IN-lists.
package sqliteutil
import (
"database/sql"
"strconv"
"strings"
"time"
)
// TimeLayout matches the SQLite column defaults produced by strftime with %f.
const TimeLayout = "2006-01-02T15:04:05.000Z"
// FormatTime renders a time in the canonical TEXT format used by the schema.
func FormatTime(t time.Time) string { return t.UTC().Format(TimeLayout) }
// ParseTime parses a canonical timestamp, tolerating the RFC3339 variants the
// driver may return. An empty or unparseable value yields the zero time.
func ParseTime(s string) time.Time {
if s == "" {
return time.Time{}
}
if t, err := time.Parse(TimeLayout, s); err == nil {
return t
}
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t
}
return time.Time{}
}
// ParseNullTime converts a nullable TEXT timestamp into *time.Time.
func ParseNullTime(ns sql.NullString) *time.Time {
if !ns.Valid || ns.String == "" {
return nil
}
t := ParseTime(ns.String)
return &t
}
// Placeholders returns an IN-list placeholder group of n items starting at
// index start, e.g. Placeholders(3, 2) -> "$3,$4".
func Placeholders(start, n int) string {
parts := make([]string, n)
for i := 0; i < n; i++ {
parts[i] = "$" + strconv.Itoa(start+i)
}
return strings.Join(parts, ",")
}
+32
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"mime"
"net/http"
"net/url"
@@ -144,6 +145,37 @@ func (f *FS) URL(_ context.Context, key string) (string, error) {
return f.signer.URL(key), nil
}
// List returns every blob under prefix as a key relative to the storage root.
func (f *FS) List(_ context.Context, prefix string) ([]ObjectInfo, error) {
var out []ObjectInfo
err := filepath.WalkDir(f.root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(f.root, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if prefix != "" && !strings.HasPrefix(rel, prefix) {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
out = append(out, ObjectInfo{Key: rel, Size: info.Size(), ModTime: info.ModTime()})
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
// ServeObject validates the signed URL and serves the blob. It supports range
// requests and sets the content type from the key extension. It writes the
// response itself and returns nil on success.
+30
View File
@@ -172,3 +172,33 @@ func TestContentTypeForKey(t *testing.T) {
}
}
}
func TestFSList(t *testing.T) {
ctx := context.Background()
fs := newTestFS(t)
for _, key := range []string{"objects/a/original.jpg", "objects/b/original.jpg", "misc/readme.txt"} {
if err := fs.Put(ctx, key, strings.NewReader("x"), 1, "text/plain"); err != nil {
t.Fatalf("put %s: %v", key, err)
}
}
all, err := fs.List(ctx, "")
if err != nil || len(all) != 3 {
t.Fatalf("list all: %v len=%d", err, len(all))
}
objs, err := fs.List(ctx, "objects/")
if err != nil {
t.Fatalf("list objects: %v", err)
}
if len(objs) != 2 {
t.Fatalf("list objects len=%d, want 2", len(objs))
}
for _, o := range objs {
if !strings.HasPrefix(o.Key, "objects/") {
t.Fatalf("unexpected key %q", o.Key)
}
if o.Size != 1 || o.ModTime.IsZero() {
t.Fatalf("unexpected info %+v", o)
}
}
}
+27
View File
@@ -117,6 +117,33 @@ func TestS3ServeObject(t *testing.T) {
})
}
func TestS3List(t *testing.T) {
ctx := context.Background()
cl, _ := newTestS3(t)
prefix := "objects/" + strconv.FormatInt(time.Now().UnixNano(), 10) + "/"
keys := []string{prefix + "original.jpg", prefix + "thumb.webp"}
for _, k := range keys {
if err := cl.Put(ctx, k, strings.NewReader("x"), 1, "image/jpeg"); err != nil {
t.Fatalf("put %s: %v", k, err)
}
}
t.Cleanup(func() { _ = cl.Delete(context.Background(), keys...) })
got, err := cl.List(ctx, prefix)
if err != nil {
t.Fatalf("list: %v", err)
}
found := map[string]bool{}
for _, o := range got {
found[o.Key] = true
}
for _, k := range keys {
if !found[k] {
t.Fatalf("key %s not listed: %+v", k, got)
}
}
}
func TestS3ServeObjectHead(t *testing.T) {
ctx := context.Background()
cl, signer := newTestS3(t)
+40
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
@@ -29,6 +30,13 @@ var ErrObjectNotFound = errors.New("object not found")
// ErrRangeNotSatisfiable is returned when a requested byte range is invalid.
var ErrRangeNotSatisfiable = errors.New("requested range not satisfiable")
// ObjectInfo describes one stored blob for inventory and reconciliation.
type ObjectInfo struct {
Key string
Size int64
ModTime time.Time
}
// Backend is the object storage abstraction. Keys are opaque and independent of
// the backend: every implementation stores and serves the same logical blob.
// Blobs are always delivered through the app.
@@ -41,6 +49,8 @@ type Backend interface {
// ServeObject validates the signed URL and writes the blob, including
// Content-Type and range handling, to the response.
ServeObject(w http.ResponseWriter, r *http.Request, key string) error
// List returns the blobs under prefix for inventory and reconciliation.
List(ctx context.Context, prefix string) ([]ObjectInfo, error)
Ping(ctx context.Context) error
EnsureBucket(ctx context.Context) error
// Bucket returns a human-readable identifier for the storage location.
@@ -183,6 +193,36 @@ func (c *S3) URL(_ context.Context, key string) (string, error) {
return c.signer.URL(key), nil
}
// List returns every object under prefix using paginated ListObjectsV2.
func (c *S3) List(ctx context.Context, prefix string) ([]ObjectInfo, error) {
var out []ObjectInfo
var token *string
for {
in := &s3.ListObjectsV2Input{Bucket: aws.String(c.bucket)}
if prefix != "" {
in.Prefix = aws.String(prefix)
}
if token != nil {
in.ContinuationToken = token
}
res, err := c.s3.ListObjectsV2(ctx, in)
if err != nil {
return nil, fmt.Errorf("list objects: %w", err)
}
for _, o := range res.Contents {
out = append(out, ObjectInfo{
Key: aws.ToString(o.Key),
Size: aws.ToInt64(o.Size),
ModTime: aws.ToTime(o.LastModified),
})
}
if res.IsTruncated == nil || !*res.IsTruncated {
return out, nil
}
token = res.NextContinuationToken
}
}
// ServeObject validates the signed URL and streams the blob from S3. It passes
// the client's Range header through to S3, so byte ranges are handled natively,
// and answers HEAD via HeadObject. It writes the response itself.
+55
View File
@@ -0,0 +1,55 @@
// Package store builds the per-domain metadata repositories for a chosen
// backend. Domains depend on their Repository interfaces; this factory is the
// single place that knows the concrete implementations.
package store
import (
"database/sql"
"github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/albums"
"shared-album/internal/auth"
"shared-album/internal/members"
"shared-album/internal/notifications"
"shared-album/internal/photos"
"shared-album/internal/sharelinks"
"shared-album/internal/social"
)
// Repos bundles one repository per domain.
type Repos struct {
Auth auth.Repository
Albums albums.Repository
Members members.Repository
Photos photos.Repository
Social social.Repository
Notifications notifications.Repository
ShareLinks sharelinks.Repository
}
// NewPostgres builds the repositories backed by PostgreSQL.
func NewPostgres(pool *pgxpool.Pool) Repos {
return Repos{
Auth: auth.NewStore(pool),
Albums: albums.NewStore(pool),
Members: members.NewStore(pool),
Photos: photos.NewStore(pool),
Social: social.NewStore(pool),
Notifications: notifications.NewStore(pool),
ShareLinks: sharelinks.NewStore(pool),
}
}
// NewSQLite builds the repositories backed by SQLite.
func NewSQLite(db *sql.DB) Repos {
return Repos{
Auth: auth.NewSQLiteStore(db),
Albums: albums.NewSQLiteStore(db),
Members: members.NewSQLiteStore(db),
Photos: photos.NewSQLiteStore(db),
Social: social.NewSQLiteStore(db),
Notifications: notifications.NewSQLiteStore(db),
ShareLinks: sharelinks.NewSQLiteStore(db),
}
}
+55
View File
@@ -0,0 +1,55 @@
package store
import (
"context"
"io/fs"
"os"
"path/filepath"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
assets "shared-album"
"shared-album/internal/db"
)
func assertComplete(t *testing.T, r Repos) {
t.Helper()
if r.Auth == nil || r.Albums == nil || r.Members == nil || r.Photos == nil ||
r.Social == nil || r.Notifications == nil || r.ShareLinks == nil {
t.Fatalf("incomplete repository bundle: %+v", r)
}
}
func TestNewSQLite(t *testing.T) {
path := filepath.Join(t.TempDir(), "meta.db")
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
t.Fatalf("migrations fs: %v", err)
}
if err := db.MigrateSQLite(path, migFS); err != nil {
t.Fatalf("migrate: %v", err)
}
database, err := db.OpenSQLite(path)
if err != nil {
t.Fatalf("open: %v", err)
}
defer database.Close()
assertComplete(t, NewSQLite(database))
}
func TestNewPostgres(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Skipf("cannot connect: %v", err)
}
if err := pool.Ping(context.Background()); err != nil {
t.Skipf("cannot ping: %v", err)
}
t.Cleanup(pool.Close)
assertComplete(t, NewPostgres(pool))
}
@@ -0,0 +1,15 @@
DROP TABLE IF EXISTS variant_jobs;
DROP TABLE IF EXISTS photo_objects;
DROP TABLE IF EXISTS objects;
DROP TABLE IF EXISTS photo_tags;
DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS refresh_tokens;
DROP TABLE IF EXISTS share_links;
DROP TABLE IF EXISTS notifications;
DROP TABLE IF EXISTS likes;
DROP TABLE IF EXISTS comments;
DROP TABLE IF EXISTS invites;
DROP TABLE IF EXISTS album_members;
DROP TABLE IF EXISTS photos;
DROP TABLE IF EXISTS albums;
DROP TABLE IF EXISTS users;
@@ -0,0 +1,159 @@
-- Consolidated SQLite schema equivalent to the PostgreSQL migration history.
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
avatar_url TEXT,
preferences TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE albums (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
cover_photo_id TEXT REFERENCES photos(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE photos (
id TEXT PRIMARY KEY,
album_id TEXT REFERENCES albums(id) ON DELETE SET NULL,
uploader_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
original_filename TEXT NOT NULL DEFAULT '',
taken_at TEXT,
latitude REAL,
longitude REAL,
caption TEXT NOT NULL DEFAULT '',
exif TEXT NOT NULL DEFAULT '{}',
deleted_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX photos_album_created_idx ON photos (album_id, created_at DESC, id DESC);
CREATE INDEX photos_deleted_idx ON photos (deleted_at) WHERE deleted_at IS NOT NULL;
CREATE TABLE album_members (
album_id TEXT NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL,
invited_by TEXT REFERENCES users(id) ON DELETE SET NULL,
joined_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (album_id, user_id)
);
CREATE TABLE invites (
id TEXT PRIMARY KEY,
album_id TEXT NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
role TEXT NOT NULL,
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
max_uses INTEGER NOT NULL DEFAULT 1,
uses INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE comments (
id TEXT PRIMARY KEY,
photo_id TEXT NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
text TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX comments_photo_idx ON comments (photo_id, created_at);
CREATE TABLE likes (
photo_id TEXT NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (photo_id, user_id)
);
CREATE TABLE notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
read_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX notifications_user_idx ON notifications (user_id, created_at DESC);
CREATE TABLE share_links (
id TEXT PRIMARY KEY,
album_id TEXT NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
expires_at TEXT NOT NULL,
revoked_at TEXT
);
CREATE TABLE refresh_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
revoked_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX refresh_tokens_user_idx ON refresh_tokens (user_id);
CREATE TABLE tags (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE UNIQUE INDEX tags_user_name_idx ON tags (user_id, lower(name));
CREATE TABLE photo_tags (
photo_id TEXT NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (photo_id, tag_id)
);
CREATE INDEX photo_tags_tag_idx ON photo_tags (tag_id);
CREATE TABLE objects (
id TEXT PRIMARY KEY,
storage_key TEXT NOT NULL UNIQUE,
mime_type TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
width INTEGER NOT NULL DEFAULT 0,
height INTEGER NOT NULL DEFAULT 0,
checksum TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT 'pending'
CHECK (state IN ('pending', 'ready', 'deleting')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX objects_state_created_idx ON objects (state, created_at);
CREATE TABLE photo_objects (
photo_id TEXT NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
object_id TEXT NOT NULL REFERENCES objects(id) ON DELETE RESTRICT,
role TEXT NOT NULL CHECK (role IN ('original', 'thumb', 'preview')),
PRIMARY KEY (photo_id, role)
);
CREATE INDEX photo_objects_object_idx ON photo_objects (object_id);
CREATE TABLE variant_jobs (
photo_id TEXT PRIMARY KEY REFERENCES photos(id) ON DELETE CASCADE,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
last_error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX variant_jobs_due_idx ON variant_jobs (next_attempt_at);
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-11
@@ -0,0 +1,120 @@
# Design
## Context
См. `proposal.md` — Why. Текущее состояние, определяющее подход:
- Каждый домен имеет конкретный `Store` с полем `*pgxpool.Pool` и SQL диалекта Postgres.
Всего ~72 метода: `auth(9)`, `albums(7)`, `members(8)`, `photos(20)`,
`photos/objects(15)`, `social(6)`, `notifications(3)`, `sharelinks(4)`.
- Postgres-специфика: `FOR UPDATE SKIP LOCKED`, `ANY($1)`, `make_interval`/`power`/`least`,
`now()`, `timestamptz`, `jsonb`/`::jsonb`, `ON CONFLICT`, `RETURNING`.
- Кросс-доменные зависимости уже выражены интерфейсами (`AlbumAccess`, `PhotoLookup`,
`Notifier`, `MemberLookup`, `AlbumReader`); свои store сервисы берут конкретными.
- Сборка `CGO_ENABLED=0`; model: один экземпляр приложения на инсталляцию.
- Ограничение: SQLite допускает один писатель, много читателей (WAL); один файл на
один экземпляр приложения.
## Goals / Non-Goals
**Goals:**
- Ввести интерфейсы репозиториев по доменам и вторую реализацию на SQLite с паритетом.
- Сохранить текущее поведение PostgreSQL по умолчанию и не менять сервисную логику.
- Обеспечить атомарность объектов, GC и очереди вариантов на обоих бэкендах.
- Гарантировать паритет общим контрактным набором тестов.
**Non-Goals:**
- Горизонтальное масштабирование и несколько экземпляров на один файл SQLite.
- Миграция данных между PostgreSQL и SQLite.
- Изменение наблюдаемого поведения API/спек (см. `proposal.md`, изменений требований нет).
- Изменение слоя хранения (см. `local-fs-storage-backend`).
## Decisions
### D1. Параллельные реализации, а не слой диалекта
Выбрано (по решению пользователя): Postgres-store не трогаем, пишем отдельные
SQLite-store. Интерфейс `Repository` становится швом между доменом и хранилищем.
- Альтернатива: единая логика + `Dialect` — отвергнута: правки во всех существующих
store и риск регрессии Postgres ради экономии дублирования.
- Цена: каждая будущая фича реализуется дважды; снижается контрактными тестами (D9).
### D2. Именование и размещение
Интерфейс домена — `Repository`; существующий pgx-тип остаётся `Store` (`NewStore(pool)`),
SQLite-тип — `SQLiteStore` и живёт в том же пакете (`photos/sqlite_store.go` и т.п.).
- В том же пакете — доступ к `models` и sentinel-ошибкам без циклов импорта и без
перемещения существующего кода.
- Сервисы типизируются интерфейсом (`albums.Repository` и т.д.); кросс-доменные
интерфейсы удовлетворяются любой реализацией.
### D3. Драйвер SQLite — `modernc.org/sqlite`
Чистый Go, совместим с `CGO_ENABLED=0`.
- Альтернатива `mattn/go-sqlite3` (CGO) — отвергнута, ломает модель сборки.
### D4. Роль `$1`-плейсхолдеров
Плейсхолдеры остаются `$1`: и pgx, и `modernc` понимают именованные параметры.
Поскольку реализации раздельные, каждая пишет свой SQL; отдельный слой диалекта не нужен.
### D5. Отдельный набор миграций для SQLite
`migrations/sqlite/` рядом с Postgres-набором, драйвер `golang-migrate/database/sqlite`.
Отображение типов: `timestamptz`→TEXT, `jsonb`→TEXT, `bigint`→INTEGER,
`double precision`→REAL, `now()`→`strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
`REFERENCES ... ON DELETE CASCADE` и `CHECK` сохраняются.
- Альтернатива: переносимый единый набор — отвергнута, тянет переписывание обоих.
### D6. Настройка соединения и конкурентность
`journal_mode=WAL`, `busy_timeout=5000`, `foreign_keys=ON` через DSN-параметры
`_pragma`. Писатель один; читатели — параллельно.
- `ANY($1)` переписывается в `IN (?, ...)` с построением списка в Go; батчи малы.
- `FOR UPDATE`/`SKIP LOCKED` заменяются транзакцией с немедленной блокировкой записи
(`BEGIN IMMEDIATE`); порядок «проверить состояние — вставить/заявку» сохраняется.
### D7. Атомарность жизненного цикла объектов
`BindObject` и claim-методы (`ClaimGarbage`, `ClaimStalePending`, `ClaimVariantJobs`)
выполняются в `BEGIN IMMEDIATE`-транзакции, что сериализует запись и воспроизводит
инварианты Postgres-версии (нельзя привязать объект, уходящий в GC; нельзя удалить
используемый; одинаковая роль без дублей).
### D8. Backoff и `now()`
`FailVariantJob` считает задержку в Go (`min(300, 10*2^attempts)`) вместо
`make_interval/power/least`. Время пишется/читается через хелперы преобразования
`time.Time` <-> RFC3339 TEXT; запросы сравнения используют `datetime('now')`/значения Go,
а не неявное приведение драйвера.
### D9. Контрактные тесты на интерфейс
Один набор тестов на каждый `Repository`, прогоняемый на обоих бэкендах: Postgres под
`TEST_DATABASE_URL` (пропуск без него), SQLite на временном файле (всегда). Это механизм
удержания паритета при параллельных реализациях.
### D10. Фабрика и сборка зависимостей
`store.NewPostgres(pool)` и `store.NewSQLite(db)` возвращают бандл репозиториев;
`app.New` принимает бандл и `storage.Backend`. Выбор бэкенда — на старте по
`DB_BACKEND=postgres|sqlite` (дефолт `postgres`), путь — `SQLITE_PATH`.
- Альтернатива: ветвление внутри `app.New` по типу пула — отвергнута, размазывает выбор.
### D11. Маппинг ошибок и `RETURNING`
`schema`-уровневые sentinel-ошибки домена сохраняются; каждая реализация сама
отображает `pgx.ErrNoRows`/`sql.ErrNoRows` в sentinel. `RETURNING` используется и в
SQLite (>=3.35), поэтому вставки с возвратом `created_at` переносятся один-в-один.
## Risks / Trade-offs
- [Дрейф параллельных реализаций] → общий контрактный набор тестов на интерфейс (D9).
- [Пропускная способность записи SQLite под многими пользователями] → WAL,
`busy_timeout`, один писатель; документируем как режим малой инсталляции.
- [Потеря валидации `jsonb`] → хранение как TEXT, проверка JSON при необходимости в Go.
- [Ошибки преобразования времени/JSON между бэкендами] → хелперы + контрактные тесты.
- [Расхождение миграций] → симметричные наборы, прогон `up` на обоих в тестах.
- [Лимит параметров SQLite в `IN (...)`] → малые батчи (`WORKER_BATCH`), построение
списка в Go.
## Migration Plan
1. Ввести интерфейсы, не меняя Postgres-реализации и дефолт `postgres`.
2. Добавить SQLite-реализации, миграции и фабрику; включение — `DB_BACKEND=sqlite`.
3. Существующие развёртывания не меняются; откат — вернуть `DB_BACKEND=postgres`.
4. Перенос данных между бэкендами не автоматизируется и вне рамок изменения.
## Open Questions
- Нужен ли SQLite-специфичный поиск меток (например, FTS) или достаточно текущих
запросов с точным совпадением/префиксом.
@@ -0,0 +1,37 @@
# Proposal
## Why
Слой метаданных жёстко привязан к PostgreSQL: каждый store держит `*pgxpool.Pool` и
пишет SQL диалекта Postgres. Инсталляциям без внешней БД нужен встроенный,
самодостаточный режим на SQLite с паритетом поведения для одного экземпляра приложения.
## What Changes
- Ввести интерфейсы репозиториев по доменам (auth, albums, members, photos, social,
notifications, sharelinks); существующие pgx-типы становятся одной из реализаций.
- Добавить SQLite-реализации (~72 метода, включая жизненный цикл объектов, сборку
мусора и очередь вариантов) на чистом Go (`modernc.org/sqlite`, `CGO_ENABLED=0`).
- Добавить отдельный набор миграций для SQLite.
- Ввести `DB_BACKEND=postgres|sqlite` и `SQLITE_PATH`; валидировать условно; настроить
WAL, `busy_timeout`, `foreign_keys`.
- Добавить фабрику сборки репозиториев по выбранному бэкенду.
- Обеспечить единый контрактный набор тестов, прогоняемый на обоих бэкендах.
## Capabilities
### New Capabilities
- `metadata-backends`: выбор и работа слоя метаданных — PostgreSQL и встроенная SQLite с паритетным поведением для одного экземпляра.
### Modified Capabilities
<!-- Нет изменений требований существующих спек: поведение системы для клиента не меняется, меняется способ хранения метаданных. -->
## Impact
- `backend/internal/*/store.go` и `backend/internal/photos/objects.go` (интерфейсы +
SQLite-реализации), `internal/db`, `internal/app`, `cmd/server`, `config`, `migrations`.
- Тесты: общий контрактный набор для Postgres и SQLite.
- `.env.example`, `project.md`.
- Ограничение: один экземпляр приложения на один SQLite-файл (без горизонтального
масштабирования); Postgres остаётся поведением по умолчанию.
- Не затрагивает слой хранения.
@@ -0,0 +1,76 @@
# Spec Delta
## Purpose
Выбор и работа слоя метаданных: внешняя PostgreSQL или встроенная SQLite, с
паритетным поведением для одного экземпляра приложения.
## ADDED Requirements
### Requirement: Выбор бэкенда метаданных
Система SHALL поддерживать выбор бэкенда слоя метаданных параметром конфигурации,
при этом PostgreSQL SHALL оставаться значением по умолчанию.
#### Scenario: Выбор PostgreSQL
- **WHEN** в конфигурации задан бэкенд PostgreSQL
- **THEN** метаданные читаются и пишутся во внешнюю PostgreSQL
#### Scenario: Выбор встроенной SQLite
- **WHEN** в конфигурации задан бэкенд SQLite
- **THEN** метаданные читаются и пишутся в локальный файл SQLite без внешней службы
#### Scenario: Неполная конфигурация
- **WHEN** конфигурация выбранного бэкенда неполна
- **THEN** система отказывается стартовать с явной ошибкой конфигурации
### Requirement: Паритет поведения бэкендов
Оба бэкенда метаданных SHALL обеспечивать одинаковое наблюдаемое поведение системы:
аутентификацию, альбомы, роли и доступ, фото, объектный журнал, сборку мусора и
очередь генерации вариантов.
#### Scenario: Одинаковое поведение API
- **WHEN** один и тот же сценарий выполняется на PostgreSQL и на SQLite
- **THEN** наблюдаемые результаты и ошибки API совпадают
#### Scenario: Соблюдение прав доступа
- **WHEN** на любом бэкенде участник без прав выполняет запрещённое действие
- **THEN** система отклоняет действие одинаковым образом
### Requirement: Встроенный одиночный экземпляр
Бэкенд SQLite SHALL работать как самодостаточный режим для одного экземпляра приложения,
и система SHALL отвергать или явно документировать использование одного файла SQLite
несколькими экземплярами.
#### Scenario: Один экземпляр
- **WHEN** приложение запущено в единственном экземпляре
- **THEN** SQLite корректно обслуживает всех пользователей этого экземпляра
#### Scenario: Совместная работа с файлом
- **WHEN** предполагается совместная работа нескольких экземпляров с одним файлом SQLite
- **THEN** конфигурация считает такой режим неподдерживаемым
### Requirement: Атомарность жизненного цикла объектов
На обоих бэкендах привязка готового объекта к фото и заявка объекта на удаление SHALL
быть атомарными и SHALL исключать привязку объекта, который собирается быть удалён, и
удаление используемого объекта.
#### Scenario: Конкурентная привязка и сборка мусора
- **WHEN** привязка объекта и заявка на его удаление происходят одновременно
- **THEN** система либо привязывает объект без удаления, либо удаляет его без привязки
#### Scenario: Повторная привязка
- **WHEN** объект уже привязан к фото в роли
- **THEN** повторная привязка приводит к согласованному состоянию без дублей
### Requirement: Устойчивость к перезапуску
Очередь генерации вариантов и сборка мусора SHALL восстанавливаться из состояния слоя
метаданных после перезапуска на обоих бэкендах, и система SHALL применять схему
метаданных при старте.
#### Scenario: Незавершённые задания
- **WHEN** процесс перезапускается с незавершёнными заданиями генерации вариантов
- **THEN** обработка заданий продолжается
#### Scenario: Применение схемы
- **WHEN** приложение стартует с выбранным бэкендом
- **THEN** схема метаданных выбранного бэкенда приведена к актуальному состоянию
@@ -0,0 +1,32 @@
# Tasks
## 1. Интерфейсы репозиториев и контрактный набор
- [x] 1.1 Ввести `Repository` в доменах auth, albums, members, photos, social, notifications, sharelinks и перевести сервисы на интерфейсы; проверка — `go build ./...` проходит, PostgreSQL-реализации не менялись.
- [x] 1.2 Поднять общий контрактный набор тестов на каждый `Repository`; проверка — набор зелёный на PostgreSQL под `TEST_DATABASE_URL` (пропуск без него).
- [x] 1.3 Зафиксировать маппинг sentinel-ошибок домена и `created_at` через `RETURNING` как часть контракта; проверка — тесты ошибок «не найдено» на Postgres проходят.
## 2. SQLite: драйвер, миграции, фабрика
- [x] 2.1 Добавить `modernc.org/sqlite` и подключение с `journal_mode=WAL`, `busy_timeout`, `foreign_keys=ON`; проверка — тест открытия, `Ping` и применения прагм проходит.
- [x] 2.2 Добавить набор `migrations/sqlite/` и `db.MigrateSQLite`; проверка — тест применяет схему на временном файле и повторный запуск идемпотентен.
- [x] 2.3 Реализовать фабрику `store.NewPostgres(pool)` / `store.NewSQLite(db)`, возвращающую бандл `Repository`; проверка — тест собирает бандл для обоих бэкендов и `go build ./...` проходит.
## 3. SQLite-реализации доменов
- [x] 3.1 Реализовать SQLite-store для auth, members, albums; проверка — контрактные тесты этих доменов зелёные на SQLite.
- [x] 3.2 Реализовать SQLite-store для photos (фото, метки, корзина, backfill EXIF); проверка — контрактные тесты photos зелёные на SQLite.
- [x] 3.3 Реализовать SQLite-логику объектов (`BindObject`, `ClaimGarbage`, `ClaimStalePending`, `ClaimVariantJobs`, `FailVariantJob`) в `BEGIN IMMEDIATE`; проверка — тесты на атомарность привязки/GC и на backoff зелёные.
- [x] 3.4 Реализовать SQLite-store для social, notifications, sharelinks; проверка — контрактные тесты этих доменов зелёные на SQLite.
## 4. Конфигурация, проводка, health, документация
- [x] 4.1 Ввести `DB_BACKEND=postgres|sqlite` и `SQLITE_PATH` с условной валидацией; проверка — юнит-тесты `config.Load` для обоих бэкендов и неполной конфигурации.
- [x] 4.2 Перевести `app.New` на бандл репозиториев и выбрать бэкенд в `cmd/server`; проверка — сервер стартует с обоими значениями `DB_BACKEND`, db-метка в health динамическая.
- [x] 4.3 Обновить `.env.example`, `project.md` и README с ограничением «один экземпляр на файл SQLite»; проверка — документированный запуск с `DB_BACKEND=sqlite` стартует.
## 5. Интеграционные проверки
- [x] 5.1 Прогнать полный контрактный набор на обоих бэкендах и сравнить результаты; проверка — одинаковые исходы на Postgres и SQLite.
- [x] 5.2 End-to-end на SQLite (регистрация → альбом → загрузка → превью → удаление → GC) с перезапуском процесса; проверка — очередь вариантов и GC продолжаются, сирот нет.
- [x] 5.3 Убедиться, что дефолт `postgres` не меняет текущее развёртывание; проверка — запуск без новых переменных проходит как раньше.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-11
@@ -0,0 +1,82 @@
# Design
## Context
См. `proposal.md` — Why. Текущее состояние, определяющее подход:
- `storage.Backend` умеет Put/Delete/Get/URL/ServeObject/Ping/EnsureBucket/Bucket, но не
умеет перечислять объекты.
- Сборка мусора (`photos.Worker`) управляется журналом `objects`: `ClaimGarbage` выбирает
строки без привязок и удаляет блоб. Блобы без строки в журнале воркеру невидимы.
- Ключи объектов — `objects/<object_id>/<role><ext>`, слой метаданных хранит их в
`objects.storage_key`.
- Есть два бэкенда хранения (`s3`, `fs`) и два бэкенда метаданных (`postgres`, `sqlite`)
за интерфейсами `storage.Backend` и доменными `Repository`.
## Goals / Non-Goals
**Goals:**
- Ручная сверка хранилища и журнала: инвентаризация обеих сторон, отчёт, безопасное удаление.
- Работа с любой комбинацией бэкендов хранения и метаданных.
- Безопасное поведение по умолчанию: ничего не удаляется без явного флага, свежие блобы
защищены.
**Non-Goals:**
- Автоматическая периодическая реконсиляция в воркере.
- Восстановление пропавших блобов (в журнале есть, в хранилище нет).
- Миграция блобов между бэкендами.
- Сверка префиксов, отличных от объектов.
## Decisions
### D1. Перечисление в `storage.Backend`
Добавляем `List(ctx context.Context, prefix string) ([]ObjectInfo, error)` где
`ObjectInfo{Key string; Size int64; ModTime time.Time}`.
- S3: `ListObjectsV2` с пагинацией по `ContinuationToken`, `Prefix`.
- ФС: обход корня (`filepath.WalkDir`), ключ — путь относительно корня, размер/время из
`os.Stat`.
- Альтернатива: не трогать интерфейс и добавить отдельный тип для реконсиляции — отвергнуто,
перечисление логично принадлежит бэкенду хранения.
### D2. Ключи журнала в метаданных
Добавляем `AllStorageKeys(ctx) ([]string, error)` в `photos.Repository`; реализации:
`SELECT storage_key FROM objects` (pg и sqlite). Контрактный набор покрывает метод.
### D3. Команда `cmd/reconcile`
Отдельная maintenance-команда, читает конфиг, открывает выбранные бэкенды
(`storage.Open` + бандл репозиториев), строит множества и печатает отчёт.
Флаги: `--delete` (bool), `--min-age` (duration, по умолчанию `1h`), `--sample` (число
примеров в отчёте). Область — фиксированный префикс `objects/`.
### D4. Классификация расхождений
- **Сироты** = ключи хранилища под `objects/`, которых нет в журнале; кандидаты на удаление.
- **Пропавшие** = ключи журнала, которых нет в хранилище; только отчёт.
- Сироты моложе `--min-age` не удаляются (защита от гонки с идущей загрузкой: блоб уже
записан, строка ещё не создана).
### D5. Безопасность
По умолчанию — только отчёт и нулевое воздействие. Удаление только по `--delete`, только
под `objects/`, только старше `--min-age`. Пропавшие никогда не «исправляются».
## Risks / Trade-offs
- [Гонка с загрузкой] → `--min-age` пропускает молодые блобы; рекомендуем запускать в
спокойный период.
- [Большие бакеты: память/время] → пагинация S3 и потоковый обход ФС; в отчёте лишь
примеры, не полный список.
- [Несогласованность формата ключей между бэкендами] → оба бэкенда отдают ключи
относительно корня/перфикса без ведущего слэша; контракт фиксируется тестами.
- [Удаление используемого блоба] → сверка выполняется вручную и в спокойный период; журнал
— источник истины, поэтому «сирота» по определению не имеет строки.
## Migration Plan
1. Добавить `List` в интерфейс — S3 и ФС реализуют его (компилятор требует оба).
2. Добавить `AllStorageKeys` в `photos.Repository` — обе реализации.
3. Добавить `cmd/reconcile` (аддитивно, ничего не меняет в работающем сервисе).
4. Данные не мигрируются; команда запускается вручную по необходимости.
## Open Questions
- Нужно ли со временем добавить восстановление пропавших блобов или отметку повреждённых
записей журнала.
@@ -0,0 +1,36 @@
# Proposal
## Why
Сборка мусора воркера управляется журналом `objects` и никогда не сканирует хранилище.
Поэтому блобы, у которых нет строки в журнале (после сброса БД в обход журнала,
исторических миграций или ошибок), накапливаются и не удаляются никогда: воркер их не
видит. Нужна ручная сверка «хранилище ∖ журнал».
## What Changes
- Добавить в `storage.Backend` перечисление объектов под префиксом (ключ, размер, время
изменения); реализовать для S3 (`ListObjectsV2`) и локальной ФС (обход каталога).
- Добавить в слой метаданных чтение всех ключей журнала `objects` (Postgres и SQLite).
- Добавить maintenance-команду `cmd/reconcile`: по умолчанию — только отчёт (сироты и
пропавшие), флаг `--delete` удаляет сироты с учётом минимального возраста
(`--min-age`), область ограничена префиксом `objects/`.
- Пропавшие ключи (есть в журнале, нет в хранилище) только сообщаются — автоматически не
«исправляются».
## Capabilities
### New Capabilities
- `storage-reconciliation`: ручная сверка объектного хранилища с журналом метаданных и удаление осиротевших блобов.
### Modified Capabilities
<!-- Нет изменений требований существующих спек: поведение системы для клиента не меняется, добавляется отдельная maintenance-операция. -->
## Impact
- `backend/internal/storage` (интерфейс + перечисление для S3 и ФС).
- `backend/internal/photos` (`Repository.AllStorageKeys`, реализации pg и sqlite).
- Новый `backend/cmd/reconcile`.
- `README.md`, `project.md`.
- Не меняет API и существующие спеки; дополняет `media-objects` ручной сверкой к
журнальной сборке мусора.
@@ -0,0 +1,65 @@
# Spec Delta
## Purpose
Ручная сверка объектного хранилища с журналом метаданных: инвентаризация обеих сторон,
отчёт о расхождениях и безопасное удаление осиротевших блобов.
## ADDED Requirements
### Requirement: Инвентаризация хранилища
Слой хранения SHALL перечислять объекты под заданным префиксом, возвращая ключ, размер и
время изменения, независимо от бэкенда (S3-совместимое или локальная ФС).
#### Scenario: Перечисление в S3-совместимом хранилище
- **WHEN** запрашивается список объектов под префиксом
- **THEN** возвращаются все ключи с этим префиксом с их размером и временем изменения
#### Scenario: Перечисление в локальной ФС
- **WHEN** хранилищем выбрана локальная ФС
- **THEN** возвращаются ключи всех блобов под префиксом с их размером и временем изменения
### Requirement: Инвентаризация журнала
Слой метаданных SHALL возвращать все ключи, зарегистрированные в журнале `objects`, на
обоих бэкендах (PostgreSQL и SQLite).
#### Scenario: Ключи журнала
- **WHEN** запрашиваются ключи журнала
- **THEN** возвращается множество ключей всех строк `objects`
### Requirement: Отчёт о расхождениях
По умолчанию сверка SHALL выполняться в режиме отчёта и SHALL не изменять хранилище;
отчёт SHALL различать сироты (в хранилище есть, в журнале нет) и пропавшие (в журнале
есть, в хранилище нет).
#### Scenario: Только отчёт
- **WHEN** сверка запущена без флага удаления
- **THEN** система выводит число и примеры сирот и пропавших, ничего не удаляя
#### Scenario: Пропавшие не восстанавливаются
- **WHEN** ключ есть в журнале, но отсутствует в хранилище
- **THEN** он сообщается как пропавший и не создаётся и не удаляется
### Requirement: Удаление сирот по явному запросу
Удаление сирот SHALL выполняться только по явному флагу, только для ключей под префиксом
объектов и только для блобов старше заданного минимального возраста.
#### Scenario: Удаление включено
- **WHEN** сверка запущена с флагом удаления
- **THEN** сироты старше минимального возраста удаляются из хранилища, а более молодые пропускаются
#### Scenario: Защита от гонки с загрузкой
- **WHEN** блоб создан недавно (моложе минимального возраста)
- **THEN** он не удаляется, даже если строки в журнале ещё нет
#### Scenario: Ограничение области
- **WHEN** удаление включено
- **THEN** удаляются только ключи под префиксом объектов, остальные не затрагиваются
### Requirement: Независимость от бэкендов
Сверка SHALL работать с любой комбинацией бэкенда хранения (S3 или ФС) и бэкенда
метаданных (PostgreSQL или SQLite).
#### Scenario: Комбинации бэкендов
- **WHEN** сверка запускается на выбранной конфигурации
- **THEN** она использует выбранные бэкенды хранения и метаданных без изменений кода
@@ -0,0 +1,21 @@
# Tasks
## 1. Перечисление в хранилище
- [x] 1.1 Добавить `List(ctx, prefix)` (`[]ObjectInfo{Key,Size,ModTime}`) в `storage.Backend` и реализовать в `storage.S3` через `ListObjectsV2` с пагинацией; проверка — тест перечисления под префиксом (под `S3_ENDPOINT`, иначе пропуск).
- [x] 1.2 Реализовать `List` в `storage.FS` обходом корня с относительными ключами; проверка — юнит-тест на временном каталоге.
## 2. Ключи журнала в метаданных
- [x] 2.1 Добавить `AllStorageKeys` в `photos.Repository` и реализовать в pg- и sqlite-store; проверка — контрактный тест на обоих бэкендах.
## 3. Команда reconcile
- [x] 3.1 Реализовать `cmd/reconcile`: инвентаризация обеих сторон, классификация сирот/пропавших, отчёт по умолчанию; проверка — юнит-тесты классификации.
- [x] 3.2 Добавить флаги `--delete`, `--min-age`, `--sample` с ограничением области `objects/` и защитой свежих блобов; проверка — тест: молодой блоб не удаляется, старый сирота удаляется, пропавшие не трогаются.
- [x] 3.3 Собрать бэкенды через конфиг (`storage.Open` + бандл репозиториев) для postgres/sqlite и s3/fs; проверка — запуск на fs и s3.
## 4. Документация и интеграция
- [x] 4.1 Обновить `README.md` и `project.md` с описанием команды и её безопасного использования; проверка — документированный запуск работает.
- [x] 4.2 Интеграционная проверка: создать сироту, прогнать отчёт, затем `--delete`, убедиться в удалении и в том, что журнальные записи не затронуты.
+12 -2
View File
@@ -144,6 +144,10 @@ PG_PASSWORD=change_me
PG_DATABASE=shared_album
PG_SSLMODE=disable
# Metadata backend
DB_BACKEND=sqlite # sqlite (default, embedded single instance) | postgres
SQLITE_PATH=./galo.db # path when DB_BACKEND=sqlite
# S3 (Garage) - внутреннее хранилище, клиенту не раскрывается
S3_ENDPOINT=http://s3.localnet:3900
S3_REGION=garage
@@ -153,8 +157,8 @@ S3_BUCKET=albums
S3_USE_PATH_STYLE=true
# Storage backend
STORAGE_BACKEND=s3 # s3 (default) | fs (local filesystem)
FS_ROOT=/var/lib/galo/objects # required when STORAGE_BACKEND=fs
STORAGE_BACKEND=fs # fs (default, local filesystem) | s3
FS_ROOT=./data # root directory for object blobs (fs)
URL_SECRET=change_me # signs object URLs; falls back to JWT_SECRET
OBJECT_URL_TTL=1h # lifetime of signed object URLs
@@ -384,6 +388,12 @@ colors: {
не выдаются; для ФС — чтение с диска. Переключение бэкенда не переносит существующие
блобы — перенос выполняется вручную.
**Реконсиляция хранилища.** Сборка мусора управляется журналом `objects` и не сканирует
хранилище, поэтому осиротевшие блобы (нет строки в журнале) не удаляются автоматически.
Ручная команда `cmd/reconcile` сверяет хранилище с журналом: по умолчанию только отчёт,
`--delete` удаляет сироты старше `--min-age` (по умолчанию 1h) в пределах префикса
`objects/`; пропавшие ключи только сообщаются.
## 11. API-эндпоинты
### Auth