Backend Phase A: foundation, migrations, Docker setup
- Go backend skeleton: config (caarlos0/env), slog JSON logging, pgxpool store, HTTP server with graceful shutdown. - httpapi: render helpers, Problem errors, middleware chain (requestID / logging / recover / CORS), /healthz and /readyz. - Migrations: full initial schema (users, sessions, lists, list_members, items, op_log SOURCE OF TRUTH, item_names) + golang-migrate runner binary using source/iofs (embedded). - Docker: multi-stage Dockerfile (Go 1.26 -> distroless nonroot), builds both server and migrate binaries. - deploy: docker-compose (caddy + backend + migrate + postgres:16), Caddyfile (auto-HTTPS), .env.example, pg extensions init script. - AGENTS.md: project context + roadmap for AI agents. Verified: image builds, both binaries run in container (smoke test).
This commit is contained in:
commit
2899eb205b
23 changed files with 1372 additions and 0 deletions
22
backend/.dockerignore
Normal file
22
backend/.dockerignore
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Build artifacts
|
||||
bin/
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# IDE / OS
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# Docs
|
||||
*.md
|
||||
33
backend/Dockerfile
Normal file
33
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---- build stage ----
|
||||
FROM golang:1.26-alpine AS builder
|
||||
WORKDIR /src
|
||||
|
||||
# Cache deps first.
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# Build both binaries statically (pgx speaks the wire protocol, no cgo needed).
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags="-s -w" -trimpath \
|
||||
-o /out/server ./cmd/server && \
|
||||
CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags="-s -w" -trimpath \
|
||||
-o /out/migrate ./cmd/migrate
|
||||
|
||||
# ---- runtime stage ----
|
||||
# distroless/static ships CA certificates (needed for OIDC JWKS over HTTPS)
|
||||
# and a nonroot user.
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
WORKDIR /app
|
||||
COPY --from=builder /out/server /app/server
|
||||
COPY --from=builder /out/migrate /app/migrate
|
||||
|
||||
# The API server runs by default. The one-shot migrate job overrides the
|
||||
# command in docker-compose to "/app/migrate up".
|
||||
USER nonroot:nonroot
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/app/server"]
|
||||
105
backend/cmd/migrate/main.go
Normal file
105
backend/cmd/migrate/main.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// Package main is the migration runner. It applies embedded SQL migrations to the
|
||||
// configured database. Intended to run as a one-shot container/job before the API.
|
||||
//
|
||||
// Usage: migrate [up|down|version|force <n>]
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
|
||||
"github.com/mitbringsl/backend/internal/config"
|
||||
"github.com/mitbringsl/backend/internal/logging"
|
||||
"github.com/mitbringsl/backend/migrations"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logging.Init(cfg.LogLevel)
|
||||
|
||||
cmd := "up"
|
||||
if len(os.Args) > 1 {
|
||||
cmd = os.Args[1]
|
||||
}
|
||||
|
||||
if err := run(cfg, cmd); err != nil {
|
||||
slog.Error("migration failed", "cmd", cmd, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(cfg *config.Config, cmd string) error {
|
||||
src, err := iofs.New(migrations.FS, ".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create source: %w", err)
|
||||
}
|
||||
|
||||
// Use the configured DATABASE_URL; golang-migrate accepts the same DSN
|
||||
// form pgx uses (postgres://...). Allow a short startup grace period.
|
||||
dbURL := cfg.DatabaseURL
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var m *migrate.Migrate
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 30; attempt++ {
|
||||
m, lastErr = migrate.NewWithSourceInstance("iofs", src, dbURL)
|
||||
if lastErr == nil {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("db not ready: %w", lastErr)
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return fmt.Errorf("create migrate instance: %w", lastErr)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
switch cmd {
|
||||
case "up":
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return err
|
||||
}
|
||||
slog.Info("migrations applied (up)")
|
||||
case "down":
|
||||
if err := m.Down(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return err
|
||||
}
|
||||
slog.Info("migrations applied (down)")
|
||||
case "version":
|
||||
v, dirty, err := m.Version()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("version=%d dirty=%v\n", v, dirty)
|
||||
case "force":
|
||||
if len(os.Args) < 3 {
|
||||
return errors.New("force requires a version argument")
|
||||
}
|
||||
var v int
|
||||
fmt.Sscanf(os.Args[2], "%d", &v)
|
||||
if err := m.Force(v); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("forced migration version", "version", v)
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q (use up|down|version|force)", cmd)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
87
backend/cmd/server/main.go
Normal file
87
backend/cmd/server/main.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// Package main is the entrypoint for the mitbringsl backend HTTP server.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/mitbringsl/backend/internal/config"
|
||||
"github.com/mitbringsl/backend/internal/httpapi"
|
||||
"github.com/mitbringsl/backend/internal/logging"
|
||||
"github.com/mitbringsl/backend/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
slog.Error("fatal error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logging.Init(cfg.LogLevel)
|
||||
slog.Info("starting mitbringsl backend",
|
||||
"env", cfg.AppEnv,
|
||||
"addr", cfg.HTTPAddr,
|
||||
"google_oidc_enabled", cfg.GoogleOIDC.Enabled,
|
||||
"generic_oidc_enabled", cfg.GenericOIDC.Enabled,
|
||||
)
|
||||
|
||||
rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
pool, err := store.New(rootCtx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer pool.Close()
|
||||
slog.Info("database connected")
|
||||
|
||||
api := httpapi.NewAPI(cfg, pool)
|
||||
srv := &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: api.Handler(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
slog.Info("http server listening", "addr", cfg.HTTPAddr)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-rootCtx.Done():
|
||||
slog.Info("shutdown signal received")
|
||||
case err := <-errCh:
|
||||
return err
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
slog.Error("graceful shutdown failed", "error", err)
|
||||
}
|
||||
slog.Info("bye")
|
||||
return nil
|
||||
}
|
||||
|
||||
// keep pgxpool import referenced even when API holds the pool directly (future-proof).
|
||||
var _ = (*pgxpool.Pool)(nil)
|
||||
21
backend/go.mod
Normal file
21
backend/go.mod
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
module github.com/mitbringsl/backend
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/caarlos0/env/v11 v11.4.1
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
)
|
||||
83
backend/go.sum
Normal file
83
backend/go.sum
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw=
|
||||
github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
|
||||
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
|
||||
github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
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/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
||||
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
||||
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
||||
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
||||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
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=
|
||||
70
backend/internal/config/config.go
Normal file
70
backend/internal/config/config.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// Package config holds all runtime configuration for the backend.
|
||||
// Values are parsed from environment variables via struct tags.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/caarlos0/env/v11"
|
||||
)
|
||||
|
||||
// Config is the single source of runtime configuration.
|
||||
type Config struct {
|
||||
// HTTPAddr is the address the HTTP server listens on, e.g. ":8080".
|
||||
HTTPAddr string `env:"HTTP_ADDR" envDefault:":8080"`
|
||||
|
||||
// AppEnv: "development" or "production".
|
||||
AppEnv string `env:"APP_ENV" envDefault:"development"`
|
||||
|
||||
// LogLevel: debug | info | warn | error.
|
||||
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
|
||||
|
||||
// PublicBaseURL is the externally reachable URL (scheme + host), no trailing slash.
|
||||
// Used e.g. for OIDC redirect URIs. Example: "https://mitbringsl.example.com".
|
||||
PublicBaseURL string `env:"PUBLIC_BASE_URL" envDefault:"http://localhost:8080"`
|
||||
|
||||
// Database
|
||||
DatabaseURL string `env:"DATABASE_URL,required"`
|
||||
DBMaxOpenConns int32 `env:"DB_MAX_OPEN_CONNS" envDefault:"25"`
|
||||
DBMaxIdleConns int32 `env:"DB_MAX_IDLE_CONNS" envDefault:"5"`
|
||||
DBMaxLifetime time.Duration `env:"DB_MAX_CONN_LIFETIME" envDefault:"30m"`
|
||||
|
||||
// Session token config (opaque tokens issued by this backend after login).
|
||||
SessionTokenBytes int `env:"SESSION_TOKEN_BYTES" envDefault:"32"` // 256-bit
|
||||
SessionTokenTTL time.Duration `env:"SESSION_TOKEN_TTL" envDefault:"720h"` // 30 days
|
||||
SessionCookieName string `env:"SESSION_COOKIE_NAME" envDefault:"mitbringsl_session"`
|
||||
|
||||
// OIDC providers. Both optional; enable per provider.
|
||||
GoogleOIDC GoogleOIDCConfig
|
||||
GenericOIDC GenericOIDCConfig
|
||||
|
||||
// CORS allowed origins (comma-separated). Empty = no CORS headers.
|
||||
CORSAllowedOrigins []string `env:"CORS_ALLOWED_ORIGINS" envSeparator:","`
|
||||
}
|
||||
|
||||
// GoogleOIDCConfig for "Sign in with Google".
|
||||
type GoogleOIDCConfig struct {
|
||||
Enabled bool `env:"OIDC_GOOGLE_ENABLED" envDefault:"false"`
|
||||
ClientID string `env:"OIDC_GOOGLE_CLIENT_ID"` // the audience the verifier accepts
|
||||
Issuer string `env:"OIDC_GOOGLE_ISSUER" envDefault:"https://accounts.google.com"`
|
||||
}
|
||||
|
||||
// GenericOIDCConfig for any standards-compliant OIDC IdP (Keycloak, Authentik, ...).
|
||||
type GenericOIDCConfig struct {
|
||||
Enabled bool `env:"OIDC_GENERIC_ENABLED" envDefault:"false"`
|
||||
Issuer string `env:"OIDC_GENERIC_ISSUER"`
|
||||
ClientID string `env:"OIDC_GENERIC_CLIENT_ID"`
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables and validates basic invariants.
|
||||
func Load() (*Config, error) {
|
||||
var cfg Config
|
||||
if err := env.Parse(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse env: %w", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// IsProduction reports whether the app runs in production mode.
|
||||
func (c *Config) IsProduction() bool { return c.AppEnv == "production" }
|
||||
56
backend/internal/httpapi/api.go
Normal file
56
backend/internal/httpapi/api.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/mitbringsl/backend/internal/config"
|
||||
)
|
||||
|
||||
// API bundles all handler groups and wires the router.
|
||||
// Handler groups are added in subsequent phases (auth, lists, items, suggestions).
|
||||
type API struct {
|
||||
cfg *config.Config
|
||||
pool *pgxpool.Pool
|
||||
|
||||
health *HealthHandler
|
||||
}
|
||||
|
||||
// NewAPI constructs the API with all handler groups.
|
||||
func NewAPI(cfg *config.Config, pool *pgxpool.Pool) *API {
|
||||
return &API{
|
||||
cfg: cfg,
|
||||
pool: pool,
|
||||
health: &HealthHandler{Pool: pool},
|
||||
}
|
||||
}
|
||||
|
||||
// Handler returns the fully wired http.Handler with all middleware applied.
|
||||
func (a *API) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// --- public health endpoints (no auth) ---
|
||||
mux.HandleFunc("GET /healthz", a.health.Healthz)
|
||||
mux.HandleFunc("GET /readyz", a.health.Readyz)
|
||||
|
||||
// --- auth endpoints (added in Phase B) ---
|
||||
// mux.HandleFunc("POST /auth/register", a.auth.Register)
|
||||
// mux.HandleFunc("POST /auth/login", a.auth.Login)
|
||||
// mux.HandleFunc("POST /auth/oidc", a.auth.OIDC)
|
||||
// mux.HandleFunc("POST /auth/logout", a.auth.Logout)
|
||||
|
||||
// --- authenticated API endpoints (added in Phase C) ---
|
||||
// mux.HandleFunc("GET /api/lists", a.requireAuth(a.lists.List))
|
||||
// mux.HandleFunc("GET /api/lists/{id}/ops", a.requireAuth(a.items.PullOps))
|
||||
// mux.HandleFunc("POST /api/lists/{id}/ops", a.requireAuth(a.items.PushOps))
|
||||
// mux.HandleFunc("GET /api/suggestions", a.requireAuth(a.suggest.Suggest))
|
||||
|
||||
return Chain(
|
||||
mux,
|
||||
requestIDMiddleware,
|
||||
loggingMiddleware,
|
||||
recoverMiddleware,
|
||||
corsMiddleware(a.cfg),
|
||||
)
|
||||
}
|
||||
28
backend/internal/httpapi/health.go
Normal file
28
backend/internal/httpapi/health.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// HealthHandler exposes /healthz (liveness) and /readyz (readiness + DB ping).
|
||||
type HealthHandler struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func (h *HealthHandler) Healthz(w http.ResponseWriter, r *http.Request) {
|
||||
renderJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *HealthHandler) Readyz(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := h.Pool.Ping(ctx); err != nil {
|
||||
renderError(w, http.StatusServiceUnavailable, "Database unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
renderJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||
}
|
||||
125
backend/internal/httpapi/middleware.go
Normal file
125
backend/internal/httpapi/middleware.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/mitbringsl/backend/internal/config"
|
||||
)
|
||||
|
||||
// ctxKey is an unexported key type for context values in this package.
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
ctxKeyRequestID ctxKey = iota
|
||||
ctxKeyUserID
|
||||
ctxKeySessionID
|
||||
)
|
||||
|
||||
type wrappedWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
bytes int
|
||||
}
|
||||
|
||||
func (w *wrappedWriter) WriteHeader(status int) {
|
||||
w.status = status
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (w *wrappedWriter) Write(b []byte) (int, error) {
|
||||
if w.status == 0 {
|
||||
w.status = http.StatusOK
|
||||
}
|
||||
n, err := w.ResponseWriter.Write(b)
|
||||
w.bytes += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
// requestIDMiddleware injects a random request id into the request context and response header.
|
||||
func requestIDMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.Header.Get("X-Request-ID")
|
||||
if id == "" {
|
||||
b := make([]byte, 8)
|
||||
_, _ = rand.Read(b)
|
||||
id = hex.EncodeToString(b)
|
||||
}
|
||||
w.Header().Set("X-Request-ID", id)
|
||||
ctx := context.WithValue(r.Context(), ctxKeyRequestID, id)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// loggingMiddleware logs each request as structured JSON.
|
||||
func loggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ww := &wrappedWriter{ResponseWriter: w}
|
||||
next.ServeHTTP(ww, r)
|
||||
slog.Info("http request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", ww.status,
|
||||
"bytes", ww.bytes,
|
||||
"request_id", r.Context().Value(ctxKeyRequestID),
|
||||
"remote", r.RemoteAddr,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// recoverMiddleware catches panics, logs them and returns 500.
|
||||
func recoverMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
slog.Error("panic recovered",
|
||||
"error", rec,
|
||||
"request_id", r.Context().Value(ctxKeyRequestID),
|
||||
"stack", string(debug.Stack()),
|
||||
)
|
||||
renderError(w, http.StatusInternalServerError, "Internal error", "An unexpected error occurred.")
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// corsMiddleware adds permissive-but-scoped CORS headers when configured.
|
||||
// Native clients (Android) don't need CORS; this only affects browsers.
|
||||
func corsMiddleware(cfg *config.Config) func(http.Handler) http.Handler {
|
||||
allowed := map[string]bool{}
|
||||
for _, o := range cfg.CORSAllowedOrigins {
|
||||
if o != "" {
|
||||
allowed[o] = true
|
||||
}
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" && allowed[origin] {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Chain wires middlewares in declaration order: first one runs outermost.
|
||||
func Chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
|
||||
for i := len(mws) - 1; i >= 0; i-- {
|
||||
h = mws[i](h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
78
backend/internal/httpapi/render.go
Normal file
78
backend/internal/httpapi/render.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// Package httpapi contains HTTP handlers, routing and middleware.
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Problem is a small RFC 7807-ish error body.
|
||||
type Problem struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Status int `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// renderJSON writes v as JSON with the given status code.
|
||||
func renderJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("write json response failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// renderError writes a Problem response.
|
||||
func renderError(w http.ResponseWriter, status int, title, detail string) {
|
||||
renderJSON(w, status, Problem{
|
||||
Title: title,
|
||||
Status: status,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
// apiError maps well-known errors to HTTP status codes. Returns true if handled.
|
||||
func apiError(w http.ResponseWriter, err error) bool {
|
||||
switch {
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
renderError(w, http.StatusNotFound, "Not found", "The requested resource does not exist.")
|
||||
case errors.Is(err, ErrUnauthorized):
|
||||
renderError(w, http.StatusUnauthorized, "Unauthorized", err.Error())
|
||||
case errors.Is(err, ErrForbidden):
|
||||
renderError(w, http.StatusForbidden, "Forbidden", err.Error())
|
||||
case errors.Is(err, ErrConflict):
|
||||
renderError(w, http.StatusConflict, "Conflict", err.Error())
|
||||
case errors.Is(err, ErrBadRequest):
|
||||
renderError(w, http.StatusBadRequest, "Bad request", err.Error())
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Sentinel domain errors mapped by apiError.
|
||||
var (
|
||||
ErrBadRequest = errors.New("bad request")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
ErrConflict = errors.New("conflict")
|
||||
)
|
||||
|
||||
// decodeJSON decodes r.Body into v. Returns false and writes an error on failure.
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
renderError(w, http.StatusBadRequest, "Invalid JSON", err.Error())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
25
backend/internal/logging/logging.go
Normal file
25
backend/internal/logging/logging.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Package logging configures the structured logger (log/slog).
|
||||
package logging
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Init installs a JSON handler writing to stdout at the given level.
|
||||
func Init(level string) {
|
||||
var lvl slog.Level
|
||||
switch strings.ToLower(level) {
|
||||
case "debug":
|
||||
lvl = slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
lvl = slog.LevelWarn
|
||||
case "error":
|
||||
lvl = slog.LevelError
|
||||
default:
|
||||
lvl = slog.LevelInfo
|
||||
}
|
||||
h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})
|
||||
slog.SetDefault(slog.New(h))
|
||||
}
|
||||
32
backend/internal/store/db.go
Normal file
32
backend/internal/store/db.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Package store wraps database access (pgxpool) for the backend.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/mitbringsl/backend/internal/config"
|
||||
)
|
||||
|
||||
// New creates and configures a pgx connection pool, then pings the DB to verify access.
|
||||
func New(ctx context.Context, cfg *config.Config) (*pgxpool.Pool, error) {
|
||||
pcfg, err := pgxpool.ParseConfig(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse database url: %w", err)
|
||||
}
|
||||
pcfg.MaxConns = cfg.DBMaxOpenConns
|
||||
pcfg.MinConns = cfg.DBMaxIdleConns
|
||||
pcfg.MaxConnLifetime = cfg.DBMaxLifetime
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, pcfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pool: %w", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping db: %w", err)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
8
backend/migrations/000001_init_schema.down.sql
Normal file
8
backend/migrations/000001_init_schema.down.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
-- Reverse of 000001.
|
||||
DROP TABLE IF EXISTS item_names;
|
||||
DROP TABLE IF EXISTS op_log;
|
||||
DROP TABLE IF EXISTS items;
|
||||
DROP TABLE IF EXISTS list_members;
|
||||
DROP TABLE IF EXISTS lists;
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
DROP TABLE IF EXISTS users;
|
||||
114
backend/migrations/000001_init_schema.up.sql
Normal file
114
backend/migrations/000001_init_schema.up.sql
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
-- Initial schema for mitbringsl.
|
||||
-- Design notes:
|
||||
-- * op_log is the append-only SOURCE OF TRUTH for sync.
|
||||
-- * users/lists/items are projections of op_log (kept up to date as ops arrive).
|
||||
-- * Tombstones (deleted_at) prevent resurrection of removed items by late offline ops.
|
||||
-- * (client_id, client_seq) unique key on op_log makes pushes idempotent.
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- users
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT, -- NULL for OIDC-only accounts
|
||||
oidc_subject TEXT, -- IdP subject id
|
||||
oidc_issuer TEXT, -- which IdP the subject belongs to
|
||||
display_name TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (oidc_issuer, oidc_subject)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- sessions (opaque tokens issued by this backend after login)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE, -- store SHA-256 hash, never the raw token
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
user_agent TEXT,
|
||||
ip TEXT
|
||||
);
|
||||
CREATE INDEX sessions_user_id_idx ON sessions(user_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- lists (projection)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE lists (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ, -- tombstone
|
||||
hlc_ts BIGINT NOT NULL DEFAULT 0,
|
||||
version BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX lists_owner_idx ON lists(owner_id) WHERE deleted_at IS NULL;
|
||||
|
||||
-- list_members (prepared for shared lists; MVP uses owner only)
|
||||
CREATE TABLE list_members (
|
||||
list_id UUID NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'member', -- 'owner' | 'member'
|
||||
PRIMARY KEY (list_id, user_id)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- items (projection)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
list_id UUID NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
quantity TEXT,
|
||||
checked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_order INTEGER,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ, -- tombstone
|
||||
hlc_ts BIGINT NOT NULL DEFAULT 0,
|
||||
client_id UUID,
|
||||
checked_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX items_list_idx ON items(list_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX items_checked_idx ON items(list_id, checked) WHERE deleted_at IS NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- op_log (SOURCE OF TRUTH)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE op_log (
|
||||
seq BIGSERIAL PRIMARY KEY, -- monotonic server cursor
|
||||
list_id UUID NOT NULL, -- list the op belongs to (FK added after lists exist; keep loose for tombstone cleanup)
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
client_id UUID NOT NULL,
|
||||
op_type TEXT NOT NULL, -- list_create|list_rename|list_delete
|
||||
-- item_add|item_update|item_remove
|
||||
target_id UUID NOT NULL, -- affected list/item id
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
client_seq BIGINT NOT NULL, -- idempotency key part
|
||||
hlc_ts BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (client_id, client_seq)
|
||||
);
|
||||
CREATE INDEX op_log_list_seq_idx ON op_log(list_id, seq);
|
||||
CREATE INDEX op_log_target_idx ON op_log(target_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- item_names (autocomplete aggregation, all users)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE item_names (
|
||||
name TEXT PRIMARY KEY, -- lowercased
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX item_names_trgm_idx ON item_names USING gin (name gin_trgm_ops);
|
||||
CREATE INDEX item_names_count_idx ON item_names(usage_count DESC);
|
||||
9
backend/migrations/embed.go
Normal file
9
backend/migrations/embed.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Package migrations embeds the SQL migration files for use by the migrate binary.
|
||||
package migrations
|
||||
|
||||
import "embed"
|
||||
|
||||
// FS holds all migration files (*.sql) under this directory.
|
||||
//
|
||||
//go:embed *.sql
|
||||
var FS embed.FS
|
||||
Loading…
Add table
Add a link
Reference in a new issue