diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa6a5dd..60da712 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,3 +64,59 @@ jobs: - name: go test run: go test ./... + + # The same suite against PostgreSQL. Separate job rather than a matrix on `check` + # because only the Go half is engine-dependent: svelte-check and pnpm build would + # run twice for no reason. The frontend build is still needed here, since the Go + # binary go:embeds frontend/build and `go build` fails without it. + postgres: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_PASSWORD: calnode_ci + POSTGRES_DB: calnode + ports: + - 5432:5432 + # Without a health check the first connection races the server's startup, + # which fails as "connection refused" and reads like a bad DSN. + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10.32.1 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + + - name: Install frontend deps + working-directory: frontend + run: pnpm install --frozen-lockfile + + - name: Build frontend (required by the Go embed) + working-directory: frontend + run: pnpm build + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + # CALNODE_TEST_POSTGRES_DSN is what switches internal/dbtest onto Postgres. + # Unset — which is every other job and every local run — the suite uses + # in-memory SQLite exactly as before, so this job is additive: it cannot change + # what a contributor sees. + - name: go test (PostgreSQL) + env: + CALNODE_TEST_POSTGRES_DSN: postgres://postgres:calnode_ci@127.0.0.1:5432/calnode?sslmode=disable + run: go test ./... diff --git a/DEPLOY.md b/DEPLOY.md index e5e6297..41f2567 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -27,7 +27,9 @@ This guide covers a generic Docker deploy and a step-by-step **Railway** deploy | `CALNODE_RECOVERY_SECRET` | recommended | — | Escrow secret so the data key can be recovered if the encryption key is rotated/lost. Store it somewhere separate. | | `BASE_URL` | **yes (prod)** | `http://localhost:3000` | Identity host — admin UI, OAuth callbacks, invite links. **Must include the scheme** (`https://booking.example.com`). The `https://` prefix flips the app into production mode (secure cookies, encryption-key enforcement). | | `PUBLIC_BASE_URL` | no | = `BASE_URL` | Booker-facing host for booking links/emails, if different from the identity host. | -| `DATABASE_URL` | no | `sqlite://./data/calnode.db` | Point at the persistent volume, e.g. `sqlite:///data/calnode.db`. | +| `DATABASE_URL` | no | `sqlite://./data/calnode.db` | Point at the persistent volume, e.g. `sqlite:///data/calnode.db`. A `postgres://user:pass@host:5432/dbname` URL selects PostgreSQL instead; anything else is SQLite. | +| `DB_MAX_OPEN_CONNS` | no | `10` | **PostgreSQL only.** Size of the connection pool. It has to fit inside the server's own `max_connections`, shared with every other client — raise it for a busy instance on a well-sized server, lower it behind PgBouncer or on a shared one. Must be a positive integer; anything else is ignored (with a warning) and the default stands. **Ignored on SQLite, which is always 1**: the single connection is what serialises write transactions, not a tuning choice. | +| `DB_MAX_IDLE_CONNS` | no | `5` | **PostgreSQL only.** How many idle connections the pool keeps rather than closing. Positive integer, and capped at `DB_MAX_OPEN_CONNS` (a larger value is clamped, since `database/sql` would silently do the same). | | `PORT` | no | `3000` | The app listens on `$PORT`. Many platforms inject their own (Railway injects `8080`) — let them. | | `EMAIL_SMTP_HOST` / `_PORT` / `_USER` / `_PASS` | no¹ | — / `587` | SMTP. Can also be set later in Settings → Email (DB-stored, encrypted). | | `EMAIL_SMTP_TLS` / `_STARTTLS` | no | `false` | `STARTTLS` for 587, implicit `TLS` for 465. | diff --git a/cmd/calnode/main.go b/cmd/calnode/main.go index 29a38aa..7f94db3 100644 --- a/cmd/calnode/main.go +++ b/cmd/calnode/main.go @@ -59,14 +59,14 @@ func main() { slog.Warn("Google OAuth NOT configured — GOOGLE_CLIENT_ID is empty") } - database, err := db.Open(cfg.DatabaseURL) + database, err := db.OpenDB(cfg.DatabaseURL) if err != nil { logger.Error("failed to open database", "error", err) os.Exit(1) } defer database.Close() - if err := db.Migrate(database); err != nil { + if err := database.Migrate(); err != nil { logger.Error("failed to run migrations", "error", err) os.Exit(1) } diff --git a/cmd/calnode/mcp.go b/cmd/calnode/mcp.go index aa6984d..9eca8a2 100644 --- a/cmd/calnode/mcp.go +++ b/cmd/calnode/mcp.go @@ -30,14 +30,14 @@ func runMCPStdio(_ []string) { logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: cfg.LogLevel})) slog.SetDefault(logger) - database, err := db.Open(cfg.DatabaseURL) + database, err := db.OpenDB(cfg.DatabaseURL) if err != nil { logger.Error("mcp: failed to open database", "error", err) os.Exit(1) } defer database.Close() - if err := db.Migrate(database); err != nil { + if err := database.Migrate(); err != nil { logger.Error("mcp: failed to run migrations", "error", err) os.Exit(1) } diff --git a/cmd/calnode/recover_key.go b/cmd/calnode/recover_key.go index ebd285f..a7acf51 100644 --- a/cmd/calnode/recover_key.go +++ b/cmd/calnode/recover_key.go @@ -36,7 +36,7 @@ func runRecoverKey(args []string) { dbURL = "sqlite://./data/calnode.db" } - database, err := db.Open(dbURL) + database, err := db.OpenDB(dbURL) if err != nil { fmt.Fprintf(os.Stderr, "error: open database: %v\n", err) os.Exit(1) diff --git a/cmd/calnode/reset_admin.go b/cmd/calnode/reset_admin.go index 03495b4..4f2a2a7 100644 --- a/cmd/calnode/reset_admin.go +++ b/cmd/calnode/reset_admin.go @@ -33,7 +33,7 @@ func runResetAdmin(args []string) { cfg := config.Load() - database, err := db.Open(cfg.DatabaseURL) + database, err := db.OpenDB(cfg.DatabaseURL) if err != nil { fmt.Fprintf(os.Stderr, "error: failed to open database: %v\n", err) os.Exit(1) diff --git a/cmd/calnode/rotate_key.go b/cmd/calnode/rotate_key.go index f1d1085..000fb9c 100644 --- a/cmd/calnode/rotate_key.go +++ b/cmd/calnode/rotate_key.go @@ -37,7 +37,7 @@ func runRotateKey(args []string) { dbURL = "sqlite://./data/calnode.db" } - database, err := db.Open(dbURL) + database, err := db.OpenDB(dbURL) if err != nil { fmt.Fprintf(os.Stderr, "error: open database: %v\n", err) os.Exit(1) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e81e86c..84ea4a8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -67,15 +67,32 @@ app you must `pnpm build` in `frontend/` **and** rebuild/restart the Go binary --- -## 4. Persistence (SQLite) — and the single-connection rule - -- `internal/db`: opens SQLite with **`SetMaxOpenConns(1)`** + `SetMaxIdleConns(1)`, - **WAL** journal mode, `busy_timeout=5000`. One writer connection by design. -- Migrations: **goose** SQL files in `internal/db/migrations/` (00001→00029). Run - automatically on startup. `ALTER TABLE ADD COLUMN` is reversible-by-convention - only (SQLite can't easily drop columns). - -### ⚠️ The single-connection gotcha (bit us once) +## 4. Persistence (SQLite or PostgreSQL) — and the single-connection rule + +- `internal/db`: `OpenDB(DATABASE_URL)` picks the engine from the URL scheme. A + `postgres://` URL selects PostgreSQL; everything else (`sqlite://./rel`, + `sqlite:///abs`, `:memory:`, a bare path) selects SQLite, so every configuration + that worked before still works unchanged. +- SQLite: **`SetMaxOpenConns(1)`** + `SetMaxIdleConns(1)`, **WAL** journal mode, + `busy_timeout=5000`. One writer connection by design. +- PostgreSQL: a normal pool (10 open / 5 idle). The single connection above is a + SQLite constraint, not a Calnode design choice, and carrying it over would + serialise the whole instance on an engine with its own concurrency control. +- The handle rebinds placeholders: Calnode's SQL is written once with `?` and + rewritten to `$1…$n` on PostgreSQL. ⚠️ `db.DB` embeds `*sql.DB` and the field is + exported, so `h.DB.Query(...)` compiles and **skips rebinding** — it passes on + SQLite and fails on PostgreSQL with a syntax error far from the edit. Call the + wrapper's own methods. The handful of statements no single spelling covers use + `Dialect.SQL(sqlite, postgres)`. +- Timestamps are computed in Go (`internal/dbtime`) rather than by the engine; + `datetime('now')` and `strftime` do not exist in PostgreSQL. `dbtime` keeps the + two layouts the schema already stores, byte-identical to what SQLite wrote. +- Migrations: **goose** SQL files in `internal/db/migrations/{sqlite,postgres}/` — + one schema, two spellings, same version numbers. Run automatically on startup. + `ALTER TABLE ADD COLUMN` is reversible-by-convention only (SQLite can't easily + drop columns). + +### ⚠️ The single-connection gotcha (bit us once) — SQLite only With one connection, **never run a query while a `rows` cursor from the same pool is still open** (i.e. inside a `for rows.Next()` loop). The open cursor holds the @@ -85,9 +102,32 @@ exceeded` (not "database is locked"). **Pattern:** read the cursor fully into a slice, close it, then loop. See `Handler.assignedHosts`, the calendar reconciler, `Reschedule`. (Memory: `sqlite-single-connection`.) -Bonus property: because booking transactions serialize on the single connection, -the app-level overlap check reliably guards **all** hosts (not just the one the -partial unique index covers) — no TOCTOU between concurrent bookings. +This is a consequence of the single connection, so it does not apply on +PostgreSQL — but the materialise-first pattern must stay, because it is the only +thing keeping those three paths working on SQLite. + +### The double-booking guarantee, per engine + +The app-level overlap check reads "is this host free?" and then writes on the +answer. What keeps that free of TOCTOU races differs: + +- **SQLite** — booking transactions serialize on the single connection, so the + check reliably guards **all** hosts, not just the one the partial unique index + covers. +- **PostgreSQL** — the pool has many connections, so two overlapping bookings could + both clear the check. `booking.lockHosts` takes **`pg_advisory_xact_lock`** on + each host id before the first overlap read, in `Create`, `Reschedule` and + `ReassignHost`. The key is SHA-256 of `"calnode:booking:host:" + hostID`, first + eight bytes as an int64; ids are locked in sorted order so two transactions + needing the same pair cannot deadlock. The lock ends with the transaction, so + there is nothing to release. On SQLite it is a no-op. + +Both engines also carry the partial unique index +`idx_bookings_no_double (host_id, start_at) WHERE status != 'cancelled'`. It catches +an **identical** start time and nothing else — a partial overlap is two distinct +keys — which is why the app-level check and the lock exist and why all three stay. +Measured on PostgreSQL, 40 races between overlapping-but-not-identical slots: with +the lock, 0 double bookings; without it, 39, of which the index caught one. ### Data model (key tables) @@ -264,7 +304,7 @@ members. mode (role-tagged), then loads each host's availability **concurrently** (goroutines) — the slow part is one Google free/busy round-trip per host, so parallelizing turns N sequential calls into ~one call's latency. DB queries - serialize on the single connection (fast); only the network overlaps. Response + serialize on SQLite's single connection (fast); only the network overlaps. Response includes a `hosts` metadata map (id→name/avatar) for rendering faces. - **Busy** for a host = every non-cancelled booking they attend, via `booking_hosts` (NOT just `bookings.host_id`) — so a non-primary Group/fixed seat @@ -704,7 +744,9 @@ as the desired state: ## 17. Cross-cutting gotchas (read before editing) 1. **SQLite single connection** — never query inside an open cursor; materialize - first (§4). + first (§4). Harmless on PostgreSQL, but the pattern stays either way. + On PostgreSQL the same single connection is also what the booking overlap check + loses, which `pg_advisory_xact_lock` replaces (§4). 2. **All times UTC** in storage; convert at the edges. The slot busy-window must be widened for tz boundaries. 3. **Frontend is embedded at compile time** — `pnpm build` + rebuild Go to see diff --git a/go.mod b/go.mod index 8b8a9a9..73b76fd 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ toolchain go1.26.6 require ( github.com/disintegration/imaging v1.6.2 + github.com/jackc/pgx/v5 v5.10.0 github.com/joho/godotenv v1.5.1 github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/pressly/goose/v3 v3.27.1 @@ -22,6 +23,9 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/mattn/go-isatty v0.0.21 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect diff --git a/go.sum b/go.sum index 5dc4a22..73978d1 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,6 @@ cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +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/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= @@ -18,6 +19,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= @@ -40,6 +49,9 @@ github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfv github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/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= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= @@ -66,6 +78,8 @@ golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +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= modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc= diff --git a/internal/booking/concurrency_test.go b/internal/booking/concurrency_test.go new file mode 100644 index 0000000..075cf64 --- /dev/null +++ b/internal/booking/concurrency_test.go @@ -0,0 +1,93 @@ +package booking_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/calnode/calnode/internal/booking" + "github.com/calnode/calnode/internal/dbtest" +) + +// TestCreate_concurrentPartialOverlap_postgres is the test for the guarantee +// pg_advisory_xact_lock replaces. +// +// The two slots deliberately OVERLAP without SHARING a start time. That is the case +// no index catches: idx_bookings_no_double is UNIQUE(host_id, start_at), so 10:00 +// and 10:15 are two distinct keys and both inserts satisfy it. The only thing +// standing between them is hostBusy's read, and on a multi-connection pool two +// transactions can both take that read before either writes. If the lock is not +// held, this test double-books. +// +// It is skipped on SQLite, where the race is not reachable: db.SetMaxOpenConns(1) +// means the second transaction cannot begin until the first has committed. +func TestCreate_concurrentPartialOverlap_postgres(t *testing.T) { + database := dbtest.RequirePostgres(t) + svc := booking.New(database) + hostID := seedHost(t, database) + etID := seedEventType(t, database, hostID) + + // Enough rounds that a lost race is very unlikely to go unseen. One round is + // not evidence: two goroutines miss each other's window often enough that an + // unlocked build passes a single round most of the time. + const rounds = 40 + + for round := 0; round < rounds; round++ { + // A fresh, non-overlapping window per round, so a round is independent of + // every earlier round's surviving booking. + base := slot(0, 0).Add(time.Duration(round) * time.Hour) + first := [2]time.Time{base, base.Add(30 * time.Minute)} + second := [2]time.Time{base.Add(15 * time.Minute), base.Add(45 * time.Minute)} + + var wg sync.WaitGroup + errs := make([]error, 2) + start := make(chan struct{}) + for i, window := range [2][2]time.Time{first, second} { + wg.Add(1) + go func(i int, from, to time.Time) { + defer wg.Done() + <-start // line both up so the transactions truly overlap + _, errs[i] = svc.Create(context.Background(), booking.CreateParams{ + EventTypeID: etID, + HostIDs: []string{hostID}, + StartAt: from, + EndAt: to, + Organizer: booking.Attendee{ + Name: "Alice", + Email: "alice@example.com", + }, + }) + }(i, window[0], window[1]) + } + close(start) + wg.Wait() + + var created int + for i, err := range errs { + switch { + case err == nil: + created++ + case errors.Is(err, booking.ErrDoubleBooked): + // the expected loser + default: + t.Fatalf("round %d: booking %d: unexpected error: %v", round, i, err) + } + } + if created != 1 { + t.Fatalf("round %d: %d of 2 overlapping bookings were created; want exactly 1 (errors: %v, %v)", + round, created, errs[0], errs[1]) + } + } + + // And the database agrees: one booking per round, never two in a window. + var n int + if err := database.QueryRow( + `SELECT COUNT(*) FROM bookings WHERE host_id = ? AND status != 'cancelled'`, hostID).Scan(&n); err != nil { + t.Fatalf("count bookings: %v", err) + } + if n != rounds { + t.Errorf("bookings for host = %d; want %d (one per round)", n, rounds) + } +} diff --git a/internal/booking/hostlock.go b/internal/booking/hostlock.go new file mode 100644 index 0000000..678cc10 --- /dev/null +++ b/internal/booking/hostlock.go @@ -0,0 +1,78 @@ +package booking + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "fmt" + "slices" + + "github.com/calnode/calnode/internal/db" +) + +// hostLockDomain prefixes every id fed to the hash below, so a future advisory +// lock on some other entity cannot collide with a host's key by hashing the same +// raw string. +const hostLockDomain = "calnode:booking:host:" + +// lockHosts serialises the check-then-write window for a set of hosts, for the +// remainder of tx. +// +// Create, Reschedule and ReassignHost all read "is this host busy at this time?" +// and then write on the answer. On SQLite that is free of TOCTOU races without any +// locking, because the pool is a single connection (db.SetMaxOpenConns(1), see +// internal/db/db.go) and transactions therefore cannot interleave at all. A +// Postgres pool has many connections: two overlapping bookings can both read +// "free" and both insert, and the partial unique index +// idx_bookings_no_double(host_id, start_at) only catches an identical start time, +// not a partial overlap. That is exactly the gap the app-level check was never +// asked to close on its own. +// +// pg_advisory_xact_lock is held until the transaction commits or rolls back, so +// there is no unlock call to forget and no leak when a caller returns early — which +// every guard in these functions does. Locking per host rather than once globally +// keeps bookings for different hosts concurrent, which is the whole point of moving +// off the single connection. +// +// On SQLite this is a no-op and the existing guarantee stands unchanged. +func lockHosts(ctx context.Context, tx *db.Tx, hostIDs ...string) error { + if tx.Dialect() != db.DialectPostgres { + return nil + } + + // Sorted and deduplicated. Two transactions that needed the same two hosts in + // opposite orders would otherwise deadlock, and Postgres resolves a deadlock by + // killing one of them — a 500 on a booking that should have been a 409 or a + // success. Sorting is done on a copy: Create's HostIDs arrive in round-robin + // priority order and that order decides who gets the booking. + ids := slices.Clone(hostIDs) + slices.Sort(ids) + ids = slices.Compact(ids) + + for _, id := range ids { + if id == "" { + continue + } + if _, err := tx.ExecContext(ctx, + `SELECT pg_advisory_xact_lock(?)`, hostLockKey(id)); err != nil { + return fmt.Errorf("booking: lock host %s: %w", id, err) + } + } + return nil +} + +// hostLockKey maps a host id onto the single int64 key pg_advisory_xact_lock takes. +// +// SHA-256 of the domain-separated id, with the first eight bytes read big-endian as +// a signed integer. Deriving it in Go rather than with the engine's hashtext() keeps +// the derivation readable and testable from Go, and means the value arrives as an +// ordinary bound parameter. +// +// Two distinct hosts landing on the same key would cost an unnecessary +// serialisation between two unrelated bookings, never a wrong answer, so what this +// needs is stability and a good spread rather than cryptographic strength. SHA-256 +// is used because this package already imports it for manage tokens. +func hostLockKey(hostID string) int64 { + sum := sha256.Sum256([]byte(hostLockDomain + hostID)) + return int64(binary.BigEndian.Uint64(sum[:8])) +} diff --git a/internal/booking/hostlock_internal_test.go b/internal/booking/hostlock_internal_test.go new file mode 100644 index 0000000..eb8fce0 --- /dev/null +++ b/internal/booking/hostlock_internal_test.go @@ -0,0 +1,49 @@ +package booking + +import ( + "testing" + + "github.com/calnode/calnode/internal/db" +) + +// TestHostLockKey pins the derivation. The key must not move between processes or +// releases: two Calnode instances sharing one Postgres have to derive the same key +// for the same host, or they take different locks and serialise against nothing. +// +// The expected values were computed independently of this code (SHA-256 of +// "calnode:booking:host:" + id, first eight bytes big-endian as a signed integer), +// so this fails if the implementation drifts rather than agreeing with itself. +func TestHostLockKey(t *testing.T) { + cases := map[string]int64{ + "host-42": 7118598067704648523, + "host-43": 2949734679630933584, + } + for id, want := range cases { + if got := hostLockKey(id); got != want { + t.Errorf("hostLockKey(%q) = %d; want %d — the derivation changed, which desynchronises running instances", + id, got, want) + } + } +} + +// TestLockHosts_sqliteIsNoOp records the other half of the design: SQLite already +// has the guarantee, so the lock must not be attempted there. pg_advisory_xact_lock +// does not exist in SQLite, so if this ever started issuing the statement the error +// would be immediate. +func TestLockHosts_sqliteIsNoOp(t *testing.T) { + h, err := db.OpenDB("sqlite://:memory:") + if err != nil { + t.Fatalf("open: %v", err) + } + defer h.Close() + + tx, err := h.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + defer tx.Rollback() //nolint:errcheck + + if err := lockHosts(t.Context(), tx, "a", "b", "a", ""); err != nil { + t.Errorf("lockHosts on SQLite returned %v; want nil (it must be a no-op there)", err) + } +} diff --git a/internal/booking/service.go b/internal/booking/service.go index da604f9..2695e62 100644 --- a/internal/booking/service.go +++ b/internal/booking/service.go @@ -13,16 +13,17 @@ import ( "strings" "time" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/uid" ) // Service handles booking creation and lifecycle. type Service struct { - db *sql.DB + db *db.DB } // New returns a Service backed by db. -func New(db *sql.DB) *Service { +func New(db *db.DB) *Service { return &Service{db: db} } @@ -45,6 +46,17 @@ func (s *Service) Create(ctx context.Context, p CreateParams) (*Booking, error) } defer tx.Rollback() //nolint:errcheck + // Before the first overlap read: every host whose availability this + // transaction is about to decide on. See lockHosts — on SQLite it does + // nothing, on Postgres it is what makes the checks below race-free. + lockIDs := make([]string, 0, len(p.HostIDs)+len(p.RequiredHosts)+len(p.OptionalHosts)) + lockIDs = append(lockIDs, p.HostIDs...) + lockIDs = append(lockIDs, p.RequiredHosts...) + lockIDs = append(lockIDs, p.OptionalHosts...) + if err := lockHosts(ctx, tx, lockIDs...); err != nil { + return nil, err + } + now := time.Now().UTC().Format(time.RFC3339Nano) // Select hosts. Round-robin picks one *free* candidate from the rotation pool @@ -124,7 +136,7 @@ func (s *Service) Create(ctx context.Context, p CreateParams) (*Booking, error) SELECT COUNT(*) FROM bookings b JOIN booking_attendees a ON a.booking_id = b.id AND a.is_organizer = 1 WHERE b.event_type_id = ? AND b.status != 'cancelled' - AND b.end_at > ? AND a.email = ? COLLATE NOCASE`, + AND b.end_at > ? AND LOWER(a.email) = LOWER(?)`, p.EventTypeID, now, p.Organizer.Email).Scan(&active); err != nil { return nil, fmt.Errorf("booking: active-limit check: %w", err) } @@ -279,7 +291,7 @@ const bookingColumns = `id, event_type_id, host_id, start_at, end_at, status, // than matching bookings.host_id (which would miss a Group/fixed-host attendee). // excludeBookingID excludes the booking being modified from its own overlap check // (Reschedule/ReassignHost); pass "" when there's no booking yet to exclude (Create). -func hostBusy(ctx context.Context, tx *sql.Tx, hostID, start, end, excludeBookingID string) (bool, error) { +func hostBusy(ctx context.Context, tx *db.Tx, hostID, start, end, excludeBookingID string) (bool, error) { var n int err := tx.QueryRowContext(ctx, ` SELECT COUNT(*) FROM bookings b @@ -377,7 +389,7 @@ func (s *Service) ValidateManageToken(ctx context.Context, rawToken string) (*Bo // be in priority order (lowest priority number first). "priority" takes the first // free host; "even" (and "soonest", which has no meaning once the slot is fixed) // take the least-loaded one. -func pickRotationHost(ctx context.Context, tx *sql.Tx, eventTypeID, strategy string, free []string, now string) (string, error) { +func pickRotationHost(ctx context.Context, tx *db.Tx, eventTypeID, strategy string, free []string, now string) (string, error) { if strategy == "priority" { return free[0], nil } @@ -387,7 +399,7 @@ func pickRotationHost(ctx context.Context, tx *sql.Tx, eventTypeID, strategy str // leastLoadedHost returns the candidate with the fewest upcoming (non-cancelled, // not-yet-ended) bookings for this event type — even-distribution round-robin. // Ties are broken by the order of candidates (caller passes them in priority order). -func leastLoadedHost(ctx context.Context, tx *sql.Tx, eventTypeID string, candidates []string, now string) (string, error) { +func leastLoadedHost(ctx context.Context, tx *db.Tx, eventTypeID string, candidates []string, now string) (string, error) { ph := make([]string, len(candidates)) args := make([]any, 0, len(candidates)+2) args = append(args, eventTypeID, now) @@ -449,6 +461,15 @@ func (s *Service) Reschedule(ctx context.Context, bookingID string, newStart, ne return nil, ErrAlreadyCancelled } + // The primary host is locked before the host list is read, not after. A + // concurrent ReassignHost locks the booking's current primary too, so holding it + // here is what stops that reassignment committing between this read of + // booking_hosts and the UPDATE below — which would move the booking to a host + // whose availability was never checked. + if err := lockHosts(ctx, tx, b.HostID); err != nil { + return nil, err + } + // Every host on this booking keeps their seat through a reschedule, so each // must be free at the new time — not just the primary. Read the host list // fully before the per-host overlap queries (single-connection pool). @@ -469,6 +490,12 @@ func (s *Service) Reschedule(ctx context.Context, bookingID string, newStart, ne if len(hostIDs) == 0 { // legacy booking with no booking_hosts rows hostIDs = []string{b.HostID} } + // Now the rest of the seat holders, still before any overlap check. Re-locking + // the primary is free: an advisory lock already held by this transaction is + // re-entrant and is released once, at commit. + if err := lockHosts(ctx, tx, hostIDs...); err != nil { + return nil, err + } for _, hid := range hostIDs { busy, err := hostBusy(ctx, tx, hid, startStr, endStr, bookingID) if err != nil { @@ -527,6 +554,13 @@ func (s *Service) ReassignHost(ctx context.Context, bookingID, newHostID string) startStr := b.StartAt.UTC().Format(time.RFC3339Nano) endStr := b.EndAt.UTC().Format(time.RFC3339Nano) + // Both hosts: the new one because its availability is being decided, the old + // one because a concurrent Reschedule of this booking holds that same key and + // must not interleave with the host change. + if err := lockHosts(ctx, tx, b.HostID, newHostID); err != nil { + return nil, err + } + // The new host must be free at this time across everything they attend. busy, err := hostBusy(ctx, tx, newHostID, startStr, endStr, bookingID) if err != nil { @@ -652,7 +686,11 @@ func scanBooking(s scanner) (*Booking, error) { return &b, nil } -// isUniqueViolation reports whether err is a SQLite UNIQUE constraint failure. -func isUniqueViolation(err error) bool { - return strings.Contains(err.Error(), "UNIQUE constraint failed") -} +// isUniqueViolation reports whether err is a unique-constraint violation — on this +// booking path, idx_bookings_no_double rejecting an exact start-time collision that +// the app-level overlap check did not catch. +// +// A thin wrapper over db.IsUniqueViolation, kept only because three call sites read +// better with the local name and the doc comment above belongs to this path rather +// than to the shared helper. +func isUniqueViolation(err error) bool { return db.IsUniqueViolation(err) } diff --git a/internal/booking/service_test.go b/internal/booking/service_test.go index 9f656a0..18618a5 100644 --- a/internal/booking/service_test.go +++ b/internal/booking/service_test.go @@ -2,29 +2,22 @@ package booking_test import ( "context" - "database/sql" "testing" "time" "github.com/calnode/calnode/internal/booking" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" "github.com/calnode/calnode/internal/uid" ) -func newTestDB(t *testing.T) *sql.DB { +func newTestDB(t *testing.T) *db.DB { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("open test db: %v", err) - } - t.Cleanup(func() { database.Close() }) - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate test db: %v", err) - } + database := dbtest.Open(t) return database } -func seedHost(t *testing.T, database *sql.DB) string { +func seedHost(t *testing.T, database *db.DB) string { t.Helper() id := uid.New() _, err := database.ExecContext(context.Background(), ` @@ -37,7 +30,7 @@ func seedHost(t *testing.T, database *sql.DB) string { return id } -func seedEventType(t *testing.T, database *sql.DB, userID string) string { +func seedEventType(t *testing.T, database *db.DB, userID string) string { t.Helper() id := uid.New() _, err := database.ExecContext(context.Background(), ` diff --git a/internal/caldav/caldav.go b/internal/caldav/caldav.go index cf143e7..d72635d 100644 --- a/internal/caldav/caldav.go +++ b/internal/caldav/caldav.go @@ -27,6 +27,7 @@ import ( "github.com/calnode/calnode/internal/calendar" "github.com/calnode/calnode/internal/connstore" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/netutil" "github.com/calnode/calnode/internal/secret" "github.com/calnode/calnode/internal/uid" @@ -37,7 +38,7 @@ var _ calendar.Provider = (*Client)(nil) // Client manages CalDAV connections (encrypted app-password credentials) and access. type Client struct { - db *sql.DB + db *db.DB key [32]byte logger *slog.Logger hc *http.Client @@ -45,7 +46,7 @@ type Client struct { // New creates a Client. encKeyHex is the 64-char hex AES-256 encryption key (the same // instance key used to encrypt the other providers' tokens). -func New(db *sql.DB, encKeyHex string) (*Client, error) { +func New(db *db.DB, encKeyHex string) (*Client, error) { b, err := hex.DecodeString(encKeyHex) if err != nil || len(b) != 32 { return nil, fmt.Errorf("caldav: invalid encryption key") diff --git a/internal/caldav/caldav_test.go b/internal/caldav/caldav_test.go index ab24ed7..eba7a93 100644 --- a/internal/caldav/caldav_test.go +++ b/internal/caldav/caldav_test.go @@ -2,7 +2,6 @@ package caldav import ( "context" - "database/sql" "io" "net/http" "net/http/httptest" @@ -12,19 +11,14 @@ import ( "github.com/calnode/calnode/internal/calendar" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" ) const testKeyHex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" -func newTestDB(t *testing.T) *sql.DB { +func newTestDB(t *testing.T) *db.DB { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("open: %v", err) - } - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate: %v", err) - } + database := dbtest.Open(t) t.Cleanup(func() { database.Close() }) return database } @@ -41,7 +35,7 @@ func newTestClient(t *testing.T) *Client { return c } -func seedUser(t *testing.T, database *sql.DB, userID string) { +func seedUser(t *testing.T, database *db.DB, userID string) { t.Helper() _, err := database.ExecContext(context.Background(), ` INSERT INTO users (id, email, name, iana_timezone, is_admin, created_at) @@ -267,7 +261,7 @@ func TestInvitesGuests_false(t *testing.T) { // ----- helpers ----- -func destEmail(t *testing.T, database *sql.DB, userID string) string { +func destEmail(t *testing.T, database *db.DB, userID string) string { t.Helper() var e string err := database.QueryRowContext(context.Background(), @@ -278,7 +272,7 @@ func destEmail(t *testing.T, database *sql.DB, userID string) string { return e } -func countConns(t *testing.T, database *sql.DB, userID string) int { +func countConns(t *testing.T, database *db.DB, userID string) int { t.Helper() var n int if err := database.QueryRowContext(context.Background(), diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go index afaade9..f8e6696 100644 --- a/internal/calendar/calendar.go +++ b/internal/calendar/calendar.go @@ -10,6 +10,7 @@ import ( "sort" "time" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/slots" ) @@ -82,13 +83,13 @@ type Provider interface { // Service holds the configured providers and dispatches per-user operations to // whichever provider that user has connected. type Service struct { - db *sql.DB + db *db.DB providers map[string]Provider primary string // default provider for new connections (first registered) } // NewService returns an empty Service. Register one provider per configured backend. -func NewService(db *sql.DB) *Service { +func NewService(db *db.DB) *Service { return &Service{db: db, providers: map[string]Provider{}} } diff --git a/internal/calendar/calendar_test.go b/internal/calendar/calendar_test.go index 8f778e5..b066708 100644 --- a/internal/calendar/calendar_test.go +++ b/internal/calendar/calendar_test.go @@ -4,18 +4,11 @@ import ( "context" "testing" - "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" ) func TestCanAutoGenerate(t *testing.T) { - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("open: %v", err) - } - defer database.Close() - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate: %v", err) - } + database := dbtest.Open(t) ctx := context.Background() seed := func(userID, provider, kind string) { @@ -66,14 +59,7 @@ func TestCanAutoGenerate(t *testing.T) { // TestConnectionManagement covers the multi-calendar Service helpers: listing connections, // switching the single destination, and promoting a survivor when the destination is removed. func TestConnectionManagement(t *testing.T) { - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("open: %v", err) - } - defer database.Close() - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate: %v", err) - } + database := dbtest.Open(t) ctx := context.Background() if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, name, iana_timezone, is_admin, created_at) diff --git a/internal/calendar/conflicts.go b/internal/calendar/conflicts.go index 7da2998..946ece1 100644 --- a/internal/calendar/conflicts.go +++ b/internal/calendar/conflicts.go @@ -2,7 +2,8 @@ package calendar import ( "context" - "database/sql" + + "github.com/calnode/calnode/internal/db" ) // ConflictCalendarIDs resolves which calendar IDs of one connected account must be checked for @@ -17,7 +18,7 @@ import ( // // The caller must have no open rows cursor on the shared DB pool when calling this (the pool is // single-connection): drain and Close() any cursor first. -func ConflictCalendarIDs(ctx context.Context, db *sql.DB, provider, userID, accountEmail, fallbackCalID string) ([]string, error) { +func ConflictCalendarIDs(ctx context.Context, db *db.DB, provider, userID, accountEmail, fallbackCalID string) ([]string, error) { rows, err := db.QueryContext(ctx, `SELECT calendar_id, check_conflicts FROM connection_calendars WHERE user_id = ? AND provider = ? AND account_email = ?`, diff --git a/internal/calendar/connid_staleness_test.go b/internal/calendar/connid_staleness_test.go index 5264e20..484dffe 100644 --- a/internal/calendar/connid_staleness_test.go +++ b/internal/calendar/connid_staleness_test.go @@ -7,19 +7,13 @@ import ( "testing" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" ) // newTestDB opens a migrated in-memory DB with one user. -func newTestDB(t *testing.T) *sql.DB { +func newTestDB(t *testing.T) *db.DB { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("open: %v", err) - } - t.Cleanup(func() { database.Close() }) - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate: %v", err) - } + database := dbtest.Open(t) if _, err := database.Exec( `INSERT INTO users (id, email, name, iana_timezone, is_admin, created_at) VALUES ('u1','u1@x.test','U','UTC',0,'2026-01-01T00:00:00Z')`); err != nil { @@ -28,7 +22,7 @@ func newTestDB(t *testing.T) *sql.DB { return database } -func seedConn(t *testing.T, database *sql.DB, id, userID, provider, email string, check, dest int) { +func seedConn(t *testing.T, database *db.DB, id, userID, provider, email string, check, dest int) { t.Helper() if _, err := database.Exec( `INSERT INTO calendar_connections @@ -50,7 +44,7 @@ func seedConn(t *testing.T, database *sql.DB, id, userID, provider, email string // refreshToken reproduces what a provider does on token refresh: same account, brand new // row id. -func refreshToken(t *testing.T, db *sql.DB, userID, provider, email, newID string) { +func refreshToken(t *testing.T, db *db.DB, userID, provider, email, newID string) { t.Helper() var dest, check int if err := db.QueryRow( diff --git a/internal/calendar/microsoft/microsoft.go b/internal/calendar/microsoft/microsoft.go index 6194e0a..8c1f0d3 100644 --- a/internal/calendar/microsoft/microsoft.go +++ b/internal/calendar/microsoft/microsoft.go @@ -27,6 +27,7 @@ import ( "github.com/calnode/calnode/internal/calendar" "github.com/calnode/calnode/internal/connstore" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/oauthstore" "github.com/calnode/calnode/internal/secret" "github.com/calnode/calnode/internal/uid" @@ -41,14 +42,14 @@ var _ calendar.Provider = (*Client)(nil) type Client struct { config *oauth2.Config key [32]byte - db *sql.DB + db *db.DB logger *slog.Logger apiBase string // base URL for Graph API; overridable in tests } // New creates a Client. tenant defaults to "common" (any Microsoft account). // encKeyHex is the 64-char hex AES-256 encryption key. -func New(db *sql.DB, clientID, clientSecret, tenant, redirectURL, encKeyHex string) (*Client, error) { +func New(db *db.DB, clientID, clientSecret, tenant, redirectURL, encKeyHex string) (*Client, error) { b, err := hex.DecodeString(encKeyHex) if err != nil || len(b) != 32 { return nil, fmt.Errorf("microsoft: invalid encryption key") diff --git a/internal/calendar/microsoft/microsoft_test.go b/internal/calendar/microsoft/microsoft_test.go index 30088fa..53a0856 100644 --- a/internal/calendar/microsoft/microsoft_test.go +++ b/internal/calendar/microsoft/microsoft_test.go @@ -2,7 +2,6 @@ package microsoft import ( "context" - "database/sql" "encoding/base64" "io" "net/http" @@ -15,19 +14,14 @@ import ( "github.com/calnode/calnode/internal/calendar" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" ) const testKeyHex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" -func newTestDB(t *testing.T) *sql.DB { +func newTestDB(t *testing.T) *db.DB { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("open: %v", err) - } - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate: %v", err) - } + database := dbtest.Open(t) t.Cleanup(func() { database.Close() }) return database } @@ -41,7 +35,7 @@ func newTestClient(t *testing.T) *Client { return c } -func seedUser(t *testing.T, database *sql.DB, userID string) { +func seedUser(t *testing.T, database *db.DB, userID string) { t.Helper() _, err := database.ExecContext(context.Background(), ` INSERT INTO users (id, email, name, iana_timezone, is_admin, created_at) diff --git a/internal/config/config.go b/internal/config/config.go index 60fb580..e67de48 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -61,6 +61,18 @@ type Config struct { // DemoResetInterval is how often DemoMode wipes and re-seeds the DB. Configurable // (not hardcoded to 30m) so local verification doesn't require waiting half an hour. DemoResetInterval time.Duration + + // DBMaxOpenConns / DBMaxIdleConns size the PostgreSQL connection pool. + // PostgreSQL's own max_connections is the thing they have to fit inside, and + // that is a property of the server a self-hoster runs, not of Calnode — a + // small instance behind a PgBouncer wants a different number from one talking + // to a 200-connection server directly. + // + // They do NOT apply to SQLite, which is pinned at 1/1 in internal/db because + // the single writer connection is a correctness guarantee (ARCHITECTURE §17), + // not a tuning choice. + DBMaxOpenConns int + DBMaxIdleConns int } func Load() *Config { @@ -100,10 +112,62 @@ func Load() *Config { cfg.CookieSecure = getBool("COOKIE_SECURE", strings.HasPrefix(cfg.BaseURL, "https://")) cfg.DemoMode = getBool("DEMO_MODE", false) cfg.DemoResetInterval = getDuration("DEMO_RESET_INTERVAL", 30*time.Minute) + cfg.DBMaxOpenConns, cfg.DBMaxIdleConns = PoolFromEnv() return cfg } +// Pool defaults. Deliberately modest: one Calnode instance is one small process, +// and a self-hoster's PostgreSQL is usually sized to match. +const ( + DefaultDBMaxOpenConns = 10 + DefaultDBMaxIdleConns = 5 +) + +// PoolFromEnv reads DB_MAX_OPEN_CONNS and DB_MAX_IDLE_CONNS. +// +// It is exported separately from Load because internal/db calls it directly: +// OpenDB has to know the pool size, every entry point that opens a database +// would otherwise have to remember to pass it, and forgetting would silently +// give that entry point the defaults. config imports nothing from the app, so +// db → config is not a cycle. +// +// Validation, rather than handing database/sql whatever the environment said: +// +// - unset, unparsable or not positive → the default, with a warning. This +// matches getBool/getDuration above, which also fall back rather than +// failing a boot over a typo in an optional knob. +// - idle > open → idle is clamped to open. database/sql silently reduces the +// idle limit to the open limit in that case, so the pair is the honest +// description of what the pool will do. +func PoolFromEnv() (maxOpen, maxIdle int) { + maxOpen = getPositiveInt("DB_MAX_OPEN_CONNS", DefaultDBMaxOpenConns) + maxIdle = getPositiveInt("DB_MAX_IDLE_CONNS", DefaultDBMaxIdleConns) + if maxIdle > maxOpen { + slog.Warn("DB_MAX_IDLE_CONNS exceeds DB_MAX_OPEN_CONNS; clamping", + "idle", maxIdle, "open", maxOpen) + maxIdle = maxOpen + } + return maxOpen, maxIdle +} + +func getPositiveInt(key string, def int) int { + v := os.Getenv(key) + if v == "" { + return def + } + n, err := strconv.Atoi(v) + if err != nil { + slog.Warn("ignoring unparsable integer environment variable", "key", key, "value", v, "default", def) + return def + } + if n < 1 { + slog.Warn("ignoring non-positive environment variable", "key", key, "value", n, "default", def) + return def + } + return n +} + func parseLogLevel(s string) slog.Level { switch s { case "debug": diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a3a9962..5b60eb9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -121,3 +121,76 @@ func TestLoad_demoResetIntervalInvalidFallsBackToDefault(t *testing.T) { t.Errorf("DemoResetInterval = %v; want 30m default on invalid input", cfg.DemoResetInterval) } } + +func TestLoad_poolDefaults(t *testing.T) { + os.Unsetenv("DB_MAX_OPEN_CONNS") + os.Unsetenv("DB_MAX_IDLE_CONNS") + cfg := config.Load() + if cfg.DBMaxOpenConns != 10 { + t.Errorf("DBMaxOpenConns = %d; want 10", cfg.DBMaxOpenConns) + } + if cfg.DBMaxIdleConns != 5 { + t.Errorf("DBMaxIdleConns = %d; want 5", cfg.DBMaxIdleConns) + } + // The exported constants are what internal/db falls back to, so they must be + // the same numbers Load reports rather than a second opinion. + if config.DefaultDBMaxOpenConns != 10 || config.DefaultDBMaxIdleConns != 5 { + t.Errorf("defaults = %d/%d; want 10/5", + config.DefaultDBMaxOpenConns, config.DefaultDBMaxIdleConns) + } +} + +func TestPoolFromEnv(t *testing.T) { + tests := []struct { + name string + open, idle string + wantOpen, wantIdle int + }{ + {name: "unset", wantOpen: 10, wantIdle: 5}, + {name: "both set", open: "40", idle: "12", wantOpen: 40, wantIdle: 12}, + {name: "idle above open is clamped to open", open: "6", idle: "99", wantOpen: 6, wantIdle: 6}, + {name: "zero open is not positive", open: "0", idle: "2", wantOpen: 10, wantIdle: 2}, + {name: "negative idle is not positive", open: "8", idle: "-1", wantOpen: 8, wantIdle: 5}, + {name: "unparsable open", open: "many", idle: "3", wantOpen: 10, wantIdle: 3}, + {name: "one and one", open: "1", idle: "1", wantOpen: 1, wantIdle: 1}, + // The default idle (5) is above an explicitly small open limit, so the + // clamp has to apply to the DEFAULT too, not only to a value someone set. + {name: "small open clamps the default idle", open: "2", wantOpen: 2, wantIdle: 2}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + setEnvOrUnset(t, "DB_MAX_OPEN_CONNS", tc.open) + setEnvOrUnset(t, "DB_MAX_IDLE_CONNS", tc.idle) + + gotOpen, gotIdle := config.PoolFromEnv() + if gotOpen != tc.wantOpen || gotIdle != tc.wantIdle { + t.Errorf("PoolFromEnv() = %d/%d; want %d/%d", gotOpen, gotIdle, tc.wantOpen, tc.wantIdle) + } + if gotIdle > gotOpen { + t.Errorf("idle %d exceeds open %d; the pair must always satisfy idle <= open", gotIdle, gotOpen) + } + + cfg := config.Load() + if cfg.DBMaxOpenConns != gotOpen || cfg.DBMaxIdleConns != gotIdle { + t.Errorf("Load() reported %d/%d; want the same %d/%d PoolFromEnv gives", + cfg.DBMaxOpenConns, cfg.DBMaxIdleConns, gotOpen, gotIdle) + } + }) + } +} + +func setEnvOrUnset(t *testing.T, key, value string) { + t.Helper() + if value == "" { + previous, had := os.LookupEnv(key) + os.Unsetenv(key) + t.Cleanup(func() { + if had { + os.Setenv(key, previous) + } + }) + return + } + t.Setenv(key, value) +} diff --git a/internal/connstore/connstore.go b/internal/connstore/connstore.go index 1191e5d..e2e4b0e 100644 --- a/internal/connstore/connstore.go +++ b/internal/connstore/connstore.go @@ -13,7 +13,7 @@ import ( "fmt" ) -// Execer is satisfied by both *sql.DB and *sql.Tx — ResolveFlags runs inside whichever +// Execer is satisfied by both *db.DB and *db.Tx — ResolveFlags runs inside whichever // transaction the caller already opened for its own upsert. type Execer interface { QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row diff --git a/internal/connstore/connstore_test.go b/internal/connstore/connstore_test.go index fe6e3e5..bbbeed3 100644 --- a/internal/connstore/connstore_test.go +++ b/internal/connstore/connstore_test.go @@ -2,26 +2,20 @@ package connstore import ( "context" - "database/sql" "testing" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" ) -func newTestDB(t *testing.T) *sql.DB { +func newTestDB(t *testing.T) *db.DB { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("newTestDB: open: %v", err) - } - if err := db.Migrate(database); err != nil { - t.Fatalf("newTestDB: migrate: %v", err) - } + database := dbtest.Open(t) t.Cleanup(func() { database.Close() }) return database } -func seedUser(t *testing.T, database *sql.DB, userID string) { +func seedUser(t *testing.T, database *db.DB, userID string) { t.Helper() if _, err := database.ExecContext(context.Background(), ` INSERT INTO users (id, email, name, iana_timezone, is_admin, created_at) @@ -31,7 +25,7 @@ func seedUser(t *testing.T, database *sql.DB, userID string) { } } -func seedConnection(t *testing.T, database *sql.DB, userID, provider, accountEmail string, checkConflicts, isDestination int) { +func seedConnection(t *testing.T, database *db.DB, userID, provider, accountEmail string, checkConflicts, isDestination int) { t.Helper() if _, err := database.ExecContext(context.Background(), ` INSERT INTO calendar_connections diff --git a/internal/db/collation_test.go b/internal/db/collation_test.go new file mode 100644 index 0000000..6fed8f7 --- /dev/null +++ b/internal/db/collation_test.go @@ -0,0 +1,285 @@ +package db_test + +import ( + "slices" + "sort" + "strings" + "testing" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" +) + +// Calnode stores timestamps as TEXT and compares them lexicographically: the +// worker claims with `run_at <= ?`, sessions and tokens expire on +// `expires_at > ?`, the consent window brackets `decided_at`, booking overlap is +// `start_at`/`end_at` against bound strings, and several lists are +// `ORDER BY created_at`. On SQLite that is memcmp, always. On PostgreSQL it is a +// comparison under the column's collation, so migration 00059 pins every one of +// those columns to COLLATE "C". +// +// These tests hold that pin. They are Postgres-only because SQLite has no +// collation to get wrong. + +// timestampColumnPredicate selects the columns migration 00059 covers, by name. +// +// `_at`/`_until` catches every timestamp column in the schema. The three +// remaining names are the availability columns, which hold 'HH:MM' and +// 'YYYY-MM-DD' and are ordered as times too (`ORDER BY day_of_week, start_time` +// in internal/handler/availability.go, `ORDER BY date` in override.go). Matching +// by name rather than by a committed list is the point: a timestamp column added +// by a later migration is caught here without anyone remembering to add it. +const timestampColumnPredicate = `(c.column_name ~ '_(at|until)$' + OR c.column_name IN ('date', 'start_time', 'end_time'))` + +// wantTimestampColumns is the number of columns 00059 altered. A floor, not an +// equality: the assertion that matters is "every match is C", and a query that +// silently stopped matching anything would satisfy that vacuously. +const wantTimestampColumns = 54 + +func TestPostgres_timestampColumnsCollateC(t *testing.T) { + handle := dbtest.RequirePostgres(t) + + rows, err := handle.Query(` + SELECT c.table_name, c.column_name, COALESCE(c.collation_name, '') + FROM information_schema.columns c + JOIN information_schema.tables t + ON t.table_schema = c.table_schema AND t.table_name = c.table_name + WHERE c.table_schema = current_schema() + AND t.table_type = 'BASE TABLE' + AND c.data_type = 'text' + AND ` + timestampColumnPredicate + ` + ORDER BY c.table_name, c.column_name`) + if err != nil { + t.Fatalf("read information_schema.columns: %v", err) + } + defer rows.Close() + + var checked int + var offenders []string + for rows.Next() { + var table, column, collation string + if err := rows.Scan(&table, &column, &collation); err != nil { + t.Fatalf("scan column row: %v", err) + } + checked++ + if collation != "C" { + offenders = append(offenders, table+"."+column+" = "+collation) + } + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate columns: %v", err) + } + + if checked < wantTimestampColumns { + t.Errorf("audited %d timestamp columns; want at least %d — did the predicate stop matching?", + checked, wantTimestampColumns) + } + if len(offenders) > 0 { + t.Errorf("%d of %d timestamp columns are not COLLATE \"C\":\n\t%s", + len(offenders), checked, strings.Join(offenders, "\n\t")) + } + t.Logf("audited %d TEXT timestamp columns, %d not C", checked, len(offenders)) +} + +// collationProbeValues are timestamp-shaped strings whose byte order and +// linguistic order disagree. +// +// The first two are the shapes the schema really stores (internal/dbtime: a +// space-separated `datetime('now')` and an RFC 3339 `strftime`). ⚠️ Measured on +// the PostgreSQL 17 this branch is developed against, those two do NOT flip +// under the database's en_US.utf8 default, nor under any of the other 878 +// collations installed on that server: glibc ignores the space at the primary +// level but still sorts a digit before 'T', which happens to agree with +// memcmp. So they cannot carry the control on their own — a test built only on +// them would pass with or without migration 00059. +// +// The third is the same instant with RFC 3339's lower-case 't' and 'z', which +// §5.6 of the RFC explicitly permits and which an importer or a third-party API +// can therefore hand us. Case is a tertiary-level difference: en_US.utf8 puts +// lower case first, memcmp puts upper case first ('T' is 0x54, 't' is 0x74). +// That is the pair that makes this control able to fail. +var collationProbeValues = []string{ + "2026-01-01 10:00:00", + "2026-01-01T10:00:00.000Z", + "2026-01-01t10:00:00z", + "2026-01-01T10:00:00Z", +} + +// TestPostgres_collationControl is the positive control: it proves the audit +// above is testing something, by showing that a plain TEXT column on this server +// really does order these values differently from a COLLATE "C" one. +// +// If the server's own default already behaves byte-wise (a C or C.UTF-8 +// database), there is nothing to control against and the test SKIPS naming the +// collation, rather than passing vacuously — a green run on such a server says +// nothing about a deployment on a linguistic one. +func TestPostgres_collationControl(t *testing.T) { + handle := dbtest.RequirePostgres(t) + + // The server's collation, for the skip message. lc_collate stopped being a + // GUC in PostgreSQL 16 (SHOW lc_collate errors with "unrecognized + // configuration parameter"), so it is read from the catalog, which is also + // where the per-database value has always actually lived. + var collate, ctype, provider string + if err := handle.QueryRow(` + SELECT datcollate, datctype, datlocprovider + FROM pg_database WHERE datname = current_database()`).Scan(&collate, &ctype, &provider); err != nil { + t.Fatalf("read pg_database locale: %v", err) + } + serverCollation := collate + " (ctype " + ctype + ", provider " + provider + ")" + + if _, err := handle.Exec(` + CREATE TABLE collation_control ( + plain TEXT NOT NULL, + cee TEXT COLLATE "C" NOT NULL + )`); err != nil { + t.Fatalf("create control table: %v", err) + } + + for _, v := range collationProbeValues { + if _, err := handle.Exec(`INSERT INTO collation_control (plain, cee) VALUES (?, ?)`, v, v); err != nil { + t.Fatalf("insert %q: %v", v, err) + } + } + + plainOrder := orderedColumn(t, handle, "plain") + ceeOrder := orderedColumn(t, handle, "cee") + + wantBytes := slices.Clone(collationProbeValues) + sort.Strings(wantBytes) // Go's sort on strings is byte-wise, i.e. what SQLite does + + if !slices.Equal(ceeOrder, wantBytes) { + t.Errorf("COLLATE \"C\" column ordered\n\t%v\nwant byte order\n\t%v", ceeOrder, wantBytes) + } + + if slices.Equal(plainOrder, ceeOrder) { + t.Skipf("server default collation %s orders these values byte-wise too, "+ + "so this control cannot distinguish a collated column from a C one; "+ + "the audit is unproven on this server", serverCollation) + } + + t.Logf("control fired: server default collation is %s\n\tplain: %v\n\tC : %v", + serverCollation, plainOrder, ceeOrder) +} + +func orderedColumn(t *testing.T, handle *db.DB, column string) []string { + t.Helper() + + // column is one of two literals above, never input. + rows, err := handle.Query(`SELECT ` + column + ` FROM collation_control ORDER BY ` + column) + if err != nil { + t.Fatalf("order by %s: %v", column, err) + } + defer rows.Close() + + var got []string + for rows.Next() { + var v string + if err := rows.Scan(&v); err != nil { + t.Fatalf("scan %s: %v", column, err) + } + got = append(got, v) + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate %s: %v", column, err) + } + return got +} + +// TestPostgres_jobsRunAtOrdering holds the ordering on the column the worker +// actually polls. +// +// Two things are asserted, and they are not the same thing: +// +// 1. `ORDER BY run_at` matches Go's byte-wise sort of the same values. This is +// the discriminating half: the lower-case RFC 3339 value in the set orders +// differently under a linguistic collation, so this fails without 00059. +// 2. The real claim predicate — `WHERE status = 'pending' AND run_at <= ?` from +// internal/worker/worker.go, with an RFC 3339 `now` — sees the +// space-separated shape as due and a future T-shape as not. internal/handler/ +// notetaker.go depends on exactly that: it writes `datetime('now')`'s space +// form *because* it sorts before any T-separated stamp, which is what makes a +// notetaker job due immediately. Both collations happen to agree here, so +// this half is a regression pin rather than a discriminator. +func TestPostgres_jobsRunAtOrdering(t *testing.T) { + handle := dbtest.RequirePostgres(t) + + // Same instant in the two shapes the tree writes, plus the lower-case RFC + // 3339 spelling, all in the past relative to `now` below. + runAts := []string{ + "2026-01-01 10:00:00", + "2026-01-01T10:00:00.000Z", + "2026-01-01t10:00:00z", + "2026-01-01T10:00:00Z", + } + for i, runAt := range runAts { + insertJob(t, handle, "job-past-"+string(rune('a'+i)), runAt) + } + insertJob(t, handle, "job-future", "2027-01-01T00:00:00Z") + + rows, err := handle.Query(`SELECT run_at FROM jobs ORDER BY run_at`) + if err != nil { + t.Fatalf("order by run_at: %v", err) + } + var order []string + for rows.Next() { + var v string + if err := rows.Scan(&v); err != nil { + rows.Close() + t.Fatalf("scan run_at: %v", err) + } + order = append(order, v) + } + rows.Close() + if err := rows.Err(); err != nil { + t.Fatalf("iterate run_at: %v", err) + } + + want := append(slices.Clone(runAts), "2027-01-01T00:00:00Z") + sort.Strings(want) + if !slices.Equal(order, want) { + t.Errorf("ORDER BY run_at =\n\t%v\nwant byte order\n\t%v", order, want) + } + + // The worker's own predicate, verbatim from internal/worker/worker.go, with + // the RFC 3339 `now` it binds. + const now = "2026-06-01T12:00:00Z" + claimed, err := handle.Query(` + SELECT id, type, payload, attempts, max_attempts + FROM jobs + WHERE status = 'pending' AND run_at <= ? + LIMIT 10`, now) + if err != nil { + t.Fatalf("claim query: %v", err) + } + defer claimed.Close() + + var ids []string + for claimed.Next() { + var id, typ, payload string + var attempts, maxAttempts int + if err := claimed.Scan(&id, &typ, &payload, &attempts, &maxAttempts); err != nil { + t.Fatalf("scan claimed job: %v", err) + } + ids = append(ids, id) + } + if err := claimed.Err(); err != nil { + t.Fatalf("iterate claimed jobs: %v", err) + } + sort.Strings(ids) + + wantIDs := []string{"job-past-a", "job-past-b", "job-past-c", "job-past-d"} + if !slices.Equal(ids, wantIDs) { + t.Errorf("claimed %v; want %v (the four past shapes, not the future one)", ids, wantIDs) + } +} + +func insertJob(t *testing.T, handle *db.DB, id, runAt string) { + t.Helper() + if _, err := handle.Exec(` + INSERT INTO jobs (id, type, payload, run_at, status, attempts, max_attempts) + VALUES (?, 'reminder.send', ?, ?, 'pending', 0, 3)`, id, `{"id":"`+id+`"}`, runAt); err != nil { + t.Fatalf("insert job %s: %v", id, err) + } +} diff --git a/internal/db/constraint.go b/internal/db/constraint.go new file mode 100644 index 0000000..6b44e13 --- /dev/null +++ b/internal/db/constraint.go @@ -0,0 +1,104 @@ +package db + +import ( + "errors" + "slices" + "strings" + + "github.com/jackc/pgx/v5/pgconn" + "modernc.org/sqlite" +) + +// Constraint violations are the one class of database error Calnode routinely acts +// on rather than just reporting: a duplicate slug is a 409, an out-of-range value is +// a 400, a dangling reference is a 404. Deciding which is which used to be a +// substring match on SQLite's English message, which is invisible to every gate and +// degraded silently to a 500 on PostgreSQL. +// +// Both engines are matched on their error codes. Codes rather than text because the +// text is not a contract: PostgreSQL localises its messages by the server's +// lc_messages, so a server running in German defeats any text match no matter how +// carefully written. +const ( + pgUniqueViolation = "23505" // unique_violation, and PostgreSQL's code for a primary-key collision too + pgCheckViolation = "23514" // check_violation + pgForeignKeyViolation = "23503" // foreign_key_violation +) + +// SQLite's extended result codes, as reported by (*sqlite.Error).Code(). +// +// ⛔ SQLITE_CONSTRAINT_PRIMARYKEY is a SEPARATE code from +// SQLITE_CONSTRAINT_UNIQUE even though both carry the message "UNIQUE constraint +// failed". Matching only 2067 would silently stop recognising primary-key +// collisions, and Calnode has one that matters: idempotency_keys.idempotency_key is +// a bare PRIMARY KEY, so every idempotent replay arrives as 1555. Both belong to +// IsUniqueViolation. PostgreSQL has no such split — a primary-key collision is +// 23505 like any other unique violation — which is why the trap only exists on one +// side. +const ( + sqliteConstraintCheck = 275 // SQLITE_CONSTRAINT_CHECK + sqliteConstraintForeignKey = 787 // SQLITE_CONSTRAINT_FOREIGNKEY + sqliteConstraintPrimaryKey = 1555 // SQLITE_CONSTRAINT_PRIMARYKEY + sqliteConstraintUnique = 2067 // SQLITE_CONSTRAINT_UNIQUE +) + +// SQLite's message fragments, used only as a fallback — see violates. +const ( + sqliteUniqueText = "UNIQUE constraint failed" + sqliteCheckText = "CHECK constraint failed" + sqliteForeignKeyText = "FOREIGN KEY constraint failed" +) + +// IsUniqueViolation reports whether err is a unique-constraint violation — a +// duplicate slug, a replayed idempotency key, a second booking at one host's exact +// start time. A primary-key collision counts, on both engines. +func IsUniqueViolation(err error) bool { + return violates(err, pgUniqueViolation, sqliteUniqueText, + sqliteConstraintUnique, sqliteConstraintPrimaryKey) +} + +// IsCheckViolation reports whether err is a CHECK-constraint violation, i.e. a value +// outside the set the column allows. Callers turn this into a 400, since the only way +// to reach it is a request carrying a value the handler did not validate. +func IsCheckViolation(err error) bool { + return violates(err, pgCheckViolation, sqliteCheckText, sqliteConstraintCheck) +} + +// IsForeignKeyViolation reports whether err is a foreign-key violation — a reference +// to a row that does not exist, or a delete that would orphan one. +func IsForeignKeyViolation(err error) bool { + return violates(err, pgForeignKeyViolation, sqliteForeignKeyText, sqliteConstraintForeignKey) +} + +// violates classifies err: the driver's own error code when one is available, the +// message only when it is not. +// +// A driver error is a DEFINITE answer in both directions. A *pgconn.PgError or a +// *sqlite.Error whose code does not match returns false and does not fall through to +// the text comparison — falling through would classify an error by whether its +// message happened to contain an English phrase, which is the fragility being +// removed. It would also reintroduce the primary-key trap in reverse: a 1555 error +// excluded by code would be readmitted by its "UNIQUE constraint failed" message. +// +// The text fallback is deliberate rather than vestigial. It covers an error that +// reaches here without the concrete driver type still attached — a driver release +// that changes its error type, a layer that reformats an error into a plain one +// instead of wrapping it. In that case the message is the only signal left, and +// answering from it beats answering "not a constraint violation" and returning a 500. +func violates(err error, sqlstate, sqliteText string, sqliteCodes ...int) bool { + if err == nil { + return false + } + + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code == sqlstate + } + + var sqliteErr *sqlite.Error + if errors.As(err, &sqliteErr) { + return slices.Contains(sqliteCodes, sqliteErr.Code()) + } + + return strings.Contains(err.Error(), sqliteText) +} diff --git a/internal/db/constraint_test.go b/internal/db/constraint_test.go new file mode 100644 index 0000000..151c570 --- /dev/null +++ b/internal/db/constraint_test.go @@ -0,0 +1,176 @@ +package db_test + +import ( + "errors" + "testing" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" + "github.com/calnode/calnode/internal/uid" +) + +// TestConstraintPredicates provokes a real violation of each class against the real +// schema, on whichever engine dbtest is configured for, and asserts the predicate +// recognises it. +// +// Provoked rather than constructed: a hand-built error would only prove the +// predicate agrees with whatever the test author believed the driver returns, and +// the whole reason these helpers exist is that that belief was wrong on PostgreSQL. +// Run it twice — once bare, once with CALNODE_TEST_POSTGRES_DSN — and both engines +// are covered. +func TestConstraintPredicates(t *testing.T) { + h := dbtest.Open(t) + + // A user and an event type to hang the booking constraints off. + userID := uid.New() + if _, err := h.Exec( + `INSERT INTO users (id, email, name, iana_timezone) VALUES (?, ?, 'Host', 'UTC')`, + userID, userID+"@example.com"); err != nil { + t.Fatalf("seed user: %v", err) + } + etID := uid.New() + if _, err := h.Exec( + `INSERT INTO event_types (id, user_id, slug, name, duration_minutes) + VALUES (?, ?, ?, 'Call', 30)`, etID, userID, etID); err != nil { + t.Fatalf("seed event type: %v", err) + } + + t.Run("unique", func(t *testing.T) { + // users.email is UNIQUE in both migration sets. + _, err := h.Exec( + `INSERT INTO users (id, email, name, iana_timezone) VALUES (?, ?, 'Clash', 'UTC')`, + uid.New(), userID+"@example.com") + assertOnly(t, err, "unique", db.IsUniqueViolation) + }) + + // ⛔ The case that distinguishes a correct implementation from a plausible one. + // + // SQLite reports a PRIMARY KEY collision as SQLITE_CONSTRAINT_PRIMARYKEY (1555), + // NOT as SQLITE_CONSTRAINT_UNIQUE (2067) — while still saying "UNIQUE constraint + // failed" in the message. So a predicate matching only 2067 passes the subtest + // above and fails here, and the old text match passed both by accident. + // + // This is not a theoretical shape: idempotency_keys.idempotency_key is a bare + // PRIMARY KEY, so claimIdempotencyKey's entire replay path depends on 1555 being + // classified as a unique violation. PostgreSQL reports 23505 for both, so this + // subtest is redundant there — and it runs there anyway, because "redundant on + // one engine" is exactly the assumption worth re-checking after a driver bump. + t.Run("unique via primary key", func(t *testing.T) { + key := uid.New() + if _, err := h.Exec( + `INSERT INTO idempotency_keys (idempotency_key, request_hash, created_at) VALUES (?, 'h', ?)`, + key, "2026-06-01T00:00:00Z"); err != nil { + t.Fatalf("seed idempotency key: %v", err) + } + _, err := h.Exec( + `INSERT INTO idempotency_keys (idempotency_key, request_hash, created_at) VALUES (?, 'h', ?)`, + key, "2026-06-01T00:00:00Z") + assertOnly(t, err, "primary key", db.IsUniqueViolation) + }) + + t.Run("check", func(t *testing.T) { + // bookings.status has CHECK (status IN ('confirmed','cancelled')). + _, err := h.Exec(` + INSERT INTO bookings (id, event_type_id, host_id, start_at, end_at, status, created_at, updated_at) + VALUES (?, ?, ?, '2026-06-15T09:00:00Z', '2026-06-15T09:30:00Z', 'not-a-status', ?, ?)`, + uid.New(), etID, userID, "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z") + assertOnly(t, err, "check", db.IsCheckViolation) + }) + + t.Run("foreign key", func(t *testing.T) { + // event_type_id references event_types(id). SQLite needs foreign_keys=ON, + // which OpenDB sets. + _, err := h.Exec(` + INSERT INTO bookings (id, event_type_id, host_id, start_at, end_at, status, created_at, updated_at) + VALUES (?, 'no-such-event-type', ?, '2026-06-15T10:00:00Z', '2026-06-15T10:30:00Z', 'confirmed', ?, ?)`, + uid.New(), userID, "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z") + assertOnly(t, err, "foreign key", db.IsForeignKeyViolation) + }) + + t.Run("unrelated error", func(t *testing.T) { + // A predicate that answered true for everything would satisfy every caller + // above and be badly wrong, so the negative cases carry as much weight. + _, err := h.Exec(`SELECT * FROM a_table_that_does_not_exist`) + if err == nil { + t.Fatal("expected an error from a missing table") + } + assertNone(t, err, "missing table") + assertNone(t, errors.New("some unrelated failure"), "plain error") + assertNone(t, nil, "nil") + }) + + t.Run("unhandled constraint class", func(t *testing.T) { + // A NOT NULL violation is a constraint violation of a class nothing here + // classifies (SQLite 1299, PostgreSQL 23502). It must match none of the + // three, so a caller cannot turn it into a 409 by accident. + _, err := h.Exec( + `INSERT INTO users (id, email, name, iana_timezone) VALUES (?, ?, NULL, 'UTC')`, + uid.New(), uid.New()+"@example.com") + if err == nil { + t.Fatal("expected a NOT NULL violation") + } + assertNone(t, err, "not-null violation") + }) +} + +// TestConstraintTextFallback covers the branch no live engine reaches: an error that +// arrives without its driver type still attached. +// +// It is engine-independent, so it needs no database. The branch exists for a driver +// release that changes its error type, or a layer that reformats an error into a +// plain one rather than wrapping it — in which case the message is the only signal +// left, and answering from it beats returning a 500. Without this test the fallback +// would be unexecuted code that reads like an accident. +func TestConstraintTextFallback(t *testing.T) { + cases := []struct { + text string + want func(error) bool + name string + }{ + {"boom: UNIQUE constraint failed: t.a", db.IsUniqueViolation, "unique"}, + {"boom: CHECK constraint failed: n > 0", db.IsCheckViolation, "check"}, + {"boom: FOREIGN KEY constraint failed", db.IsForeignKeyViolation, "foreign key"}, + } + for _, c := range cases { + err := errors.New(c.text) + if !c.want(err) { + t.Errorf("%s: fallback did not recognise %q", c.name, c.text) + } + } + // And the fallback is still discriminating, not a catch-all. + assertNone(t, errors.New("NOT NULL constraint failed: t.a"), "not-null text") +} + +// assertOnly checks that want recognises err and the other two predicates do not: +// the classes have to be distinguishable, or a CHECK violation becomes a 409. +func assertOnly(t *testing.T, err error, class string, want func(error) bool) { + t.Helper() + if err == nil { + t.Fatalf("%s: expected a constraint violation, got nil", class) + } + if !want(err) { + t.Errorf("%s: predicate did not recognise %v", class, err) + } + matches := 0 + for _, p := range []func(error) bool{db.IsUniqueViolation, db.IsCheckViolation, db.IsForeignKeyViolation} { + if p(err) { + matches++ + } + } + if matches != 1 { + t.Errorf("%s: %d of 3 predicates matched %v; want exactly 1", class, matches, err) + } +} + +func assertNone(t *testing.T, err error, what string) { + t.Helper() + for name, p := range map[string]func(error) bool{ + "IsUniqueViolation": db.IsUniqueViolation, + "IsCheckViolation": db.IsCheckViolation, + "IsForeignKeyViolation": db.IsForeignKeyViolation, + } { + if p(err) { + t.Errorf("%s returned true for %s (%v)", name, what, err) + } + } +} diff --git a/internal/db/db.go b/internal/db/db.go index 21c6240..b9b77a7 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -13,17 +13,83 @@ import ( "github.com/pressly/goose/v3" _ "modernc.org/sqlite" + + "github.com/calnode/calnode/internal/config" ) -//go:embed migrations/*.sql +//go:embed migrations/sqlite/*.sql migrations/postgres/*.sql var migrations embed.FS -// Open connects to SQLite at the given URL and configures pragmas. -// URL format: sqlite://./path/to/db or sqlite:///absolute/path or just a file path. -func Open(databaseURL string) (*sql.DB, error) { +// Option configures OpenDB. +type Option func(*openOptions) + +type openOptions struct { + maxOpen, maxIdle int +} + +// WithPool sets the PostgreSQL pool sizes explicitly, bypassing +// DB_MAX_OPEN_CONNS / DB_MAX_IDLE_CONNS. For a caller that must not follow the +// environment — a one-shot CLI, or a test pinning the numbers it asserts. +// +// Values are sanity-checked the same way config does it, because this is the +// function that hands them to database/sql: a non-positive size falls back to +// the default, and an idle limit above the open limit is clamped. +func WithPool(maxOpen, maxIdle int) Option { + return func(o *openOptions) { + if maxOpen > 0 { + o.maxOpen = maxOpen + } + if maxIdle > 0 { + o.maxIdle = maxIdle + } + if o.maxIdle > o.maxOpen { + o.maxIdle = o.maxOpen + } + } +} + +// OpenDB connects to the database named by databaseURL and configures the pool +// for the engine it names. +// +// There is deliberately no bare-handle sibling. An Open returning *sql.DB +// existed through the port "for callers that have not moved over yet", and it +// was a foot-gun with no upside: statements issued through it are not rebound, +// so every ? in them is a syntax error on Postgres, found at runtime and far +// from the call. Anything that genuinely needs the bare pool (goose, Litestream) +// reaches it as handle.DB, which at least says so at the call site. +// +// Pool sizing comes from the environment (config.PoolFromEnv) unless a WithPool +// option overrides it, so every entry point picks up DB_MAX_OPEN_CONNS / +// DB_MAX_IDLE_CONNS without each one having to remember to pass them. It is +// ignored entirely on SQLite — see openSQLite. +// +// URL formats: +// +// sqlite://./path/to/db, sqlite:///absolute/path, or a bare file path +// postgres://user:pass@host:port/dbname (postgresql:// is accepted too) +func OpenDB(databaseURL string, opts ...Option) (*DB, error) { + if dialectFromURL(databaseURL) == DialectPostgres { + o := openOptions{} + o.maxOpen, o.maxIdle = config.PoolFromEnv() + for _, opt := range opts { + opt(&o) + } + return openPostgres(databaseURL, o) + } + return openSQLite(databaseURL) +} + +// openSQLite opens SQLite and configures pragmas. +// +// It takes no pool options on purpose. DB_MAX_OPEN_CONNS is meaningless here and +// honouring it would be a correctness bug, not a tuning choice: the single +// connection is what serialises write transactions and what keeps the +// booking-overlap check free of TOCTOU races (ARCHITECTURE §17), and the pragmas +// below are connection-scoped, so a second connection would not have them. +func openSQLite(databaseURL string) (*DB, error) { dsn := parseDSN(databaseURL) - db, err := sql.Open("sqlite", dsn) + db, err := sql.Open(DialectSQLite.driverName(), dsn) if err != nil { return nil, fmt.Errorf("open database: %w", err) } @@ -47,18 +113,66 @@ func Open(databaseURL string) (*sql.DB, error) { return nil, fmt.Errorf("set busy timeout: %w", err) } - return db, nil + return &DB{DB: db, dialect: DialectSQLite}, nil +} + +// openPostgres opens a PostgreSQL pool. +// +// The one-connection pool of openSQLite is a SQLite constraint, not a Calnode +// design choice, and carrying it over would serialise the whole instance on a +// database that has its own concurrency control. Sizes come from the caller +// (ultimately DB_MAX_OPEN_CONNS / DB_MAX_IDLE_CONNS, defaulting to 10/5) +// because the number that fits is a property of the server: PostgreSQL's +// max_connections is shared with every other client, and an instance behind +// PgBouncer wants a different figure from one talking to the server directly. +// +// The pool does cost one property the SQLite path gets by accident: the +// booking-overlap check (ARCHITECTURE §17) is free of TOCTOU races there only +// because every transaction queues on that single connection. Here two +// overlapping bookings can clear the check concurrently, which is what +// booking.lockHosts' advisory lock closes. +func openPostgres(databaseURL string, o openOptions) (*DB, error) { + // pgx parses the DSN here, so a malformed URL fails at Open. Reachability is + // not probed: Migrate runs immediately after Open in every entry point and + // reports an unreachable server with the same context a probe would. + db, err := sql.Open(DialectPostgres.driverName(), databaseURL) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + db.SetMaxOpenConns(o.maxOpen) + db.SetMaxIdleConns(o.maxIdle) + + return &DB{DB: db, dialect: DialectPostgres}, nil } -// Migrate runs any pending Goose migrations embedded in migrations/*.sql. +// Migrate runs any pending Goose migrations embedded for this handle's dialect. +func (h *DB) Migrate() error { + return migrate(h.DB, h.dialect) +} + +// Migrate runs any pending Goose migrations embedded for db's engine, which is +// recovered from its driver. func Migrate(db *sql.DB) error { + return migrate(db, dialectOf(db)) +} + +// gooseMu guards goose's package-level dialect and base FS. A running Calnode +// only ever uses one engine, but the tests migrate both in one process and the +// two settings must not interleave. +var gooseMu sync.Mutex + +func migrate(db *sql.DB, dialect Dialect) error { + gooseMu.Lock() + defer gooseMu.Unlock() + goose.SetBaseFS(migrations) - if err := goose.SetDialect("sqlite3"); err != nil { + if err := goose.SetDialect(dialect.gooseDialect()); err != nil { return fmt.Errorf("set goose dialect: %w", err) } - if err := goose.Up(db, "migrations"); err != nil { + if err := goose.Up(db, dialect.migrationsDir()); err != nil { return fmt.Errorf("run migrations: %w", err) } @@ -73,39 +187,54 @@ var ( // TargetVersion returns the highest migration version embedded in the binary — // i.e. the schema version a fully-migrated database should report. +// +// It is dialect-independent: the per-dialect directories are two spellings of +// one schema and carry the same version numbers, which TestMigrationDirs_parity +// enforces. func TargetVersion() (int64, error) { targetVersionOnce.Do(func() { - entries, err := fs.ReadDir(migrations, "migrations") + targetVersion, targetVersionErr = maxVersion(DialectSQLite.migrationsDir()) + }) + return targetVersion, targetVersionErr +} + +// maxVersion returns the highest goose version number in an embedded migrations +// directory. +func maxVersion(dir string) (int64, error) { + entries, err := fs.ReadDir(migrations, dir) + if err != nil { + return 0, fmt.Errorf("read embedded migrations: %w", err) + } + var highest int64 + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { + continue + } + // Filenames are "NNNNN_description.sql"; the leading number is the version. + name := path.Base(e.Name()) + numPart, _, _ := strings.Cut(name, "_") + v, err := strconv.ParseInt(numPart, 10, 64) if err != nil { - targetVersionErr = fmt.Errorf("read embedded migrations: %w", err) - return + continue // ignore files that don't follow the goose naming convention } - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { - continue - } - // Filenames are "NNNNN_description.sql"; the leading number is the version. - name := path.Base(e.Name()) - numPart, _, _ := strings.Cut(name, "_") - v, err := strconv.ParseInt(numPart, 10, 64) - if err != nil { - continue // ignore files that don't follow the goose naming convention - } - if v > targetVersion { - targetVersion = v - } + if v > highest { + highest = v } - }) - return targetVersion, targetVersionErr + } + return highest, nil } // AppliedVersion returns the schema version currently applied to db by reading // goose's bookkeeping table directly (no goose global state). A missing // goose_db_version table returns an error, which callers treat as "not migrated". +// +// is_applied is tested for truth rather than compared to 1: goose stores it as an +// INTEGER on SQLite and a BOOLEAN on Postgres, and the bare column is the one +// spelling both engines accept. func AppliedVersion(ctx context.Context, db *sql.DB) (int64, error) { var v sql.NullInt64 err := db.QueryRowContext(ctx, - `SELECT MAX(version_id) FROM goose_db_version WHERE is_applied = 1`).Scan(&v) + `SELECT MAX(version_id) FROM goose_db_version WHERE is_applied`).Scan(&v) if err != nil { return 0, err } @@ -130,6 +259,21 @@ func SchemaReady(ctx context.Context, db *sql.DB) (bool, error) { return applied >= target, nil } +// SchemaReady is the handle-level spelling, for callers that hold a *DB — which +// is every caller in the tree. The package-level functions above stay for the +// bare-pool cases (goose's own bookkeeping, the tests that open an unmigrated +// pool), but a handler reaching into h.db.DB to answer a readiness probe was one +// more place where the exported embedded field looked like the normal way to do +// things. +func (h *DB) SchemaReady(ctx context.Context) (bool, error) { + return SchemaReady(ctx, h.DB) +} + +// AppliedVersion is the handle-level spelling of the package function. +func (h *DB) AppliedVersion(ctx context.Context) (int64, error) { + return AppliedVersion(ctx, h.DB) +} + func parseDSN(url string) string { // Strip scheme prefix: sqlite:// → remainder dsn := strings.TrimPrefix(url, "sqlite://") diff --git a/internal/db/db_test.go b/internal/db/db_test.go index e07bf0b..21f9933 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -2,15 +2,16 @@ package db_test import ( "context" + "path/filepath" "testing" "github.com/calnode/calnode/internal/db" ) -func TestOpen_inMemory(t *testing.T) { - database, err := db.Open("sqlite://:memory:") +func TestOpenDB_inMemory(t *testing.T) { + database, err := db.OpenDB("sqlite://:memory:") if err != nil { - t.Fatalf("db.Open: %v", err) + t.Fatalf("db.OpenDB: %v", err) } defer database.Close() @@ -20,40 +21,40 @@ func TestOpen_inMemory(t *testing.T) { } func TestMigrate_runsClean(t *testing.T) { - database, err := db.Open("sqlite://:memory:") + database, err := db.OpenDB("sqlite://:memory:") if err != nil { - t.Fatalf("db.Open: %v", err) + t.Fatalf("db.OpenDB: %v", err) } defer database.Close() - if err := db.Migrate(database); err != nil { + if err := db.Migrate(database.DB); err != nil { t.Fatalf("db.Migrate: %v", err) } } func TestMigrate_idempotent(t *testing.T) { - database, err := db.Open("sqlite://:memory:") + database, err := db.OpenDB("sqlite://:memory:") if err != nil { - t.Fatalf("db.Open: %v", err) + t.Fatalf("db.OpenDB: %v", err) } defer database.Close() // Running twice should not error (goose is idempotent). for range 2 { - if err := db.Migrate(database); err != nil { + if err := db.Migrate(database.DB); err != nil { t.Fatalf("db.Migrate (run 2): %v", err) } } } func TestMigrate_tablesExist(t *testing.T) { - database, err := db.Open("sqlite://:memory:") + database, err := db.OpenDB("sqlite://:memory:") if err != nil { - t.Fatalf("db.Open: %v", err) + t.Fatalf("db.OpenDB: %v", err) } defer database.Close() - if err := db.Migrate(database); err != nil { + if err := db.Migrate(database.DB); err != nil { t.Fatalf("db.Migrate: %v", err) } @@ -79,24 +80,24 @@ func TestMigrate_tablesExist(t *testing.T) { } func TestSchemaReady_falseBeforeMigrate_trueAfter(t *testing.T) { - database, err := db.Open("sqlite://:memory:") + database, err := db.OpenDB("sqlite://:memory:") if err != nil { - t.Fatalf("db.Open: %v", err) + t.Fatalf("db.OpenDB: %v", err) } defer database.Close() ctx := context.Background() // Before migrating, the goose bookkeeping table is absent → not ready. - if ready, _ := db.SchemaReady(ctx, database); ready { + if ready, _ := db.SchemaReady(ctx, database.DB); ready { t.Error("SchemaReady = true before migrations ran; want false") } - if err := db.Migrate(database); err != nil { + if err := db.Migrate(database.DB); err != nil { t.Fatalf("db.Migrate: %v", err) } - ready, err := db.SchemaReady(ctx, database) + ready, err := db.SchemaReady(ctx, database.DB) if err != nil { t.Fatalf("SchemaReady after migrate: %v", err) } @@ -109,7 +110,7 @@ func TestSchemaReady_falseBeforeMigrate_trueAfter(t *testing.T) { if err != nil { t.Fatalf("TargetVersion: %v", err) } - applied, err := db.AppliedVersion(ctx, database) + applied, err := db.AppliedVersion(ctx, database.DB) if err != nil { t.Fatalf("AppliedVersion: %v", err) } @@ -119,16 +120,34 @@ func TestSchemaReady_falseBeforeMigrate_trueAfter(t *testing.T) { if target < 17 { t.Errorf("target version = %d; want >= 17 (sanity check against known migrations)", target) } + + // The handle-level spellings are what the tree uses now that there is no bare + // Open; they must agree with the package-level ones they delegate to. This is + // the path /readyz takes (internal/handler/health.go). + handleReady, err := database.SchemaReady(ctx) + if err != nil { + t.Fatalf("(*DB).SchemaReady: %v", err) + } + if handleReady != ready { + t.Errorf("(*DB).SchemaReady = %v; want %v (same as the package function)", handleReady, ready) + } + handleApplied, err := database.AppliedVersion(ctx) + if err != nil { + t.Fatalf("(*DB).AppliedVersion: %v", err) + } + if handleApplied != applied { + t.Errorf("(*DB).AppliedVersion = %d; want %d", handleApplied, applied) + } } func TestDoubleBookingIndex_exists(t *testing.T) { - database, err := db.Open("sqlite://:memory:") + database, err := db.OpenDB("sqlite://:memory:") if err != nil { - t.Fatalf("db.Open: %v", err) + t.Fatalf("db.OpenDB: %v", err) } defer database.Close() - if err := db.Migrate(database); err != nil { + if err := db.Migrate(database.DB); err != nil { t.Fatalf("db.Migrate: %v", err) } @@ -140,3 +159,121 @@ func TestDoubleBookingIndex_exists(t *testing.T) { t.Errorf("double-booking guard index not found: %v", err) } } + +// TestOpenDB_sqlitePragmasAndPool pins the SQLite path against accidental +// change: the single connection is a correctness guarantee (ARCHITECTURE §17), +// not a tuning choice, and the pragmas are connection-scoped so losing the +// connection loses them. A file database is used because :memory: cannot be in +// WAL mode. +func TestOpenDB_sqlitePragmasAndPool(t *testing.T) { + handle, err := db.OpenDB("sqlite://" + filepath.Join(t.TempDir(), "calnode.db")) + if err != nil { + t.Fatalf("db.OpenDB: %v", err) + } + defer handle.Close() + + if got := handle.Dialect(); got != db.DialectSQLite { + t.Errorf("dialect = %v; want %v", got, db.DialectSQLite) + } + if got := handle.Stats().MaxOpenConnections; got != 1 { + t.Errorf("MaxOpenConnections = %d; want 1", got) + } + + pragmas := []struct{ name, want string }{ + {"journal_mode", "wal"}, + {"foreign_keys", "1"}, + {"busy_timeout", "5000"}, + } + for _, p := range pragmas { + var got string + if err := handle.QueryRow(`PRAGMA ` + p.name).Scan(&got); err != nil { + t.Fatalf("PRAGMA %s: %v", p.name, err) + } + if got != p.want { + t.Errorf("PRAGMA %s = %q; want %q", p.name, got, p.want) + } + } +} + +// TestOpenDB_wrapperRoundTrip exercises the method set the rest of the codebase +// uses, on the dialect where rebinding is a no-op, so a mistake in the wrapper +// itself cannot hide behind a missing Postgres server. +func TestOpenDB_wrapperRoundTrip(t *testing.T) { + handle, err := db.OpenDB("sqlite://:memory:") + if err != nil { + t.Fatalf("db.OpenDB: %v", err) + } + defer handle.Close() + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + ctx := context.Background() + + if _, err := handle.ExecContext(ctx, + `INSERT INTO users (id, email, name) VALUES (?, ?, ?)`, + "u1", "a@example.com", "A"); err != nil { + t.Fatalf("ExecContext insert: %v", err) + } + + var name string + if err := handle.QueryRowContext(ctx, + `SELECT name FROM users WHERE id = ?`, "u1").Scan(&name); err != nil { + t.Fatalf("QueryRowContext: %v", err) + } + if name != "A" { + t.Errorf("name = %q; want %q", name, "A") + } + + rows, err := handle.QueryContext(ctx, `SELECT id FROM users WHERE email = ?`, "a@example.com") + if err != nil { + t.Fatalf("QueryContext: %v", err) + } + count := 0 + for rows.Next() { + count++ + } + if err := rows.Err(); err != nil { + t.Fatalf("rows.Err: %v", err) + } + rows.Close() + if count != 1 { + t.Errorf("rows returned = %d; want 1", count) + } + + tx, err := handle.BeginTx(ctx, nil) + if err != nil { + t.Fatalf("BeginTx: %v", err) + } + if tx.Dialect() != handle.Dialect() { + t.Errorf("tx dialect = %v; want %v", tx.Dialect(), handle.Dialect()) + } + if _, err := tx.ExecContext(ctx, `UPDATE users SET name = ? WHERE id = ?`, "B", "u1"); err != nil { + tx.Rollback() + t.Fatalf("tx.ExecContext: %v", err) + } + if err := tx.QueryRowContext(ctx, `SELECT name FROM users WHERE id = ?`, "u1").Scan(&name); err != nil { + tx.Rollback() + t.Fatalf("tx.QueryRowContext: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("tx.Commit: %v", err) + } + if name != "B" { + t.Errorf("name after tx update = %q; want %q", name, "B") + } + + stmt, err := handle.PrepareContext(ctx, `SELECT COUNT(*) FROM users WHERE email = ?`) + if err != nil { + t.Fatalf("PrepareContext: %v", err) + } + defer stmt.Close() + var n int + if err := stmt.QueryRowContext(ctx, "a@example.com").Scan(&n); err != nil { + t.Fatalf("stmt.QueryRowContext: %v", err) + } + if n != 1 { + t.Errorf("count = %d; want 1", n) + } +} diff --git a/internal/db/dialect.go b/internal/db/dialect.go new file mode 100644 index 0000000..e3881c2 --- /dev/null +++ b/internal/db/dialect.go @@ -0,0 +1,106 @@ +package db + +import ( + "database/sql" + "strings" + + "github.com/jackc/pgx/v5/stdlib" +) + +// Dialect names the SQL engine behind a handle. +// +// Calnode's SQL is hand-written and almost entirely portable. Two things are +// not: placeholder syntax (? versus $n), which the DB/Tx wrapper hides by +// rebinding every statement on its way through, and the handful of statements +// that use engine-specific functions, which callers resolve with Dialect.SQL. +type Dialect int + +const ( + // DialectSQLite is the zero value deliberately: a handle whose dialect + // could not be determined behaves exactly as it did before this package + // knew about Postgres. + DialectSQLite Dialect = iota + DialectPostgres +) + +// String returns the dialect's canonical lower-case name. +func (d Dialect) String() string { + switch d { + case DialectPostgres: + return "postgres" + default: + return "sqlite" + } +} + +// SQL picks between two hand-written statements. +// +// Reach for this only when one portable statement is genuinely impossible — +// engine-specific functions (datetime('now'), strftime), upsert spelling, a +// PRAGMA. Differing placeholders are not a reason: the wrapper rebinds those, +// and duplicating a statement to change ? to $1 doubles the maintenance for no +// gain. +func (d Dialect) SQL(sqlite, postgres string) string { + if d == DialectPostgres { + return postgres + } + return sqlite +} + +// Rebind converts a portable ?-placeholder statement into this dialect's form. +// SQLite takes ? natively, so its statements are returned untouched with no +// allocation. +func (d Dialect) Rebind(query string) string { + if d != DialectPostgres { + return query + } + return Rebind(query) +} + +// driverName is the database/sql driver this dialect opens with. +func (d Dialect) driverName() string { + if d == DialectPostgres { + return "pgx" + } + return "sqlite" +} + +// gooseDialect is goose's name for this engine. +func (d Dialect) gooseDialect() string { + if d == DialectPostgres { + return "postgres" + } + return "sqlite3" +} + +// migrationsDir is the embedded directory holding this dialect's migrations. +// The two sets carry the same version numbers by construction — one schema, two +// spellings — which is what lets TargetVersion stay dialect-independent. +func (d Dialect) migrationsDir() string { + if d == DialectPostgres { + return "migrations/postgres" + } + return "migrations/sqlite" +} + +// dialectFromURL classifies a DATABASE_URL. Only an explicit postgres URL +// selects Postgres, so every form that worked before still works unchanged: +// sqlite://./rel, sqlite:///abs, :memory:, and a bare file path. +func dialectFromURL(databaseURL string) Dialect { + scheme := strings.ToLower(databaseURL) + if strings.HasPrefix(scheme, "postgres://") || strings.HasPrefix(scheme, "postgresql://") { + return DialectPostgres + } + return DialectSQLite +} + +// dialectOf recovers the dialect from an already-open handle, for the +// package-level helpers that still take a bare *sql.DB. An unrecognised driver +// reads as SQLite: that is what every caller of those helpers was before, so an +// unknown driver degrades to the old behaviour rather than to an error. +func dialectOf(db *sql.DB) Dialect { + if _, ok := db.Driver().(*stdlib.Driver); ok { + return DialectPostgres + } + return DialectSQLite +} diff --git a/internal/db/dialect_internal_test.go b/internal/db/dialect_internal_test.go new file mode 100644 index 0000000..19d7588 --- /dev/null +++ b/internal/db/dialect_internal_test.go @@ -0,0 +1,99 @@ +package db + +import ( + "database/sql" + "testing" +) + +func TestDialectFromURL(t *testing.T) { + tests := []struct { + url string + want Dialect + }{ + {"sqlite://./data/calnode.db", DialectSQLite}, + {"sqlite:///var/lib/calnode/calnode.db", DialectSQLite}, + {"sqlite://:memory:", DialectSQLite}, + {"sqlite://file::memory:?cache=shared&_fk=1", DialectSQLite}, + {"./data/calnode.db", DialectSQLite}, + {"/var/lib/calnode/calnode.db", DialectSQLite}, + {":memory:", DialectSQLite}, + {"", DialectSQLite}, + {"postgres://calnode@localhost:5432/calnode", DialectPostgres}, + {"postgresql://calnode@localhost:5432/calnode", DialectPostgres}, + {"postgres://u:p@h:5432/d?sslmode=require", DialectPostgres}, + {"POSTGRES://u:p@h:5432/d", DialectPostgres}, + // A path that merely mentions postgres is still a SQLite file. + {"./postgres-backup/calnode.db", DialectSQLite}, + } + + for _, tt := range tests { + if got := dialectFromURL(tt.url); got != tt.want { + t.Errorf("dialectFromURL(%q) = %v; want %v", tt.url, got, tt.want) + } + } +} + +func TestParseDSN(t *testing.T) { + tests := []struct{ url, want string }{ + {"sqlite://./data/calnode.db", "./data/calnode.db"}, + {"sqlite:///var/lib/calnode/calnode.db", "/var/lib/calnode/calnode.db"}, + {"sqlite://:memory:", ":memory:"}, + {"sqlite://file::memory:?cache=shared&_fk=1", "file::memory:?cache=shared&_fk=1"}, + {"./data/calnode.db", "./data/calnode.db"}, + // Windows: sqlite:///C:/path/db → C:/path/db. + {"sqlite:///C:/calnode/calnode.db", "C:/calnode/calnode.db"}, + } + + for _, tt := range tests { + if got := parseDSN(tt.url); got != tt.want { + t.Errorf("parseDSN(%q) = %q; want %q", tt.url, got, tt.want) + } + } +} + +// TestDialectOf covers the driver sniffing the package-level Migrate and the +// version helpers rely on. It needs no server: database/sql is lazy, so both +// handles exist without a connection. +func TestDialectOf(t *testing.T) { + sqlite, err := sql.Open(DialectSQLite.driverName(), ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + defer sqlite.Close() + + postgres, err := sql.Open(DialectPostgres.driverName(), "postgres://u:p@127.0.0.1:5432/d") + if err != nil { + t.Fatalf("open postgres: %v", err) + } + defer postgres.Close() + + if got := dialectOf(sqlite); got != DialectSQLite { + t.Errorf("dialectOf(sqlite handle) = %v; want %v", got, DialectSQLite) + } + if got := dialectOf(postgres); got != DialectPostgres { + t.Errorf("dialectOf(postgres handle) = %v; want %v", got, DialectPostgres) + } +} + +func TestDialectNames(t *testing.T) { + tests := []struct { + dialect Dialect + driver, goose string + migrationsPath string + }{ + {DialectSQLite, "sqlite", "sqlite3", "migrations/sqlite"}, + {DialectPostgres, "pgx", "postgres", "migrations/postgres"}, + } + + for _, tt := range tests { + if got := tt.dialect.driverName(); got != tt.driver { + t.Errorf("%v.driverName() = %q; want %q", tt.dialect, got, tt.driver) + } + if got := tt.dialect.gooseDialect(); got != tt.goose { + t.Errorf("%v.gooseDialect() = %q; want %q", tt.dialect, got, tt.goose) + } + if got := tt.dialect.migrationsDir(); got != tt.migrationsPath { + t.Errorf("%v.migrationsDir() = %q; want %q", tt.dialect, got, tt.migrationsPath) + } + } +} diff --git a/internal/db/handle.go b/internal/db/handle.go new file mode 100644 index 0000000..c8cac0c --- /dev/null +++ b/internal/db/handle.go @@ -0,0 +1,130 @@ +package db + +import ( + "context" + "database/sql" +) + +// DB is a *sql.DB that knows its dialect and rebinds placeholders. +// +// Every query method takes the portable ? form and rewrites it for the engine in +// use, so the rest of Calnode writes one statement per query no matter which +// database it runs on. Behaviour on SQLite is identical to using *sql.DB +// directly: Rebind is a no-op there. +// +// The embedded *sql.DB is exported on purpose — pool tuning, Ping, Close and +// anything that must hand a plain *sql.DB to a library (goose here, Litestream +// in DEPLOY.md) reach it as h.DB. Statements issued through that field, or +// through a *sql.Conn from the promoted Conn method, are NOT rebound; use the +// wrapper's own methods unless you are deliberately writing engine-specific SQL. +type DB struct { + *sql.DB + dialect Dialect +} + +// Dialect reports which engine this handle is talking to, for the few callers +// that must branch on it. +func (h *DB) Dialect() Dialect { return h.dialect } + +// Rebind converts a ?-placeholder statement for this handle's dialect. Useful +// when a caller builds SQL dynamically and hands it to something other than the +// methods below. +func (h *DB) Rebind(query string) string { return h.dialect.Rebind(query) } + +func (h *DB) Query(query string, args ...any) (*sql.Rows, error) { + return h.DB.Query(h.dialect.Rebind(query), args...) +} + +func (h *DB) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { + return h.DB.QueryContext(ctx, h.dialect.Rebind(query), args...) +} + +func (h *DB) QueryRow(query string, args ...any) *sql.Row { + return h.DB.QueryRow(h.dialect.Rebind(query), args...) +} + +func (h *DB) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row { + return h.DB.QueryRowContext(ctx, h.dialect.Rebind(query), args...) +} + +func (h *DB) Exec(query string, args ...any) (sql.Result, error) { + return h.DB.Exec(h.dialect.Rebind(query), args...) +} + +func (h *DB) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { + return h.DB.ExecContext(ctx, h.dialect.Rebind(query), args...) +} + +// Prepare and PrepareContext rebind at prepare time, so the returned *sql.Stmt +// needs no wrapper of its own. +func (h *DB) Prepare(query string) (*sql.Stmt, error) { + return h.DB.Prepare(h.dialect.Rebind(query)) +} + +func (h *DB) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) { + return h.DB.PrepareContext(ctx, h.dialect.Rebind(query)) +} + +// Begin and BeginTx return a *Tx rather than a *sql.Tx: a transaction that +// silently stopped rebinding would be the easiest way to reintroduce ? into a +// Postgres statement. +func (h *DB) Begin() (*Tx, error) { + tx, err := h.DB.Begin() + if err != nil { + return nil, err + } + return &Tx{Tx: tx, dialect: h.dialect}, nil +} + +func (h *DB) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error) { + tx, err := h.DB.BeginTx(ctx, opts) + if err != nil { + return nil, err + } + return &Tx{Tx: tx, dialect: h.dialect}, nil +} + +// Tx is a *sql.Tx with the same rebinding behaviour as DB. Commit and Rollback +// are the embedded ones — they carry no SQL text. +type Tx struct { + *sql.Tx + dialect Dialect +} + +// Dialect reports which engine this transaction is running against. +func (t *Tx) Dialect() Dialect { return t.dialect } + +// Rebind converts a ?-placeholder statement for this transaction's dialect. +func (t *Tx) Rebind(query string) string { return t.dialect.Rebind(query) } + +func (t *Tx) Query(query string, args ...any) (*sql.Rows, error) { + return t.Tx.Query(t.dialect.Rebind(query), args...) +} + +func (t *Tx) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { + return t.Tx.QueryContext(ctx, t.dialect.Rebind(query), args...) +} + +func (t *Tx) QueryRow(query string, args ...any) *sql.Row { + return t.Tx.QueryRow(t.dialect.Rebind(query), args...) +} + +func (t *Tx) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row { + return t.Tx.QueryRowContext(ctx, t.dialect.Rebind(query), args...) +} + +func (t *Tx) Exec(query string, args ...any) (sql.Result, error) { + return t.Tx.Exec(t.dialect.Rebind(query), args...) +} + +func (t *Tx) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { + return t.Tx.ExecContext(ctx, t.dialect.Rebind(query), args...) +} + +func (t *Tx) Prepare(query string) (*sql.Stmt, error) { + return t.Tx.Prepare(t.dialect.Rebind(query)) +} + +func (t *Tx) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) { + return t.Tx.PrepareContext(ctx, t.dialect.Rebind(query)) +} diff --git a/internal/db/migrations/postgres/00001_initial_schema.sql b/internal/db/migrations/postgres/00001_initial_schema.sql new file mode 100644 index 0000000..1b453e0 --- /dev/null +++ b/internal/db/migrations/postgres/00001_initial_schema.sql @@ -0,0 +1,210 @@ +-- +goose Up + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + iana_timezone TEXT NOT NULL DEFAULT 'UTC', + avatar_url TEXT, + is_admin SMALLINT NOT NULL DEFAULT 0, -- first user bootstraps as admin + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, -- stored hashed; shown once on creation + last_used_at TEXT, + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE TABLE IF NOT EXISTS teams ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE TABLE IF NOT EXISTS team_members ( + id TEXT PRIMARY KEY, + team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'member')), + routing_priority INTEGER NOT NULL DEFAULT 0, + UNIQUE (team_id, user_id) +); + +CREATE TABLE IF NOT EXISTS event_types ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + team_id TEXT REFERENCES teams(id) ON DELETE SET NULL, + slug TEXT NOT NULL UNIQUE, -- unique within workspace (§5) + name TEXT NOT NULL, + description TEXT, + duration_minutes INTEGER NOT NULL, + slot_interval_minutes INTEGER NOT NULL DEFAULT 30, + location_type TEXT NOT NULL DEFAULT 'link' + CHECK (location_type IN ('zoom','google_meet','teams','custom_video','phone','in_person','link')), + location_value TEXT, + routing_mode TEXT NOT NULL DEFAULT 'fixed' + CHECK (routing_mode IN ('fixed', 'round_robin', 'collective', 'priority')), + buffer_before_minutes INTEGER NOT NULL DEFAULT 0, + buffer_after_minutes INTEGER NOT NULL DEFAULT 0, + min_notice_minutes INTEGER NOT NULL DEFAULT 0, + max_future_days INTEGER NOT NULL DEFAULT 60, + seat_limit INTEGER NOT NULL DEFAULT 1, + is_active SMALLINT NOT NULL DEFAULT 1, + is_public SMALLINT NOT NULL DEFAULT 1, -- false = bookable only via direct link + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE TABLE IF NOT EXISTS event_type_questions ( + id TEXT PRIMARY KEY, + event_type_id TEXT NOT NULL REFERENCES event_types(id) ON DELETE CASCADE, + label TEXT NOT NULL, + type TEXT NOT NULL CHECK (type IN ('text', 'select', 'checkbox')), + options TEXT, -- JSON array; used for type='select' + required SMALLINT NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS availability_rules ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + event_type_id TEXT REFERENCES event_types(id) ON DELETE CASCADE, -- NULL = global default + day_of_week INTEGER NOT NULL CHECK (day_of_week BETWEEN 0 AND 6), -- 0=Sun … 6=Sat + start_time TEXT NOT NULL, -- HH:MM host-local wall-clock (§6.3) + end_time TEXT NOT NULL, + UNIQUE (user_id, event_type_id, day_of_week, start_time, end_time) +); + +CREATE TABLE IF NOT EXISTS availability_overrides ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + date TEXT NOT NULL, -- YYYY-MM-DD + is_available SMALLINT NOT NULL DEFAULT 0, + start_time TEXT, -- HH:MM; only when is_available=1 + end_time TEXT +); + +CREATE TABLE IF NOT EXISTS calendar_connections ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL CHECK (provider IN ('google', 'microsoft', 'caldav')), + access_token_enc TEXT NOT NULL, -- AES-GCM encrypted with CALNODE_ENCRYPTION_KEY (§15) + refresh_token_enc TEXT, + calendar_id TEXT NOT NULL, + check_conflicts SMALLINT NOT NULL DEFAULT 1, -- include in free/busy checks (§8.3) + is_destination SMALLINT NOT NULL DEFAULT 0, -- write bookings to this calendar + sync_token TEXT, -- incremental sync cursor + channel_expires_at TEXT, -- push-notification channel renewal (§13) + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE TABLE IF NOT EXISTS bookings ( + id TEXT PRIMARY KEY, + event_type_id TEXT NOT NULL REFERENCES event_types(id) ON DELETE RESTRICT, -- explicit: historical bookings block event-type deletion + host_id TEXT NOT NULL REFERENCES users(id), + start_at TEXT NOT NULL, -- UTC ISO 8601 (§6.3) + end_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'confirmed' + CHECK (status IN ('confirmed', 'cancelled')), + cancellation_reason TEXT, + location_value TEXT, + meeting_link TEXT, + external_event_id TEXT, -- calendar event id we created (for own-event exclusion §6.2) + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), + updated_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +-- Double-booking guard §6.4 — FIRST-LINE ONLY. +-- This index blocks exact-start-time collisions, but does NOT prevent overlapping bookings +-- with different start times (e.g. a 09:45 booking and a 10:00 booking on the same host). +-- The booking handler MUST also run an overlap check inside BEGIN IMMEDIATE: +-- SELECT 1 FROM bookings +-- WHERE host_id = :host_id AND status != 'cancelled' +-- AND start_at < :new_end_at AND end_at > :new_start_at +-- Fail with 409 if any row is returned before inserting. +CREATE UNIQUE INDEX IF NOT EXISTS idx_bookings_no_double + ON bookings (host_id, start_at) WHERE status != 'cancelled'; + +CREATE INDEX IF NOT EXISTS idx_bookings_host_time + ON bookings (host_id, start_at, end_at) WHERE status = 'confirmed'; + +CREATE TABLE IF NOT EXISTS booking_attendees ( + id TEXT PRIMARY KEY, + booking_id TEXT NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, + name TEXT NOT NULL, + email TEXT NOT NULL, + iana_timezone TEXT NOT NULL DEFAULT 'UTC', + is_organizer SMALLINT NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS booking_answers ( + id TEXT PRIMARY KEY, + booking_id TEXT NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, + question_id TEXT NOT NULL REFERENCES event_type_questions(id), + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS webhooks ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + team_id TEXT REFERENCES teams(id) ON DELETE CASCADE, + url TEXT NOT NULL, + events TEXT NOT NULL, -- JSON array: ["booking.created","booking.cancelled",...] + secret_enc TEXT NOT NULL, -- HMAC signing secret, AES-GCM encrypted with CALNODE_ENCRYPTION_KEY (§15) + is_active SMALLINT NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE TABLE IF NOT EXISTS webhook_deliveries ( + id TEXT PRIMARY KEY, + webhook_id TEXT NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE, + booking_id TEXT REFERENCES bookings(id), + event TEXT NOT NULL, + payload TEXT NOT NULL, -- JSON + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'success', 'failed')), + response_status INTEGER, + attempt_count INTEGER NOT NULL DEFAULT 0, + last_attempted_at TEXT +); + +CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + payload TEXT NOT NULL, -- JSON + run_at TEXT NOT NULL, -- UTC ISO 8601; worker polls WHERE run_at <= now + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'running', 'done', 'failed')), + attempts INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 3, + last_error TEXT, + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE INDEX IF NOT EXISTS idx_jobs_pending + ON jobs (run_at) WHERE status = 'pending'; + +-- +goose Down + +DROP INDEX IF EXISTS idx_jobs_pending; +DROP TABLE IF EXISTS jobs; +DROP TABLE IF EXISTS webhook_deliveries; +DROP TABLE IF EXISTS webhooks; +DROP TABLE IF EXISTS booking_answers; +DROP TABLE IF EXISTS booking_attendees; +DROP INDEX IF EXISTS idx_bookings_host_time; +DROP INDEX IF EXISTS idx_bookings_no_double; +DROP TABLE IF EXISTS bookings; +DROP TABLE IF EXISTS calendar_connections; +DROP TABLE IF EXISTS availability_overrides; +DROP TABLE IF EXISTS availability_rules; +DROP TABLE IF EXISTS event_type_questions; +DROP TABLE IF EXISTS event_types; +DROP TABLE IF EXISTS team_members; +DROP TABLE IF EXISTS teams; +DROP TABLE IF EXISTS api_keys; +DROP TABLE IF EXISTS users; diff --git a/internal/db/migrations/postgres/00002_manage_tokens.sql b/internal/db/migrations/postgres/00002_manage_tokens.sql new file mode 100644 index 0000000..1a65543 --- /dev/null +++ b/internal/db/migrations/postgres/00002_manage_tokens.sql @@ -0,0 +1,14 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS booking_manage_tokens ( + token_hash TEXT PRIMARY KEY, + booking_id TEXT NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE INDEX IF NOT EXISTS idx_manage_tokens_booking + ON booking_manage_tokens (booking_id); + +-- +goose Down +DROP INDEX IF EXISTS idx_manage_tokens_booking; +DROP TABLE IF EXISTS booking_manage_tokens; diff --git a/internal/db/migrations/00003_job_lock_timeout.sql b/internal/db/migrations/postgres/00003_job_lock_timeout.sql similarity index 100% rename from internal/db/migrations/00003_job_lock_timeout.sql rename to internal/db/migrations/postgres/00003_job_lock_timeout.sql diff --git a/internal/db/migrations/postgres/00004_sessions.sql b/internal/db/migrations/postgres/00004_sessions.sql new file mode 100644 index 0000000..122a6e5 --- /dev/null +++ b/internal/db/migrations/postgres/00004_sessions.sql @@ -0,0 +1,17 @@ +-- +goose Up + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, -- 32-byte crypto-random hex; the cookie value + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); + +-- +goose Down + +DROP INDEX IF EXISTS idx_sessions_expires; +DROP INDEX IF EXISTS idx_sessions_user_id; +DROP TABLE IF EXISTS sessions; diff --git a/internal/db/migrations/00005_override_unique.sql b/internal/db/migrations/postgres/00005_override_unique.sql similarity index 100% rename from internal/db/migrations/00005_override_unique.sql rename to internal/db/migrations/postgres/00005_override_unique.sql diff --git a/internal/db/migrations/00006_jobs_type_payload_unique.sql b/internal/db/migrations/postgres/00006_jobs_type_payload_unique.sql similarity index 100% rename from internal/db/migrations/00006_jobs_type_payload_unique.sql rename to internal/db/migrations/postgres/00006_jobs_type_payload_unique.sql diff --git a/internal/db/migrations/postgres/00007_user_prefs.sql b/internal/db/migrations/postgres/00007_user_prefs.sql new file mode 100644 index 0000000..011a07e --- /dev/null +++ b/internal/db/migrations/postgres/00007_user_prefs.sql @@ -0,0 +1,12 @@ +-- +goose Up +ALTER TABLE users ADD COLUMN time_format TEXT NOT NULL DEFAULT '12h'; +ALTER TABLE users ADD COLUMN week_start INTEGER NOT NULL DEFAULT 1; -- 1=Monday, 0=Sunday +-- The two UPDATEs are kept in step with the SQLite migration; they are no-ops +-- here, because ADD COLUMN with a NOT NULL DEFAULT backfills every existing row. +UPDATE users SET time_format = '12h' WHERE time_format IS NULL; +UPDATE users SET week_start = 1 WHERE week_start IS NULL; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00008_date_format.sql b/internal/db/migrations/postgres/00008_date_format.sql new file mode 100644 index 0000000..9b5ab47 --- /dev/null +++ b/internal/db/migrations/postgres/00008_date_format.sql @@ -0,0 +1,10 @@ +-- +goose Up +ALTER TABLE users ADD COLUMN date_format TEXT NOT NULL DEFAULT 'dmy'; +-- The UPDATE is kept in step with the SQLite migration; it is a no-op here, +-- because ADD COLUMN with a NOT NULL DEFAULT backfills every existing row. +UPDATE users SET date_format = 'dmy' WHERE date_format IS NULL; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00009_override_reason.sql b/internal/db/migrations/postgres/00009_override_reason.sql new file mode 100644 index 0000000..c3b8fab --- /dev/null +++ b/internal/db/migrations/postgres/00009_override_reason.sql @@ -0,0 +1,10 @@ +-- +goose Up +ALTER TABLE availability_overrides ADD COLUMN reason TEXT NOT NULL DEFAULT 'day_off'; +-- Back-fill existing rows: custom hours rows get 'custom_hours', unavailable rows keep 'day_off'. +UPDATE availability_overrides SET reason = 'custom_hours' WHERE is_available = 1; +UPDATE availability_overrides SET reason = 'day_off' WHERE is_available = 0; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00010_messaging_prefs.sql b/internal/db/migrations/postgres/00010_messaging_prefs.sql new file mode 100644 index 0000000..dfdcc22 --- /dev/null +++ b/internal/db/migrations/postgres/00010_messaging_prefs.sql @@ -0,0 +1,29 @@ +-- +goose Up +-- User-level notification on/off toggles (all default 1 = on, preserving existing behaviour) +ALTER TABLE users ADD COLUMN notify_confirmation SMALLINT NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN notify_cancellation SMALLINT NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN notify_reschedule SMALLINT NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN notify_reminder SMALLINT NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN notify_host_booking SMALLINT NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN notify_host_cancel SMALLINT NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN notify_host_reschedule SMALLINT NOT NULL DEFAULT 1; + +-- Per-event-type custom notes appended to each email type +ALTER TABLE event_types ADD COLUMN msg_confirmation TEXT; +ALTER TABLE event_types ADD COLUMN msg_cancellation TEXT; +ALTER TABLE event_types ADD COLUMN msg_reschedule TEXT; +ALTER TABLE event_types ADD COLUMN msg_reminder TEXT; + +-- Per-event-type reminder timing list (replaces the hardcoded 24h) +CREATE TABLE event_type_reminders ( + id TEXT PRIMARY KEY, + event_type_id TEXT NOT NULL REFERENCES event_types(id) ON DELETE CASCADE, + hours_before INTEGER NOT NULL CHECK(hours_before > 0), + UNIQUE(event_type_id, hours_before) +); + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. +DROP TABLE IF EXISTS event_type_reminders; diff --git a/internal/db/migrations/postgres/00011_server_settings.sql b/internal/db/migrations/postgres/00011_server_settings.sql new file mode 100644 index 0000000..c85ba6f --- /dev/null +++ b/internal/db/migrations/postgres/00011_server_settings.sql @@ -0,0 +1,20 @@ +-- +goose Up +CREATE TABLE server_settings ( + -- Not an identity column: the row is seeded below with an explicit id and the + -- CHECK makes 1 the only legal value, so a sequence would only be misleading. + id INTEGER PRIMARY KEY CHECK(id = 1), + smtp_host TEXT NOT NULL DEFAULT '', + smtp_port TEXT NOT NULL DEFAULT '587', + smtp_user TEXT NOT NULL DEFAULT '', + smtp_pass_enc TEXT NOT NULL DEFAULT '', + smtp_tls SMALLINT NOT NULL DEFAULT 0, + smtp_starttls SMALLINT NOT NULL DEFAULT 1, + email_from TEXT NOT NULL DEFAULT '', + email_from_name TEXT NOT NULL DEFAULT 'Calnode', + updated_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')) +); +-- Seed the single row so UPDATE statements always find it. +INSERT INTO server_settings (id) VALUES (1) ON CONFLICT DO NOTHING; + +-- +goose Down +DROP TABLE IF EXISTS server_settings; diff --git a/internal/db/migrations/postgres/00012_auth_providers.sql b/internal/db/migrations/postgres/00012_auth_providers.sql new file mode 100644 index 0000000..11c19ca --- /dev/null +++ b/internal/db/migrations/postgres/00012_auth_providers.sql @@ -0,0 +1,33 @@ +-- +goose Up + +-- Email/password login and OAuth provider columns on users. +-- email_login=1 means the user can authenticate with email+password. +-- provider/provider_id store the OAuth identity (only one provider per user). +ALTER TABLE users ADD COLUMN email_login SMALLINT NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN password_hash TEXT; +ALTER TABLE users ADD COLUMN provider TEXT; -- 'google', 'microsoft', etc. +ALTER TABLE users ADD COLUMN provider_id TEXT; -- provider's opaque user ID + +-- Invite tokens: single-use, locked to a specific email, 7-day expiry. +CREATE TABLE invite_tokens ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + used_at TEXT +); + +CREATE INDEX idx_invite_tokens_email ON invite_tokens(email); + +-- +goose Down + +DROP INDEX IF EXISTS idx_invite_tokens_email; +DROP TABLE IF EXISTS invite_tokens; + +-- The SQLite migration rebuilds users here because it cannot drop a column; +-- Postgres drops the four directly, which reaches the same schema. +ALTER TABLE users DROP COLUMN provider_id; +ALTER TABLE users DROP COLUMN provider; +ALTER TABLE users DROP COLUMN password_hash; +ALTER TABLE users DROP COLUMN email_login; diff --git a/internal/db/migrations/postgres/00013_google_oauth_settings.sql b/internal/db/migrations/postgres/00013_google_oauth_settings.sql new file mode 100644 index 0000000..4e89f5f --- /dev/null +++ b/internal/db/migrations/postgres/00013_google_oauth_settings.sql @@ -0,0 +1,8 @@ +-- +goose Up +ALTER TABLE server_settings ADD COLUMN google_client_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN google_client_secret_enc TEXT NOT NULL DEFAULT ''; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00014_booking_answers_question_cascade.sql b/internal/db/migrations/postgres/00014_booking_answers_question_cascade.sql new file mode 100644 index 0000000..119af9a --- /dev/null +++ b/internal/db/migrations/postgres/00014_booking_answers_question_cascade.sql @@ -0,0 +1,20 @@ +-- +goose Up +-- booking_answers.question_id referenced event_type_questions(id) with no +-- ON DELETE rule, so deleting an intake question that already had responses +-- (or deleting an event type that owns such questions) failed with a foreign-key +-- violation surfaced as a 500. The SQLite migration recreates the table because it +-- cannot alter a constraint; Postgres replaces the constraint in place. +-- +-- booking_answers_question_id_fkey is the name Postgres gave the inline REFERENCES +-- in 00001: __fkey. A Postgres install can only have reached this +-- version through that migration, so the name is not a guess. +ALTER TABLE booking_answers + DROP CONSTRAINT booking_answers_question_id_fkey, + ADD CONSTRAINT booking_answers_question_id_fkey + FOREIGN KEY (question_id) REFERENCES event_type_questions(id) ON DELETE CASCADE; + +-- +goose Down +ALTER TABLE booking_answers + DROP CONSTRAINT booking_answers_question_id_fkey, + ADD CONSTRAINT booking_answers_question_id_fkey + FOREIGN KEY (question_id) REFERENCES event_type_questions(id); diff --git a/internal/db/migrations/postgres/00015_calendar_connections_expiry.sql b/internal/db/migrations/postgres/00015_calendar_connections_expiry.sql new file mode 100644 index 0000000..29c5d09 --- /dev/null +++ b/internal/db/migrations/postgres/00015_calendar_connections_expiry.sql @@ -0,0 +1,7 @@ +-- +goose Up +ALTER TABLE calendar_connections ADD COLUMN expiry_at TEXT; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00016_event_types_max_active_bookings.sql b/internal/db/migrations/postgres/00016_event_types_max_active_bookings.sql new file mode 100644 index 0000000..8eeb56b --- /dev/null +++ b/internal/db/migrations/postgres/00016_event_types_max_active_bookings.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- Cap how many active (upcoming, non-cancelled) bookings a single invitee may +-- hold for an event type, keyed by their email. 1 = one at a time (default); +-- 0 = unlimited. Existing rows adopt the default of 1. +ALTER TABLE event_types ADD COLUMN max_active_bookings INTEGER NOT NULL DEFAULT 1; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00017_crypto_keystore.sql b/internal/db/migrations/postgres/00017_crypto_keystore.sql new file mode 100644 index 0000000..d9253a0 --- /dev/null +++ b/internal/db/migrations/postgres/00017_crypto_keystore.sql @@ -0,0 +1,18 @@ +-- +goose Up +CREATE TABLE crypto_keystore ( + -- SQLite's INTEGER PRIMARY KEY is a rowid alias, and keyvault.go inserts + -- without an id and lets it be assigned. Identity reproduces that; BY DEFAULT + -- rather than ALWAYS so an explicit id still inserts (key recovery/rotation). + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + label TEXT NOT NULL UNIQUE, -- 'primary' | 'recovery' + wrapped_dek BYTEA NOT NULL, -- DEK encrypted under this entry's KEK + kdf TEXT NOT NULL, -- 'argon2id' + kdf_salt BYTEA NOT NULL, -- 16 random bytes + kdf_params TEXT NOT NULL, -- JSON: {"m":65536,"t":3,"p":2} + dek_version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- +goose Down +DROP TABLE IF EXISTS crypto_keystore; diff --git a/internal/db/migrations/postgres/00018_user_roles.sql b/internal/db/migrations/postgres/00018_user_roles.sql new file mode 100644 index 0000000..5ec5ffa --- /dev/null +++ b/internal/db/migrations/postgres/00018_user_roles.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- Workspace roles are Member / Admin / Owner (PRD §8.10). is_admin already +-- distinguishes member vs admin; is_owner adds the single-owner tier on top. +-- Owner implies admin. Exactly one owner exists at any time (enforced in app). +ALTER TABLE users ADD COLUMN is_owner SMALLINT NOT NULL DEFAULT 0; + +-- Backfill: the bootstrap user (earliest created) becomes the owner on upgrade. +-- Fresh installs set is_owner at Setup time instead; this no-ops when empty. +UPDATE users SET is_owner = 1, is_admin = 1 +WHERE id = (SELECT id FROM users ORDER BY created_at ASC, id ASC LIMIT 1); + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00019_user_archived.sql b/internal/db/migrations/postgres/00019_user_archived.sql new file mode 100644 index 0000000..fea30d3 --- /dev/null +++ b/internal/db/migrations/postgres/00019_user_archived.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- Member offboarding is archiving (soft-delete), not hard delete: the row and +-- all its links (past bookings, event types, team memberships) are preserved. +-- archived_at NULL = active; a timestamp = archived (login blocked, hidden from +-- default lists, skipped in routing). +ALTER TABLE users ADD COLUMN archived_at TEXT; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00020_user_archived_by.sql b/internal/db/migrations/postgres/00020_user_archived_by.sql new file mode 100644 index 0000000..733b5ae --- /dev/null +++ b/internal/db/migrations/postgres/00020_user_archived_by.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- Track who archived a member so restore can be gated: the owner can restore +-- anyone; an admin can restore only members they archived themselves. +ALTER TABLE users ADD COLUMN archived_by TEXT; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00021_event_type_hosts.sql b/internal/db/migrations/postgres/00021_event_type_hosts.sql new file mode 100644 index 0000000..09123ea --- /dev/null +++ b/internal/db/migrations/postgres/00021_event_type_hosts.sql @@ -0,0 +1,36 @@ +-- +goose Up +-- Routing model: an event type owns a host list. Each host has a role — +-- required (always attends), rotation (one is picked per booking), or optional +-- (joins if free). The three UI modes (Normal/Round-robin/Group) are presets +-- over these roles. See docs/teams-and-routing.md. +CREATE TABLE event_type_hosts ( + id TEXT PRIMARY KEY, + event_type_id TEXT NOT NULL REFERENCES event_types(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'required' + CHECK (role IN ('required', 'rotation', 'optional')), + priority INTEGER NOT NULL DEFAULT 0, + UNIQUE(event_type_id, user_id) +); + +CREATE INDEX idx_event_type_hosts_event ON event_type_hosts(event_type_id); + +-- Round-robin selection strategy for round_robin event types. +ALTER TABLE event_types ADD COLUMN rr_strategy TEXT NOT NULL DEFAULT 'even' + CHECK (rr_strategy IN ('even', 'soonest', 'priority')); + +-- Backfill: every existing event type gets its owner as the single required +-- host, so today's solo events become Normal with the owner as the one host and +-- host resolution is uniform from day one. +-- +-- The id matches what SQLite's lower(hex(randomblob(16))) produces — 32 lowercase +-- hex characters — using gen_random_uuid, which is core since PostgreSQL 13 and so +-- needs no extension. +INSERT INTO event_type_hosts (id, event_type_id, user_id, role, priority) +SELECT replace(gen_random_uuid()::text, '-', ''), id, user_id, 'required', 0 FROM event_types; + +-- +goose Down +DROP TABLE IF EXISTS event_type_hosts; +-- rr_strategy is deliberately left in place, mirroring the SQLite migration: +-- Postgres could drop it, but a down that lands on a different schema per engine +-- is worse than one that leaves a harmless column behind. diff --git a/internal/db/migrations/postgres/00022_booking_hosts.sql b/internal/db/migrations/postgres/00022_booking_hosts.sql new file mode 100644 index 0000000..aef70bb --- /dev/null +++ b/internal/db/migrations/postgres/00022_booking_hosts.sql @@ -0,0 +1,24 @@ +-- +goose Up +-- Multi-host bookings: a booking can have several hosts (Group/collective, or a +-- round-robin pick plus fixed hosts). bookings.host_id stays the *primary* host +-- (for the double-book guard + back-compat); booking_hosts records everyone who +-- attends. See docs/teams-and-routing.md. +CREATE TABLE booking_hosts ( + id TEXT PRIMARY KEY, + booking_id TEXT NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id), + is_primary SMALLINT NOT NULL DEFAULT 0, + UNIQUE(booking_id, user_id) +); + +CREATE INDEX idx_booking_hosts_booking ON booking_hosts(booking_id); +CREATE INDEX idx_booking_hosts_user ON booking_hosts(user_id); + +-- Backfill: every existing booking gets its host as the single primary host row, +-- so host resolution is uniform from day one. The id matches the shape SQLite's +-- lower(hex(randomblob(16))) produces (32 lowercase hex characters). +INSERT INTO booking_hosts (id, booking_id, user_id, is_primary) +SELECT replace(gen_random_uuid()::text, '-', ''), id, host_id, 1 FROM bookings; + +-- +goose Down +DROP TABLE IF EXISTS booking_hosts; diff --git a/internal/db/migrations/postgres/00023_booking_hosts_event_id.sql b/internal/db/migrations/postgres/00023_booking_hosts_event_id.sql new file mode 100644 index 0000000..67a58fe --- /dev/null +++ b/internal/db/migrations/postgres/00023_booking_hosts_event_id.sql @@ -0,0 +1,17 @@ +-- +goose Up +-- Per-host external calendar event ID. Multi-host bookings (Group) create a +-- calendar event on each assigned host's calendar; we store each one here so it +-- can be moved/cancelled later. The primary host's id also lives in +-- bookings.external_event_id (kept for back-compat); this backfills its row. +ALTER TABLE booking_hosts ADD COLUMN external_event_id TEXT; + +UPDATE booking_hosts +SET external_event_id = ( + SELECT b.external_event_id FROM bookings b WHERE b.id = booking_hosts.booking_id +) +WHERE is_primary = 1; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/00024_idempotency_keys.sql b/internal/db/migrations/postgres/00024_idempotency_keys.sql similarity index 100% rename from internal/db/migrations/00024_idempotency_keys.sql rename to internal/db/migrations/postgres/00024_idempotency_keys.sql diff --git a/internal/db/migrations/postgres/00025_booking_hosts_needs_sync.sql b/internal/db/migrations/postgres/00025_booking_hosts_needs_sync.sql new file mode 100644 index 0000000..53741e6 --- /dev/null +++ b/internal/db/migrations/postgres/00025_booking_hosts_needs_sync.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- needs_sync flags a booking_hosts row whose calendar event is known to be at the +-- WRONG time: set when an inline reschedule move (gcal UpdateEvent) fails, cleared +-- when it succeeds (inline or via the reconciler). The reconciler re-applies the +-- move for flagged rows — closing the one gap the presence/absence passes can't see, +-- since drift can't be inferred from booking state alone. 0 = in sync. +ALTER TABLE booking_hosts ADD COLUMN needs_sync SMALLINT NOT NULL DEFAULT 0; + +-- +goose Down +ALTER TABLE booking_hosts DROP COLUMN needs_sync; diff --git a/internal/db/migrations/00026_event_type_subjects.sql b/internal/db/migrations/postgres/00026_event_type_subjects.sql similarity index 100% rename from internal/db/migrations/00026_event_type_subjects.sql rename to internal/db/migrations/postgres/00026_event_type_subjects.sql diff --git a/internal/db/migrations/00027_webhook_fields.sql b/internal/db/migrations/postgres/00027_webhook_fields.sql similarity index 100% rename from internal/db/migrations/00027_webhook_fields.sql rename to internal/db/migrations/postgres/00027_webhook_fields.sql diff --git a/internal/db/migrations/postgres/00028_tracking_settings.sql b/internal/db/migrations/postgres/00028_tracking_settings.sql new file mode 100644 index 0000000..d7e469e --- /dev/null +++ b/internal/db/migrations/postgres/00028_tracking_settings.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- Tracking / analytics settings (instance-wide, on the singleton row): +-- head_html raw HTML/JS injected into the of the public booking +-- and manage pages (GTM/GA4/Pixel snippets, etc.). +-- tracking_csp_allow optional space-separated CSP source allowlist; when set, the +-- relaxed public-page CSP is tightened to just these origins. +-- datalayer_enabled push booking/cancel/reschedule events into window.dataLayer. +-- datalayer_fields JSON array of field keys to include in those pushes. +ALTER TABLE server_settings ADD COLUMN head_html TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN tracking_csp_allow TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN datalayer_enabled SMALLINT NOT NULL DEFAULT 0; +ALTER TABLE server_settings ADD COLUMN datalayer_fields TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN head_html; +ALTER TABLE server_settings DROP COLUMN tracking_csp_allow; +ALTER TABLE server_settings DROP COLUMN datalayer_enabled; +ALTER TABLE server_settings DROP COLUMN datalayer_fields; diff --git a/internal/db/migrations/00029_branding_settings.sql b/internal/db/migrations/postgres/00029_branding_settings.sql similarity index 100% rename from internal/db/migrations/00029_branding_settings.sql rename to internal/db/migrations/postgres/00029_branding_settings.sql diff --git a/internal/db/migrations/00030_logo_height.sql b/internal/db/migrations/postgres/00030_logo_height.sql similarity index 100% rename from internal/db/migrations/00030_logo_height.sql rename to internal/db/migrations/postgres/00030_logo_height.sql diff --git a/internal/db/migrations/00031_logo_opacity.sql b/internal/db/migrations/postgres/00031_logo_opacity.sql similarity index 100% rename from internal/db/migrations/00031_logo_opacity.sql rename to internal/db/migrations/postgres/00031_logo_opacity.sql diff --git a/internal/db/migrations/00032_calendar_account_kind.sql b/internal/db/migrations/postgres/00032_calendar_account_kind.sql similarity index 100% rename from internal/db/migrations/00032_calendar_account_kind.sql rename to internal/db/migrations/postgres/00032_calendar_account_kind.sql diff --git a/internal/db/migrations/00033_oauth_mcp.sql b/internal/db/migrations/postgres/00033_oauth_mcp.sql similarity index 100% rename from internal/db/migrations/00033_oauth_mcp.sql rename to internal/db/migrations/postgres/00033_oauth_mcp.sql diff --git a/internal/db/migrations/postgres/00034_llm_settings.sql b/internal/db/migrations/postgres/00034_llm_settings.sql new file mode 100644 index 0000000..443df0a --- /dev/null +++ b/internal/db/migrations/postgres/00034_llm_settings.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- Optional LLM layer config (PRD §8.11), stored like SMTP/Google settings on the +-- single server_settings row. Provider-agnostic: any OpenAI-compatible chat-completions +-- endpoint. Off by default; api key encrypted at rest (CALNODE_ENCRYPTION_KEY). +ALTER TABLE server_settings ADD COLUMN llm_endpoint TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN llm_model TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN llm_api_key_enc TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN llm_enabled SMALLINT NOT NULL DEFAULT 0; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN llm_enabled; +ALTER TABLE server_settings DROP COLUMN llm_api_key_enc; +ALTER TABLE server_settings DROP COLUMN llm_model; +ALTER TABLE server_settings DROP COLUMN llm_endpoint; diff --git a/internal/db/migrations/00035_llm_instructions.sql b/internal/db/migrations/postgres/00035_llm_instructions.sql similarity index 100% rename from internal/db/migrations/00035_llm_instructions.sql rename to internal/db/migrations/postgres/00035_llm_instructions.sql diff --git a/internal/db/migrations/postgres/00036_zoom_integration.sql b/internal/db/migrations/postgres/00036_zoom_integration.sql new file mode 100644 index 0000000..82e790e --- /dev/null +++ b/internal/db/migrations/postgres/00036_zoom_integration.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- Zoom OAuth app credentials (one app per instance; each host then connects their +-- own Zoom account via OAuth). +ALTER TABLE server_settings ADD COLUMN zoom_client_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN zoom_client_secret_enc TEXT NOT NULL DEFAULT ''; + +-- Per-host Zoom OAuth tokens. Zoom is a meeting-link provider, not a calendar, so it gets +-- its own table (calendar_connections.provider has a CHECK constraint for calendar kinds). +-- One connection per user (user_id PK). +CREATE TABLE zoom_connections ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + access_token_enc TEXT NOT NULL, + refresh_token_enc TEXT NOT NULL DEFAULT '', + expiry_at TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')) +); + +-- The Zoom meeting id minted for a booking (used to update/delete the meeting on +-- reschedule/cancel). Empty for non-Zoom bookings or manual links. +ALTER TABLE bookings ADD COLUMN zoom_meeting_id TEXT NOT NULL DEFAULT ''; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00037_stripe_payments.sql b/internal/db/migrations/postgres/00037_stripe_payments.sql new file mode 100644 index 0000000..533473d --- /dev/null +++ b/internal/db/migrations/postgres/00037_stripe_payments.sql @@ -0,0 +1,26 @@ +-- +goose Up +-- Stripe API credentials (one Stripe account per instance; admin configures in Settings). +ALTER TABLE server_settings ADD COLUMN stripe_secret_key_enc TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN stripe_publishable_key TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN stripe_webhook_secret_enc TEXT NOT NULL DEFAULT ''; + +-- Per-event-type price. 0 = free (the default; today's flow is unchanged). +ALTER TABLE event_types ADD COLUMN price_cents INTEGER NOT NULL DEFAULT 0; +ALTER TABLE event_types ADD COLUMN currency TEXT NOT NULL DEFAULT 'usd'; + +-- Payment state, separate from booking status so a paid hold can occupy the slot +-- (status='confirmed', so the double-booking guard reserves it) while still awaiting +-- payment (payment_status='pending'); side-effects are deferred until 'paid'. A new +-- booking-status value would have required a SQLite table rebuild (CHECK constraint). +-- none → free booking, no payment involved (default) +-- pending → awaiting Stripe Checkout completion (slot held) +-- paid → payment captured; confirmation side-effects have run +-- refunded → payment refunded (on cancel) +ALTER TABLE bookings ADD COLUMN payment_status TEXT NOT NULL DEFAULT 'none'; +ALTER TABLE bookings ADD COLUMN stripe_session_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE bookings ADD COLUMN stripe_payment_intent_id TEXT NOT NULL DEFAULT ''; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00038_booking_amount_paid.sql b/internal/db/migrations/postgres/00038_booking_amount_paid.sql new file mode 100644 index 0000000..1201282 --- /dev/null +++ b/internal/db/migrations/postgres/00038_booking_amount_paid.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- Record what was actually charged on the booking (immutable payment record), independent +-- of the event type's current price_cents which can change later. Set from the Stripe +-- Checkout session's amount_total/currency at confirmation. 0/'' for free bookings. +ALTER TABLE bookings ADD COLUMN amount_paid_cents INTEGER NOT NULL DEFAULT 0; +ALTER TABLE bookings ADD COLUMN amount_paid_currency TEXT NOT NULL DEFAULT ''; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00039_calendar_account_email.sql b/internal/db/migrations/postgres/00039_calendar_account_email.sql new file mode 100644 index 0000000..714719c --- /dev/null +++ b/internal/db/migrations/postgres/00039_calendar_account_email.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- Identify each connected calendar account so a user can connect several (e.g. work Google +-- + personal Gmail): re-auth of the same account upserts its row, a new account inserts a new +-- row. Used for dedup + display. Existing single connections backfill to '' and keep working +-- (free/busy doesn't need the email); they get a real value on next re-connect. +ALTER TABLE calendar_connections ADD COLUMN account_email TEXT NOT NULL DEFAULT ''; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00040_magic_link_tokens.sql b/internal/db/migrations/postgres/00040_magic_link_tokens.sql new file mode 100644 index 0000000..b871afc --- /dev/null +++ b/internal/db/migrations/postgres/00040_magic_link_tokens.sql @@ -0,0 +1,13 @@ +-- +goose Up +-- One-time, short-lived login links emailed to a user. We store only the SHA-256 of the +-- token (never the raw value); single-use is enforced by used_at. +CREATE TABLE magic_link_tokens ( + token_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + used_at TEXT, + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')) +); + +-- +goose Down +DROP TABLE magic_link_tokens; diff --git a/internal/db/migrations/postgres/00041_native_analytics.sql b/internal/db/migrations/postgres/00041_native_analytics.sql new file mode 100644 index 0000000..e8ba9fd --- /dev/null +++ b/internal/db/migrations/postgres/00041_native_analytics.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- Native GA4 / GTM: store just the ID; the booking page renders the official loader snippet +-- (no need to paste the whole script). Empty = that tag is off. Validated to the ID format on +-- write so the value is safe to interpolate into a script. +ALTER TABLE server_settings ADD COLUMN gtm_container_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN ga4_measurement_id TEXT NOT NULL DEFAULT ''; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00042_livekit.sql b/internal/db/migrations/postgres/00042_livekit.sql new file mode 100644 index 0000000..bb5d4b4 --- /dev/null +++ b/internal/db/migrations/postgres/00042_livekit.sql @@ -0,0 +1,26 @@ +-- LiveKit (self-hostable WebRTC video) as a built-in meeting location. +-- +-- Two parts: +-- 1. Instance-level config columns (server URL + API key/secret, secret encrypted) on +-- server_settings, plus a livekit_room column on bookings — like Zoom/Stripe. +-- 2. Widen the event_types.location_type CHECK to allow 'livekit'. +-- +-- The SQLite migration rebuilds event_types for part 2, and needs NO TRANSACTION plus +-- PRAGMA foreign_keys=OFF to do it without cascade-deleting the child rows. Postgres +-- replaces the constraint in place, so none of that applies: this file runs in goose's +-- transaction like every other one. event_types_location_type_check is the name Postgres +-- gave the inline CHECK in 00001 (
__check). + +-- +goose Up +ALTER TABLE server_settings ADD COLUMN livekit_url TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN livekit_api_key TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN livekit_api_secret_enc TEXT NOT NULL DEFAULT ''; +ALTER TABLE bookings ADD COLUMN livekit_room TEXT NOT NULL DEFAULT ''; + +ALTER TABLE event_types + DROP CONSTRAINT event_types_location_type_check, + ADD CONSTRAINT event_types_location_type_check + CHECK (location_type IN ('zoom','google_meet','teams','custom_video','phone','in_person','link','livekit')); + +-- +goose Down +-- Irreversible widening of a CHECK; the added columns are harmless. No-op down. diff --git a/internal/db/migrations/postgres/00043_livekit_recording.sql b/internal/db/migrations/postgres/00043_livekit_recording.sql new file mode 100644 index 0000000..07a6edb --- /dev/null +++ b/internal/db/migrations/postgres/00043_livekit_recording.sql @@ -0,0 +1,23 @@ +-- +goose Up +-- Meeting recording (LiveKit Egress). Off unless enabled; recordings upload to the same +-- S3 bucket Litestream backs up to (LITESTREAM_* env), under a recordings/ prefix. +ALTER TABLE server_settings ADD COLUMN recordings_enabled SMALLINT NOT NULL DEFAULT 0; + +CREATE TABLE recordings ( + id TEXT PRIMARY KEY, + booking_id TEXT, -- derived from room "booking-"; nullable + room TEXT NOT NULL, + egress_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', -- active | complete | failed + object_key TEXT NOT NULL DEFAULT '', -- S3 key of the finished file + duration_s INTEGER NOT NULL DEFAULT 0, + started_by TEXT NOT NULL DEFAULT '', -- host participant identity + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), + updated_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); +CREATE INDEX idx_recordings_room ON recordings(room); +CREATE INDEX idx_recordings_egress ON recordings(egress_id); + +-- +goose Down +-- Leave the column; drop the table. +DROP TABLE IF EXISTS recordings; diff --git a/internal/db/migrations/postgres/00044_meeting_consents.sql b/internal/db/migrations/postgres/00044_meeting_consents.sql new file mode 100644 index 0000000..5818d0d --- /dev/null +++ b/internal/db/migrations/postgres/00044_meeting_consents.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- In-meeting recording consent — notice + consent-or-leave (Zoom/Teams/Meet model). This is an +-- AUDIT LOG of who acknowledged the recording notice; it does NOT gate recording (recording +-- starts on the host's click). One row per participant identity per room. +CREATE TABLE meeting_consents ( + room TEXT NOT NULL, + participant_identity TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + decision TEXT NOT NULL DEFAULT 'continue', -- continue | leave + decided_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), + PRIMARY KEY (room, participant_identity) +); + +-- +goose Down +DROP TABLE IF EXISTS meeting_consents; diff --git a/internal/db/migrations/postgres/00045_notetaker.sql b/internal/db/migrations/postgres/00045_notetaker.sql new file mode 100644 index 0000000..45cd309 --- /dev/null +++ b/internal/db/migrations/postgres/00045_notetaker.sql @@ -0,0 +1,34 @@ +-- +goose Up +-- Notetaker: transcribe finished recordings (Deepgram) and summarise them (the BYO-LLM layer) +-- into notes attached to the booking. Off unless enabled + a Deepgram key is set. +ALTER TABLE server_settings ADD COLUMN notetaker_enabled SMALLINT NOT NULL DEFAULT 0; +ALTER TABLE server_settings ADD COLUMN stt_api_key_enc TEXT NOT NULL DEFAULT ''; -- Deepgram key (encrypted) + +CREATE TABLE transcripts ( + id TEXT PRIMARY KEY, + booking_id TEXT, -- nullable; grouping key + recording_id TEXT NOT NULL, -- one transcript per recording + room TEXT NOT NULL, + text TEXT NOT NULL DEFAULT '', + segments TEXT NOT NULL DEFAULT '[]', -- JSON [{speaker,start,end,text}] + status TEXT NOT NULL DEFAULT 'complete', -- complete | failed + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), + updated_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); +CREATE INDEX idx_transcripts_booking ON transcripts(booking_id); +CREATE INDEX idx_transcripts_recording ON transcripts(recording_id); + +CREATE TABLE notes ( + id TEXT PRIMARY KEY, + booking_id TEXT NOT NULL, -- one notes doc per booking (regenerable) + content TEXT NOT NULL DEFAULT '', -- markdown summary + model TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'complete', + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), + updated_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); +CREATE UNIQUE INDEX idx_notes_booking ON notes(booking_id); + +-- +goose Down +DROP TABLE IF EXISTS notes; +DROP TABLE IF EXISTS transcripts; diff --git a/internal/db/migrations/00046_legal_links.sql b/internal/db/migrations/postgres/00046_legal_links.sql similarity index 100% rename from internal/db/migrations/00046_legal_links.sql rename to internal/db/migrations/postgres/00046_legal_links.sql diff --git a/internal/db/migrations/00047_event_type_archived.sql b/internal/db/migrations/postgres/00047_event_type_archived.sql similarity index 100% rename from internal/db/migrations/00047_event_type_archived.sql rename to internal/db/migrations/postgres/00047_event_type_archived.sql diff --git a/internal/db/migrations/00048_override_group.sql b/internal/db/migrations/postgres/00048_override_group.sql similarity index 100% rename from internal/db/migrations/00048_override_group.sql rename to internal/db/migrations/postgres/00048_override_group.sql diff --git a/internal/db/migrations/postgres/00049_connection_calendars.sql b/internal/db/migrations/postgres/00049_connection_calendars.sql new file mode 100644 index 0000000..394922e --- /dev/null +++ b/internal/db/migrations/postgres/00049_connection_calendars.sql @@ -0,0 +1,38 @@ +-- +goose Up +-- Per-account calendar selection. A single connected account (calendar_connections, +-- keyed by user_id+provider+account_email) can now expose several calendars, each +-- independently included in free/busy conflict checks and optionally the write target. +-- +-- Deliberately keyed by the STABLE account identity (user_id, provider, account_email), +-- NOT by calendar_connections.id: the connection row is deleted+reinserted (new id) on +-- every OAuth token refresh, so an FK to it would cascade-delete a user's calendar +-- selections on the next hourly refresh. Disconnect flows delete these rows explicitly. +CREATE TABLE IF NOT EXISTS connection_calendars ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + account_email TEXT NOT NULL DEFAULT '', + calendar_id TEXT NOT NULL, -- provider's calendar id ("primary", an address, a URL) + name TEXT NOT NULL DEFAULT '', -- display name (filled from the provider's calendar list) + check_conflicts SMALLINT NOT NULL DEFAULT 1, -- include this calendar in free/busy + is_destination SMALLINT NOT NULL DEFAULT 0, -- write new booking events here (at most one per user) + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), + UNIQUE (user_id, provider, account_email, calendar_id) +); + +CREATE INDEX IF NOT EXISTS idx_connection_calendars_account + ON connection_calendars (user_id, provider, account_email); + +-- Seed from existing connections so current behaviour is preserved on upgrade: each +-- connected account's single calendar becomes one selected calendar with the same flags. +-- ON CONFLICT DO NOTHING is SQLite's INSERT OR IGNORE; the id matches the shape +-- lower(hex(randomblob(16))) produces (32 lowercase hex characters). +INSERT INTO connection_calendars + (id, user_id, provider, account_email, calendar_id, name, check_conflicts, is_destination) +SELECT replace(gen_random_uuid()::text, '-', ''), user_id, provider, COALESCE(account_email, ''), + calendar_id, '', check_conflicts, is_destination +FROM calendar_connections +ON CONFLICT DO NOTHING; + +-- +goose Down +DROP TABLE IF EXISTS connection_calendars; diff --git a/internal/db/migrations/00050_branding_banner.sql b/internal/db/migrations/postgres/00050_branding_banner.sql similarity index 100% rename from internal/db/migrations/00050_branding_banner.sql rename to internal/db/migrations/postgres/00050_branding_banner.sql diff --git a/internal/db/migrations/postgres/00051_booking_attendee_locale.sql b/internal/db/migrations/postgres/00051_booking_attendee_locale.sql new file mode 100644 index 0000000..fc7492d --- /dev/null +++ b/internal/db/migrations/postgres/00051_booking_attendee_locale.sql @@ -0,0 +1,12 @@ +-- +goose Up +-- Captures the attendee's resolved page locale at booking time (mirrors iana_timezone) — +-- this can only be captured now, not reconstructed later, since it needs the actual +-- Accept-Language/cookie/lang= state the visitor saw when they booked. Not yet consumed by +-- emails (mailer has no i18n support yet — see internal-docs/i18n-plan.md); this just +-- captures the data so it exists once that work lands. Existing rows backfill to 'en'. +ALTER TABLE booking_attendees ADD COLUMN locale TEXT NOT NULL DEFAULT 'en'; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00052_msg_greeting.sql b/internal/db/migrations/postgres/00052_msg_greeting.sql new file mode 100644 index 0000000..35f76fa --- /dev/null +++ b/internal/db/migrations/postgres/00052_msg_greeting.sql @@ -0,0 +1,13 @@ +-- +goose Up +-- Per-event-type override for the conversational assistant's opening greeting. Unlike +-- msg_confirmation/msg_cancellation/etc. (seeded with an English default at CreateEventType), +-- this is left NULL by default: the assistant falls back to the locale-keyed +-- "assistant_greeting" translation when unset, so translation keeps working automatically +-- for anyone who doesn't touch it. Only set (admin-authored, untranslated) once an operator +-- explicitly customizes it. +ALTER TABLE event_types ADD COLUMN msg_greeting TEXT; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00053_fallback_locale.sql b/internal/db/migrations/postgres/00053_fallback_locale.sql new file mode 100644 index 0000000..c7caff0 --- /dev/null +++ b/internal/db/migrations/postgres/00053_fallback_locale.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- What a visitor sees when their browser doesn't ask for any locale Calnode supports +-- (default English) — e.g. an operator serving a mostly Spanish-speaking customer base +-- might want Spanish instead. See internal/i18n.ResolveWithFallback. +ALTER TABLE server_settings ADD COLUMN fallback_locale TEXT NOT NULL DEFAULT 'en'; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00054_resend_api_key.sql b/internal/db/migrations/postgres/00054_resend_api_key.sql new file mode 100644 index 0000000..6c2852c --- /dev/null +++ b/internal/db/migrations/postgres/00054_resend_api_key.sql @@ -0,0 +1,19 @@ +-- +goose Up +-- An optional Resend API key, so Calnode can deliver over Resend's HTTPS API instead of +-- SMTP. Needed because several hosting platforms (Railway below Pro, among others) block +-- outbound SMTP on their cheaper plans by dropping the packets, which is indistinguishable +-- from a misconfiguration and cannot be worked around at the SMTP layer at all. +-- +-- Encrypted at rest with the same envelope scheme as smtp_pass_enc; never returned by the +-- API, which exposes only a resend_api_key_set boolean. +-- +-- Presence of this key is what selects the transport: set means use the HTTPS API, empty +-- means fall back to SMTP. That is deliberate over probing SMTP at startup and switching +-- automatically - a probe tests reachability at boot, not at send time, and a working TCP +-- connection is not the same thing as a working delivery path. See internal/mailer/resend.go. +ALTER TABLE server_settings ADD COLUMN resend_api_key_enc TEXT NOT NULL DEFAULT ''; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00055_booking_hosts_calendar_id.sql b/internal/db/migrations/postgres/00055_booking_hosts_calendar_id.sql new file mode 100644 index 0000000..a7d2709 --- /dev/null +++ b/internal/db/migrations/postgres/00055_booking_hosts_calendar_id.sql @@ -0,0 +1,21 @@ +-- +goose Up +-- Which calendar a booking's event was actually written into. +-- +-- Until now only external_event_id was stored, and reschedule/cancel re-resolved the +-- target calendar from the host's CURRENT destination. That was harmless while the +-- destination was effectively fixed at the account's default calendar, but now that a host +-- can pick any calendar inside a connected account, changing that choice would leave every +-- existing booking's event id pointing at a calendar it does not live in: the update or +-- delete resolves to the new calendar, the provider returns 404, the booking cancels in +-- Calnode, and the meeting silently stays on the host's calendar forever. +-- +-- Deliberately nullable with no backfill. Empty means "resolve the way we always did", +-- which is exactly right for rows created before this column existed - their events do live +-- in whatever the destination was and still is. Only new bookings record the calendar, so +-- the guarantee starts applying from here without rewriting history we cannot verify. +ALTER TABLE booking_hosts ADD COLUMN external_calendar_id TEXT; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/00056_bookings_list_indexes.sql b/internal/db/migrations/postgres/00056_bookings_list_indexes.sql similarity index 100% rename from internal/db/migrations/00056_bookings_list_indexes.sql rename to internal/db/migrations/postgres/00056_bookings_list_indexes.sql diff --git a/internal/db/migrations/postgres/00057_event_type_show_taken_slots.sql b/internal/db/migrations/postgres/00057_event_type_show_taken_slots.sql new file mode 100644 index 0000000..6c88e60 --- /dev/null +++ b/internal/db/migrations/postgres/00057_event_type_show_taken_slots.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- Whether the booking page shows already-booked times greyed out instead of hiding +-- them. Requested in discussion #14, tracked as issue #19. +-- +-- Default 0, and that default is the point rather than caution. The slots endpoint is +-- public and unauthenticated, so turning this on makes the host's booked hours legible +-- to anyone with the link. That is a fair trade for a public-hours use case (an intro +-- call, a clinic, a tutor), where a visibly busy calendar communicates demand. It is a +-- privacy regression for an instance fronting a team's internal calendars, which is why +-- it must be chosen per event type and never inherited by surprise. +-- +-- Only starts a booking or calendar conflict removed are shown. Times outside the +-- host's working hours are never rendered, so the shape of the working day is not +-- disclosed by the grid itself. +ALTER TABLE event_types ADD COLUMN show_taken_slots SMALLINT NOT NULL DEFAULT 0; + +-- +goose Down +ALTER TABLE event_types DROP COLUMN show_taken_slots; diff --git a/internal/db/migrations/postgres/00058_webhook_delivery_created_at.sql b/internal/db/migrations/postgres/00058_webhook_delivery_created_at.sql new file mode 100644 index 0000000..2892396 --- /dev/null +++ b/internal/db/migrations/postgres/00058_webhook_delivery_created_at.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- webhook_deliveries had no timestamp of its own, so "the 50 most recent deliveries" +-- was expressed as ORDER BY rowid DESC. That is unportable — PostgreSQL has no rowid — +-- and it was never quite correct here either: SQLite's rowid tracks insertion order +-- only until something renumbers it, and VACUUM is allowed to. +-- +-- The default is a constant empty string rather than a timestamp expression, matching +-- the SQLite half (whose ALTER TABLE ADD COLUMN forbids a parenthesised DEFAULT) so the +-- two engines backfill existing rows identically. New rows get their value bound by the +-- writer (internal/webhook). Rows that predate this migration keep '', which sorts last +-- under ORDER BY created_at DESC — correct, since they are the oldest deliveries on the +-- instance. +ALTER TABLE webhook_deliveries ADD COLUMN created_at TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE webhook_deliveries DROP COLUMN created_at; diff --git a/internal/db/migrations/postgres/00059_text_timestamp_collation.sql b/internal/db/migrations/postgres/00059_text_timestamp_collation.sql new file mode 100644 index 0000000..2b08c0e --- /dev/null +++ b/internal/db/migrations/postgres/00059_text_timestamp_collation.sql @@ -0,0 +1,203 @@ +-- +goose Up +-- Timestamps in Calnode are TEXT and are compared lexicographically on purpose: +-- the job queue claims with `run_at <= ?`, the recordings consent window is +-- `decided_at BETWEEN`, booking overlap is `start_at`/`end_at` against bound +-- strings, sessions and tokens expire on `expires_at > ?`, and several lists are +-- `ORDER BY created_at`. On SQLite that comparison is a byte comparison, always. +-- On PostgreSQL it is a comparison under the column's collation, which by default +-- is the database's — en_US.utf8 on a typical installation, i.e. a linguistic +-- collation that ignores punctuation and spaces at the primary level and orders +-- case at the tertiary one. +-- +-- The schema stores two timestamp layouts on purpose (internal/dbtime: a +-- space-separated `2026-01-01 10:00:00` and a `2026-01-01T10:00:00.000Z`), and a +-- linguistic collation makes no promise about how those two shapes interleave, +-- nor about a shape some future writer or import adds. Pinning the columns to +-- COLLATE "C" makes the ordering byte ordering on both engines, so a predicate +-- proved correct on SQLite means the same thing on PostgreSQL, and it does so in +-- the schema rather than in 40-odd queries that would each have to remember a +-- COLLATE clause. +-- +-- Scope: every TEXT column the tree compares or orders as a time. That is every +-- column named *_at plus jobs.locked_until, and also the four HH:MM +-- availability columns and availability_overrides.date, which are ordered as +-- times too (`ORDER BY day_of_week, start_time`, `ORDER BY date`). 54 columns +-- across 27 tables. internal/db/collation_test.go enumerates the migrated schema +-- and fails if a matching column is not C, so a timestamp column added later +-- cannot quietly miss this. +-- +-- Cost: ALTER COLUMN TYPE takes ACCESS EXCLUSIVE on the table and rebuilds its +-- indexes. Calnode instances are small and this runs once, at the startup that +-- picks the migration up, but it is not a zero-downtime change on a large +-- database. Grouped one statement per table so each is rewritten once. +-- +-- COLLATE "C" is a deterministic collation, so equality keeps comparing bytes and +-- every existing unique index (booking_manage_tokens' hashed PK, the partial +-- idx_bookings_no_double) keeps its exact current meaning. + +ALTER TABLE api_keys + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN last_used_at TYPE TEXT COLLATE "C"; +ALTER TABLE availability_overrides + ALTER COLUMN date TYPE TEXT COLLATE "C", + ALTER COLUMN end_time TYPE TEXT COLLATE "C", + ALTER COLUMN start_time TYPE TEXT COLLATE "C"; +ALTER TABLE availability_rules + ALTER COLUMN end_time TYPE TEXT COLLATE "C", + ALTER COLUMN start_time TYPE TEXT COLLATE "C"; +ALTER TABLE booking_manage_tokens + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN expires_at TYPE TEXT COLLATE "C"; +ALTER TABLE bookings + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN end_at TYPE TEXT COLLATE "C", + ALTER COLUMN start_at TYPE TEXT COLLATE "C", + ALTER COLUMN updated_at TYPE TEXT COLLATE "C"; +ALTER TABLE calendar_connections + ALTER COLUMN channel_expires_at TYPE TEXT COLLATE "C", + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN expiry_at TYPE TEXT COLLATE "C"; +ALTER TABLE connection_calendars + ALTER COLUMN created_at TYPE TEXT COLLATE "C"; +ALTER TABLE crypto_keystore + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN updated_at TYPE TEXT COLLATE "C"; +ALTER TABLE event_types + ALTER COLUMN archived_at TYPE TEXT COLLATE "C", + ALTER COLUMN created_at TYPE TEXT COLLATE "C"; +ALTER TABLE idempotency_keys + ALTER COLUMN created_at TYPE TEXT COLLATE "C"; +ALTER TABLE invite_tokens + ALTER COLUMN expires_at TYPE TEXT COLLATE "C", + ALTER COLUMN used_at TYPE TEXT COLLATE "C"; +ALTER TABLE jobs + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN locked_until TYPE TEXT COLLATE "C", + ALTER COLUMN run_at TYPE TEXT COLLATE "C"; +ALTER TABLE magic_link_tokens + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN expires_at TYPE TEXT COLLATE "C", + ALTER COLUMN used_at TYPE TEXT COLLATE "C"; +ALTER TABLE meeting_consents + ALTER COLUMN decided_at TYPE TEXT COLLATE "C"; +ALTER TABLE notes + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN updated_at TYPE TEXT COLLATE "C"; +ALTER TABLE oauth_access_tokens + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN expires_at TYPE TEXT COLLATE "C", + ALTER COLUMN last_used_at TYPE TEXT COLLATE "C"; +ALTER TABLE oauth_auth_codes + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN expires_at TYPE TEXT COLLATE "C"; +ALTER TABLE oauth_clients + ALTER COLUMN created_at TYPE TEXT COLLATE "C"; +ALTER TABLE recordings + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN updated_at TYPE TEXT COLLATE "C"; +ALTER TABLE server_settings + ALTER COLUMN updated_at TYPE TEXT COLLATE "C"; +ALTER TABLE sessions + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN expires_at TYPE TEXT COLLATE "C"; +ALTER TABLE teams + ALTER COLUMN created_at TYPE TEXT COLLATE "C"; +ALTER TABLE transcripts + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN updated_at TYPE TEXT COLLATE "C"; +ALTER TABLE users + ALTER COLUMN archived_at TYPE TEXT COLLATE "C", + ALTER COLUMN created_at TYPE TEXT COLLATE "C"; +ALTER TABLE webhook_deliveries + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN last_attempted_at TYPE TEXT COLLATE "C"; +ALTER TABLE webhooks + ALTER COLUMN created_at TYPE TEXT COLLATE "C"; +ALTER TABLE zoom_connections + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN expiry_at TYPE TEXT COLLATE "C"; + +-- +goose Down +-- Back to the database default collation. pg_catalog."default" is the spelling for +-- "whatever the database was created with"; there is no way to say "unset". +ALTER TABLE api_keys + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN last_used_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE availability_overrides + ALTER COLUMN date TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN end_time TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN start_time TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE availability_rules + ALTER COLUMN end_time TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN start_time TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE booking_manage_tokens + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN expires_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE bookings + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN end_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN start_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN updated_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE calendar_connections + ALTER COLUMN channel_expires_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN expiry_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE connection_calendars + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE crypto_keystore + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN updated_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE event_types + ALTER COLUMN archived_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE idempotency_keys + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE invite_tokens + ALTER COLUMN expires_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN used_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE jobs + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN locked_until TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN run_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE magic_link_tokens + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN expires_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN used_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE meeting_consents + ALTER COLUMN decided_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE notes + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN updated_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE oauth_access_tokens + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN expires_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN last_used_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE oauth_auth_codes + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN expires_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE oauth_clients + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE recordings + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN updated_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE server_settings + ALTER COLUMN updated_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE sessions + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN expires_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE teams + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE transcripts + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN updated_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE users + ALTER COLUMN archived_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE webhook_deliveries + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN last_attempted_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE webhooks + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE zoom_connections + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN expiry_at TYPE TEXT COLLATE pg_catalog."default"; diff --git a/internal/db/migrations/00001_initial_schema.sql b/internal/db/migrations/sqlite/00001_initial_schema.sql similarity index 100% rename from internal/db/migrations/00001_initial_schema.sql rename to internal/db/migrations/sqlite/00001_initial_schema.sql diff --git a/internal/db/migrations/00002_manage_tokens.sql b/internal/db/migrations/sqlite/00002_manage_tokens.sql similarity index 100% rename from internal/db/migrations/00002_manage_tokens.sql rename to internal/db/migrations/sqlite/00002_manage_tokens.sql diff --git a/internal/db/migrations/sqlite/00003_job_lock_timeout.sql b/internal/db/migrations/sqlite/00003_job_lock_timeout.sql new file mode 100644 index 0000000..b78622d --- /dev/null +++ b/internal/db/migrations/sqlite/00003_job_lock_timeout.sql @@ -0,0 +1,14 @@ +-- +goose Up + +-- locked_until lets the worker reclaim jobs that were left in 'running' +-- state after a process crash. Set to now+N seconds on claim; the reaper +-- at the start of each Poll resets expired running jobs back to 'pending'. +ALTER TABLE jobs ADD COLUMN locked_until TEXT; + +CREATE INDEX IF NOT EXISTS idx_jobs_running_expired + ON jobs (locked_until) WHERE status = 'running'; + +-- +goose Down + +DROP INDEX IF EXISTS idx_jobs_running_expired; +ALTER TABLE jobs DROP COLUMN locked_until; diff --git a/internal/db/migrations/00004_sessions.sql b/internal/db/migrations/sqlite/00004_sessions.sql similarity index 100% rename from internal/db/migrations/00004_sessions.sql rename to internal/db/migrations/sqlite/00004_sessions.sql diff --git a/internal/db/migrations/sqlite/00005_override_unique.sql b/internal/db/migrations/sqlite/00005_override_unique.sql new file mode 100644 index 0000000..62ebd36 --- /dev/null +++ b/internal/db/migrations/sqlite/00005_override_unique.sql @@ -0,0 +1,6 @@ +-- +goose Up +CREATE UNIQUE INDEX IF NOT EXISTS idx_availability_overrides_user_date + ON availability_overrides (user_id, date); + +-- +goose Down +DROP INDEX IF EXISTS idx_availability_overrides_user_date; diff --git a/internal/db/migrations/sqlite/00006_jobs_type_payload_unique.sql b/internal/db/migrations/sqlite/00006_jobs_type_payload_unique.sql new file mode 100644 index 0000000..46a7ae2 --- /dev/null +++ b/internal/db/migrations/sqlite/00006_jobs_type_payload_unique.sql @@ -0,0 +1,9 @@ +-- +goose Up + +-- Prevent duplicate jobs of the same type+payload (e.g. two reminder.send jobs +-- for the same booking_id if enqueueReminder is called more than once). +CREATE UNIQUE INDEX IF NOT EXISTS ux_jobs_type_payload + ON jobs (type, payload); + +-- +goose Down +DROP INDEX IF EXISTS ux_jobs_type_payload; diff --git a/internal/db/migrations/00007_user_prefs.sql b/internal/db/migrations/sqlite/00007_user_prefs.sql similarity index 100% rename from internal/db/migrations/00007_user_prefs.sql rename to internal/db/migrations/sqlite/00007_user_prefs.sql diff --git a/internal/db/migrations/00008_date_format.sql b/internal/db/migrations/sqlite/00008_date_format.sql similarity index 100% rename from internal/db/migrations/00008_date_format.sql rename to internal/db/migrations/sqlite/00008_date_format.sql diff --git a/internal/db/migrations/00009_override_reason.sql b/internal/db/migrations/sqlite/00009_override_reason.sql similarity index 100% rename from internal/db/migrations/00009_override_reason.sql rename to internal/db/migrations/sqlite/00009_override_reason.sql diff --git a/internal/db/migrations/00010_messaging_prefs.sql b/internal/db/migrations/sqlite/00010_messaging_prefs.sql similarity index 100% rename from internal/db/migrations/00010_messaging_prefs.sql rename to internal/db/migrations/sqlite/00010_messaging_prefs.sql diff --git a/internal/db/migrations/00011_server_settings.sql b/internal/db/migrations/sqlite/00011_server_settings.sql similarity index 100% rename from internal/db/migrations/00011_server_settings.sql rename to internal/db/migrations/sqlite/00011_server_settings.sql diff --git a/internal/db/migrations/00012_auth_providers.sql b/internal/db/migrations/sqlite/00012_auth_providers.sql similarity index 100% rename from internal/db/migrations/00012_auth_providers.sql rename to internal/db/migrations/sqlite/00012_auth_providers.sql diff --git a/internal/db/migrations/00013_google_oauth_settings.sql b/internal/db/migrations/sqlite/00013_google_oauth_settings.sql similarity index 100% rename from internal/db/migrations/00013_google_oauth_settings.sql rename to internal/db/migrations/sqlite/00013_google_oauth_settings.sql diff --git a/internal/db/migrations/00014_booking_answers_question_cascade.sql b/internal/db/migrations/sqlite/00014_booking_answers_question_cascade.sql similarity index 100% rename from internal/db/migrations/00014_booking_answers_question_cascade.sql rename to internal/db/migrations/sqlite/00014_booking_answers_question_cascade.sql diff --git a/internal/db/migrations/00015_calendar_connections_expiry.sql b/internal/db/migrations/sqlite/00015_calendar_connections_expiry.sql similarity index 100% rename from internal/db/migrations/00015_calendar_connections_expiry.sql rename to internal/db/migrations/sqlite/00015_calendar_connections_expiry.sql diff --git a/internal/db/migrations/00016_event_types_max_active_bookings.sql b/internal/db/migrations/sqlite/00016_event_types_max_active_bookings.sql similarity index 100% rename from internal/db/migrations/00016_event_types_max_active_bookings.sql rename to internal/db/migrations/sqlite/00016_event_types_max_active_bookings.sql diff --git a/internal/db/migrations/00017_crypto_keystore.sql b/internal/db/migrations/sqlite/00017_crypto_keystore.sql similarity index 100% rename from internal/db/migrations/00017_crypto_keystore.sql rename to internal/db/migrations/sqlite/00017_crypto_keystore.sql diff --git a/internal/db/migrations/00018_user_roles.sql b/internal/db/migrations/sqlite/00018_user_roles.sql similarity index 100% rename from internal/db/migrations/00018_user_roles.sql rename to internal/db/migrations/sqlite/00018_user_roles.sql diff --git a/internal/db/migrations/00019_user_archived.sql b/internal/db/migrations/sqlite/00019_user_archived.sql similarity index 100% rename from internal/db/migrations/00019_user_archived.sql rename to internal/db/migrations/sqlite/00019_user_archived.sql diff --git a/internal/db/migrations/00020_user_archived_by.sql b/internal/db/migrations/sqlite/00020_user_archived_by.sql similarity index 100% rename from internal/db/migrations/00020_user_archived_by.sql rename to internal/db/migrations/sqlite/00020_user_archived_by.sql diff --git a/internal/db/migrations/00021_event_type_hosts.sql b/internal/db/migrations/sqlite/00021_event_type_hosts.sql similarity index 100% rename from internal/db/migrations/00021_event_type_hosts.sql rename to internal/db/migrations/sqlite/00021_event_type_hosts.sql diff --git a/internal/db/migrations/00022_booking_hosts.sql b/internal/db/migrations/sqlite/00022_booking_hosts.sql similarity index 100% rename from internal/db/migrations/00022_booking_hosts.sql rename to internal/db/migrations/sqlite/00022_booking_hosts.sql diff --git a/internal/db/migrations/00023_booking_hosts_event_id.sql b/internal/db/migrations/sqlite/00023_booking_hosts_event_id.sql similarity index 100% rename from internal/db/migrations/00023_booking_hosts_event_id.sql rename to internal/db/migrations/sqlite/00023_booking_hosts_event_id.sql diff --git a/internal/db/migrations/sqlite/00024_idempotency_keys.sql b/internal/db/migrations/sqlite/00024_idempotency_keys.sql new file mode 100644 index 0000000..42573e8 --- /dev/null +++ b/internal/db/migrations/sqlite/00024_idempotency_keys.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- Idempotency keys for POST /v1/bookings. A client (e.g. an automation agent) +-- can safely retry a booking with the same Idempotency-Key header: the original +-- response is replayed verbatim instead of creating a duplicate booking. The key +-- is reserved (status_code NULL) while the original request runs; on success the +-- response is stored, on failure the row is released so a retry can proceed. +-- Rows are purged by the worker 24h after creation. +CREATE TABLE idempotency_keys ( + idempotency_key TEXT PRIMARY KEY, + request_hash TEXT NOT NULL, + status_code INTEGER, -- NULL while the original request is in flight + response_body TEXT, + booking_id TEXT, + created_at TEXT NOT NULL +); + +-- +goose Down +DROP TABLE IF EXISTS idempotency_keys; diff --git a/internal/db/migrations/00025_booking_hosts_needs_sync.sql b/internal/db/migrations/sqlite/00025_booking_hosts_needs_sync.sql similarity index 100% rename from internal/db/migrations/00025_booking_hosts_needs_sync.sql rename to internal/db/migrations/sqlite/00025_booking_hosts_needs_sync.sql diff --git a/internal/db/migrations/sqlite/00026_event_type_subjects.sql b/internal/db/migrations/sqlite/00026_event_type_subjects.sql new file mode 100644 index 0000000..4e58851 --- /dev/null +++ b/internal/db/migrations/sqlite/00026_event_type_subjects.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- Per-event-type custom email subjects for the attendee-facing emails. NULL/empty +-- means "use the built-in subject" — so existing rows and new ones default to the +-- current behaviour. Mirrors the msg_* custom-note columns. +ALTER TABLE event_types ADD COLUMN subj_confirmation TEXT; +ALTER TABLE event_types ADD COLUMN subj_cancellation TEXT; +ALTER TABLE event_types ADD COLUMN subj_reschedule TEXT; +ALTER TABLE event_types ADD COLUMN subj_reminder TEXT; + +-- +goose Down +ALTER TABLE event_types DROP COLUMN subj_confirmation; +ALTER TABLE event_types DROP COLUMN subj_cancellation; +ALTER TABLE event_types DROP COLUMN subj_reschedule; +ALTER TABLE event_types DROP COLUMN subj_reminder; diff --git a/internal/db/migrations/sqlite/00027_webhook_fields.sql b/internal/db/migrations/sqlite/00027_webhook_fields.sql new file mode 100644 index 0000000..01a7397 --- /dev/null +++ b/internal/db/migrations/sqlite/00027_webhook_fields.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- Per-webhook payload field selection: a JSON array of field keys to include in +-- the delivery's "data" object. NULL means "the default set" — the original +-- booking-metadata payload — so existing webhooks keep their exact current shape +-- (and never start emitting attendee PII or answers without being reconfigured). +ALTER TABLE webhooks ADD COLUMN fields TEXT; + +-- +goose Down +ALTER TABLE webhooks DROP COLUMN fields; diff --git a/internal/db/migrations/00028_tracking_settings.sql b/internal/db/migrations/sqlite/00028_tracking_settings.sql similarity index 100% rename from internal/db/migrations/00028_tracking_settings.sql rename to internal/db/migrations/sqlite/00028_tracking_settings.sql diff --git a/internal/db/migrations/sqlite/00029_branding_settings.sql b/internal/db/migrations/sqlite/00029_branding_settings.sql new file mode 100644 index 0000000..3445c88 --- /dev/null +++ b/internal/db/migrations/sqlite/00029_branding_settings.sql @@ -0,0 +1,12 @@ +-- +goose Up +-- Branding (instance-wide, on the singleton row): +-- business_name display name used as the wordmark in emails and on the public +-- booking/manage pages. Falls back to "Calnode" when empty. +-- logo_url absolute https URL to a logo image, shown in the email header +-- and the public page header. Empty = text wordmark only. +ALTER TABLE server_settings ADD COLUMN business_name TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN logo_url TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN business_name; +ALTER TABLE server_settings DROP COLUMN logo_url; diff --git a/internal/db/migrations/sqlite/00030_logo_height.sql b/internal/db/migrations/sqlite/00030_logo_height.sql new file mode 100644 index 0000000..326a38b --- /dev/null +++ b/internal/db/migrations/sqlite/00030_logo_height.sql @@ -0,0 +1,7 @@ +-- +goose Up +-- Logo display height in px (email header); public pages scale it up modestly. +-- Operator-adjustable via a 16–64px slider in Settings → Branding; 28 = sensible default. +ALTER TABLE server_settings ADD COLUMN logo_height INTEGER NOT NULL DEFAULT 28; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN logo_height; diff --git a/internal/db/migrations/sqlite/00031_logo_opacity.sql b/internal/db/migrations/sqlite/00031_logo_opacity.sql new file mode 100644 index 0000000..6a7bdb8 --- /dev/null +++ b/internal/db/migrations/sqlite/00031_logo_opacity.sql @@ -0,0 +1,7 @@ +-- +goose Up +-- Logo opacity as a percentage (20–100); lets operators make the logo subtle. +-- Applied as CSS opacity in emails + public pages. Default 100 = fully opaque. +ALTER TABLE server_settings ADD COLUMN logo_opacity INTEGER NOT NULL DEFAULT 100; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN logo_opacity; diff --git a/internal/db/migrations/sqlite/00032_calendar_account_kind.sql b/internal/db/migrations/sqlite/00032_calendar_account_kind.sql new file mode 100644 index 0000000..0a66861 --- /dev/null +++ b/internal/db/migrations/sqlite/00032_calendar_account_kind.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- Records whether a connected Microsoft calendar is a work/school account or a +-- personal Microsoft account. Personal accounts can't mint Teams-for-Business +-- links, so this gates whether a "teams" event type can auto-generate one. +-- '' = unknown (legacy rows / Google) → treated as capable; real value is +-- captured from the id_token tenant claim on (re)connect. +ALTER TABLE calendar_connections ADD COLUMN account_kind TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE calendar_connections DROP COLUMN account_kind; diff --git a/internal/db/migrations/sqlite/00033_oauth_mcp.sql b/internal/db/migrations/sqlite/00033_oauth_mcp.sql new file mode 100644 index 0000000..a17e378 --- /dev/null +++ b/internal/db/migrations/sqlite/00033_oauth_mcp.sql @@ -0,0 +1,49 @@ +-- +goose Up +-- OAuth 2.1 authorization-server tables backing the MCP "Connect" flow (PRD §11 +-- authorization). Calnode is its own AS for the /mcp resource: clients self-register +-- (dynamic client registration, public PKCE clients), the workspace owner authorizes +-- via the existing session/Google/Microsoft login + a consent screen, and bearer +-- access tokens (hashed, like api_keys) gate /mcp. All token/code values are stored as +-- SHA-256 hashes — the plaintext is only ever returned once to the client. + +CREATE TABLE oauth_clients ( + client_id TEXT PRIMARY KEY, + client_name TEXT NOT NULL DEFAULT '', + redirect_uris TEXT NOT NULL, -- JSON array of allowed redirect URIs + created_at TEXT NOT NULL +); + +CREATE TABLE oauth_auth_codes ( + code_hash TEXT PRIMARY KEY, -- SHA-256 of the authorization code + client_id TEXT NOT NULL, + user_id TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + code_challenge TEXT NOT NULL, -- PKCE S256 challenge + scope TEXT NOT NULL DEFAULT '', + resource TEXT NOT NULL DEFAULT '', -- RFC 8707 resource indicator + expires_at TEXT NOT NULL, -- short-lived (single use) + created_at TEXT NOT NULL +); + +CREATE TABLE oauth_access_tokens ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, -- SHA-256 of the access token + refresh_hash TEXT UNIQUE, -- SHA-256 of the refresh token (nullable) + client_id TEXT NOT NULL, + user_id TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT '', + resource TEXT NOT NULL DEFAULT '', + expires_at TEXT NOT NULL, -- access-token expiry + created_at TEXT NOT NULL, + last_used_at TEXT +); + +CREATE INDEX idx_oauth_tokens_user ON oauth_access_tokens(user_id); +CREATE INDEX idx_oauth_tokens_refresh ON oauth_access_tokens(refresh_hash); + +-- +goose Down +DROP INDEX idx_oauth_tokens_refresh; +DROP INDEX idx_oauth_tokens_user; +DROP TABLE oauth_access_tokens; +DROP TABLE oauth_auth_codes; +DROP TABLE oauth_clients; diff --git a/internal/db/migrations/00034_llm_settings.sql b/internal/db/migrations/sqlite/00034_llm_settings.sql similarity index 100% rename from internal/db/migrations/00034_llm_settings.sql rename to internal/db/migrations/sqlite/00034_llm_settings.sql diff --git a/internal/db/migrations/sqlite/00035_llm_instructions.sql b/internal/db/migrations/sqlite/00035_llm_instructions.sql new file mode 100644 index 0000000..1d08451 --- /dev/null +++ b/internal/db/migrations/sqlite/00035_llm_instructions.sql @@ -0,0 +1,8 @@ +-- +goose Up +-- Admin "Additional instructions" appended to the assistant's base system prompt +-- (tone, business context, do's/don'ts). The base prompt — the tool-calling contract + +-- safety rails — stays in code; this is the customization layer only. +ALTER TABLE server_settings ADD COLUMN llm_extra_instructions TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN llm_extra_instructions; diff --git a/internal/db/migrations/00036_zoom_integration.sql b/internal/db/migrations/sqlite/00036_zoom_integration.sql similarity index 100% rename from internal/db/migrations/00036_zoom_integration.sql rename to internal/db/migrations/sqlite/00036_zoom_integration.sql diff --git a/internal/db/migrations/00037_stripe_payments.sql b/internal/db/migrations/sqlite/00037_stripe_payments.sql similarity index 100% rename from internal/db/migrations/00037_stripe_payments.sql rename to internal/db/migrations/sqlite/00037_stripe_payments.sql diff --git a/internal/db/migrations/00038_booking_amount_paid.sql b/internal/db/migrations/sqlite/00038_booking_amount_paid.sql similarity index 100% rename from internal/db/migrations/00038_booking_amount_paid.sql rename to internal/db/migrations/sqlite/00038_booking_amount_paid.sql diff --git a/internal/db/migrations/00039_calendar_account_email.sql b/internal/db/migrations/sqlite/00039_calendar_account_email.sql similarity index 100% rename from internal/db/migrations/00039_calendar_account_email.sql rename to internal/db/migrations/sqlite/00039_calendar_account_email.sql diff --git a/internal/db/migrations/00040_magic_link_tokens.sql b/internal/db/migrations/sqlite/00040_magic_link_tokens.sql similarity index 100% rename from internal/db/migrations/00040_magic_link_tokens.sql rename to internal/db/migrations/sqlite/00040_magic_link_tokens.sql diff --git a/internal/db/migrations/00041_native_analytics.sql b/internal/db/migrations/sqlite/00041_native_analytics.sql similarity index 100% rename from internal/db/migrations/00041_native_analytics.sql rename to internal/db/migrations/sqlite/00041_native_analytics.sql diff --git a/internal/db/migrations/00042_livekit.sql b/internal/db/migrations/sqlite/00042_livekit.sql similarity index 100% rename from internal/db/migrations/00042_livekit.sql rename to internal/db/migrations/sqlite/00042_livekit.sql diff --git a/internal/db/migrations/00043_livekit_recording.sql b/internal/db/migrations/sqlite/00043_livekit_recording.sql similarity index 100% rename from internal/db/migrations/00043_livekit_recording.sql rename to internal/db/migrations/sqlite/00043_livekit_recording.sql diff --git a/internal/db/migrations/00044_meeting_consents.sql b/internal/db/migrations/sqlite/00044_meeting_consents.sql similarity index 100% rename from internal/db/migrations/00044_meeting_consents.sql rename to internal/db/migrations/sqlite/00044_meeting_consents.sql diff --git a/internal/db/migrations/00045_notetaker.sql b/internal/db/migrations/sqlite/00045_notetaker.sql similarity index 100% rename from internal/db/migrations/00045_notetaker.sql rename to internal/db/migrations/sqlite/00045_notetaker.sql diff --git a/internal/db/migrations/sqlite/00046_legal_links.sql b/internal/db/migrations/sqlite/00046_legal_links.sql new file mode 100644 index 0000000..5fef0ab --- /dev/null +++ b/internal/db/migrations/sqlite/00046_legal_links.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- Legal links (instance-wide, on the singleton settings row): absolute URLs to the +-- operator's own Privacy Policy and Terms. Shown as links in the public booking-page +-- footer and linked from the cookie-consent banner. Empty = the link is hidden. +-- The operator is the data controller; Calnode only surfaces the links they provide. +ALTER TABLE server_settings ADD COLUMN privacy_url TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN terms_url TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN privacy_url; +ALTER TABLE server_settings DROP COLUMN terms_url; diff --git a/internal/db/migrations/sqlite/00047_event_type_archived.sql b/internal/db/migrations/sqlite/00047_event_type_archived.sql new file mode 100644 index 0000000..a863f8d --- /dev/null +++ b/internal/db/migrations/sqlite/00047_event_type_archived.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- Archived event types: soft-hide from the default admin list without deleting the +-- row (deletion is blocked by ON DELETE RESTRICT once any booking exists). NULL = +-- not archived. Archiving also sets is_active = 0, so every existing bookability gate +-- (public booking page, the shared booking-creation core, MCP) already excludes an +-- archived type — no query needs to learn about archived_at to stay correct. Reversible. +ALTER TABLE event_types ADD COLUMN archived_at TEXT; + +-- +goose Down +ALTER TABLE event_types DROP COLUMN archived_at; diff --git a/internal/db/migrations/sqlite/00048_override_group.sql b/internal/db/migrations/sqlite/00048_override_group.sql new file mode 100644 index 0000000..e74d3f7 --- /dev/null +++ b/internal/db/migrations/sqlite/00048_override_group.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- group_id ties together the per-date rows created from a single date-range block +-- (an "out of office" span), so the UI can show and delete them as one entry. NULL +-- for single-date overrides. Slot generation is unchanged — it still reads the +-- individual per-date rows; the group is purely an admin-side convenience. +ALTER TABLE availability_overrides ADD COLUMN group_id TEXT; + +-- +goose Down +ALTER TABLE availability_overrides DROP COLUMN group_id; diff --git a/internal/db/migrations/00049_connection_calendars.sql b/internal/db/migrations/sqlite/00049_connection_calendars.sql similarity index 100% rename from internal/db/migrations/00049_connection_calendars.sql rename to internal/db/migrations/sqlite/00049_connection_calendars.sql diff --git a/internal/db/migrations/sqlite/00050_branding_banner.sql b/internal/db/migrations/sqlite/00050_branding_banner.sql new file mode 100644 index 0000000..ea8a528 --- /dev/null +++ b/internal/db/migrations/sqlite/00050_branding_banner.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- Banner (instance-wide, on the singleton row): an optional full-width image +-- shown below the logo on the public booking/manage pages and in emails. +-- banner_url absolute https URL to a banner image; empty = hidden. +-- banner_opacity 20-100; CSS opacity. 100 = fully opaque. +ALTER TABLE server_settings ADD COLUMN banner_url TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN banner_opacity INTEGER NOT NULL DEFAULT 100; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN banner_url; +ALTER TABLE server_settings DROP COLUMN banner_opacity; diff --git a/internal/db/migrations/00051_booking_attendee_locale.sql b/internal/db/migrations/sqlite/00051_booking_attendee_locale.sql similarity index 100% rename from internal/db/migrations/00051_booking_attendee_locale.sql rename to internal/db/migrations/sqlite/00051_booking_attendee_locale.sql diff --git a/internal/db/migrations/00052_msg_greeting.sql b/internal/db/migrations/sqlite/00052_msg_greeting.sql similarity index 100% rename from internal/db/migrations/00052_msg_greeting.sql rename to internal/db/migrations/sqlite/00052_msg_greeting.sql diff --git a/internal/db/migrations/00053_fallback_locale.sql b/internal/db/migrations/sqlite/00053_fallback_locale.sql similarity index 100% rename from internal/db/migrations/00053_fallback_locale.sql rename to internal/db/migrations/sqlite/00053_fallback_locale.sql diff --git a/internal/db/migrations/00054_resend_api_key.sql b/internal/db/migrations/sqlite/00054_resend_api_key.sql similarity index 100% rename from internal/db/migrations/00054_resend_api_key.sql rename to internal/db/migrations/sqlite/00054_resend_api_key.sql diff --git a/internal/db/migrations/00055_booking_hosts_calendar_id.sql b/internal/db/migrations/sqlite/00055_booking_hosts_calendar_id.sql similarity index 100% rename from internal/db/migrations/00055_booking_hosts_calendar_id.sql rename to internal/db/migrations/sqlite/00055_booking_hosts_calendar_id.sql diff --git a/internal/db/migrations/sqlite/00056_bookings_list_indexes.sql b/internal/db/migrations/sqlite/00056_bookings_list_indexes.sql new file mode 100644 index 0000000..d07b8ad --- /dev/null +++ b/internal/db/migrations/sqlite/00056_bookings_list_indexes.sql @@ -0,0 +1,34 @@ +-- +goose Up +-- Indexes for the paginated bookings list (§9). +-- +-- The two indexes that existed both lead on host_id and are partial +-- (idx_bookings_no_double, idx_bookings_host_time), which serves the double-book +-- guard well and the list not at all. Every paged query planned as: +-- +-- SCAN bookings USING INDEX idx_bookings_no_double +-- USE TEMP B-TREE FOR ORDER BY +-- +-- i.e. sort the entire matching set to hand back 25 rows. Paginating the API without +-- this makes the response smaller but not the work: the cost still grows with every +-- booking ever made. +-- +-- With (start_at, id) the same query plans as a plain index walk and stops at the +-- LIMIT, no sort: +-- +-- SCAN bookings USING INDEX idx_bookings_start_at +-- +-- id is included as the tiebreaker the list orders by; start_at is not unique, and +-- without a second key two bookings at the same time can swap between pages and one +-- of them is never shown. +CREATE INDEX IF NOT EXISTS idx_bookings_start_at + ON bookings (start_at, id); + +-- Filtering to one event type went from a full scan to a search. The trailing +-- ORDER BY term still costs a small in-memory sort within equal start_at groups, +-- which is not worth another index. +CREATE INDEX IF NOT EXISTS idx_bookings_event_type_start + ON bookings (event_type_id, start_at, id); + +-- +goose Down +DROP INDEX IF EXISTS idx_bookings_start_at; +DROP INDEX IF EXISTS idx_bookings_event_type_start; diff --git a/internal/db/migrations/00057_event_type_show_taken_slots.sql b/internal/db/migrations/sqlite/00057_event_type_show_taken_slots.sql similarity index 100% rename from internal/db/migrations/00057_event_type_show_taken_slots.sql rename to internal/db/migrations/sqlite/00057_event_type_show_taken_slots.sql diff --git a/internal/db/migrations/sqlite/00058_webhook_delivery_created_at.sql b/internal/db/migrations/sqlite/00058_webhook_delivery_created_at.sql new file mode 100644 index 0000000..866ba46 --- /dev/null +++ b/internal/db/migrations/sqlite/00058_webhook_delivery_created_at.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- webhook_deliveries had no timestamp of its own, so "the 50 most recent deliveries" +-- was expressed as ORDER BY rowid DESC. That is unportable — PostgreSQL has no rowid — +-- and it was never quite correct here either: SQLite's rowid tracks insertion order +-- only until something renumbers it, and VACUUM is allowed to. +-- +-- The default is a constant empty string rather than a timestamp expression because +-- SQLite's ALTER TABLE ADD COLUMN forbids a parenthesised or non-deterministic DEFAULT. +-- New rows get their value bound by the writer (internal/webhook). Rows that predate +-- this migration keep '', which sorts last under ORDER BY created_at DESC — correct, +-- since they are the oldest deliveries on the instance. +ALTER TABLE webhook_deliveries ADD COLUMN created_at TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE webhook_deliveries DROP COLUMN created_at; diff --git a/internal/db/migrations/sqlite/00059_text_timestamp_collation.sql b/internal/db/migrations/sqlite/00059_text_timestamp_collation.sql new file mode 100644 index 0000000..c6e628d --- /dev/null +++ b/internal/db/migrations/sqlite/00059_text_timestamp_collation.sql @@ -0,0 +1,20 @@ +-- +goose Up +-- No-op on SQLite, deliberately, and the file exists so the two migration +-- directories keep one file per version (TestMigrationDirs_parity enforces that, +-- and TargetVersion is dialect-independent because of it). +-- +-- The Postgres half pins every TEXT timestamp column to COLLATE "C" so that +-- `run_at <= ?`, the consent window, the booking overlap predicates, the token +-- expiries and `ORDER BY created_at` compare bytes there. SQLite has nothing to +-- pin: its only collations are BINARY (the default), NOCASE and RTRIM, and +-- BINARY *is* memcmp. The behaviour this migration buys on Postgres is what +-- SQLite already does, which is why the port could get this far without noticing. +-- +-- A statement is included rather than leaving the section empty because goose +-- treats a migration with no statements as a parse problem, and a SELECT is the +-- cheapest way to say "nothing to do" in a file that still has to be applied and +-- recorded. +SELECT 1; + +-- +goose Down +SELECT 1; diff --git a/internal/db/migrations_internal_test.go b/internal/db/migrations_internal_test.go new file mode 100644 index 0000000..328b3a1 --- /dev/null +++ b/internal/db/migrations_internal_test.go @@ -0,0 +1,58 @@ +package db + +import ( + "io/fs" + "testing" +) + +// TestMigrationDirs_parity is what lets TargetVersion be dialect-independent and +// what stops the two sets drifting: a migration added for one engine and +// forgotten for the other would otherwise only show up when someone ran the other +// engine. +func TestMigrationDirs_parity(t *testing.T) { + sqliteFiles := migrationFiles(t, DialectSQLite) + postgresFiles := migrationFiles(t, DialectPostgres) + + if len(sqliteFiles) != len(postgresFiles) { + t.Fatalf("migration count differs: sqlite %d, postgres %d", len(sqliteFiles), len(postgresFiles)) + } + + for i, name := range sqliteFiles { + if postgresFiles[i] != name { + t.Errorf("migration %d differs: sqlite %q, postgres %q", i, name, postgresFiles[i]) + } + } + + sqliteTarget, err := maxVersion(DialectSQLite.migrationsDir()) + if err != nil { + t.Fatalf("maxVersion(sqlite): %v", err) + } + postgresTarget, err := maxVersion(DialectPostgres.migrationsDir()) + if err != nil { + t.Fatalf("maxVersion(postgres): %v", err) + } + if sqliteTarget != postgresTarget { + t.Errorf("target version differs: sqlite %d, postgres %d", sqliteTarget, postgresTarget) + } + if int(sqliteTarget) != len(sqliteFiles) { + t.Errorf("target version %d does not match file count %d — a gap or a duplicate number", + sqliteTarget, len(sqliteFiles)) + } +} + +// migrationFiles lists a dialect's embedded migrations, sorted (fs.ReadDir sorts +// by name, which for NNNNN_ prefixes is version order). +func migrationFiles(t *testing.T, dialect Dialect) []string { + t.Helper() + + entries, err := fs.ReadDir(migrations, dialect.migrationsDir()) + if err != nil { + t.Fatalf("read %s: %v", dialect.migrationsDir(), err) + } + + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + return names +} diff --git a/internal/db/pool_test.go b/internal/db/pool_test.go new file mode 100644 index 0000000..d38c133 --- /dev/null +++ b/internal/db/pool_test.go @@ -0,0 +1,167 @@ +package db_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/calnode/calnode/internal/config" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" +) + +// unreachablePostgresDSN is a syntactically valid Postgres URL pointed at +// nothing. sql.Open is lazy — pgx parses the DSN and no connection is attempted +// until a query runs — so the pool settings can be read back from Stats() +// without a server anywhere near the test. +const unreachablePostgresDSN = "postgres://calnode:pw@127.0.0.1:5432/calnode?sslmode=disable" + +func TestOpenDB_poolSizeFromEnv(t *testing.T) { + tests := []struct { + name string + open string // DB_MAX_OPEN_CONNS, "" = unset + idle string // DB_MAX_IDLE_CONNS, "" = unset + wantOpen int + }{ + {name: "unset uses the defaults", wantOpen: config.DefaultDBMaxOpenConns}, + {name: "raised", open: "40", idle: "10", wantOpen: 40}, + {name: "lowered to one", open: "1", idle: "1", wantOpen: 1}, + {name: "zero is not positive, so the default stands", open: "0", wantOpen: config.DefaultDBMaxOpenConns}, + {name: "negative is not positive either", open: "-4", wantOpen: config.DefaultDBMaxOpenConns}, + {name: "unparsable falls back", open: "lots", wantOpen: config.DefaultDBMaxOpenConns}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + setPoolEnv(t, tc.open, tc.idle) + + handle, err := db.OpenDB(unreachablePostgresDSN) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + defer handle.Close() + + if got := handle.Stats().MaxOpenConnections; got != tc.wantOpen { + t.Errorf("MaxOpenConnections = %d; want %d", got, tc.wantOpen) + } + }) + } +} + +// TestOpenDB_withPoolBeatsEnv covers the escape hatch: a caller that must not +// follow the environment. +func TestOpenDB_withPoolBeatsEnv(t *testing.T) { + setPoolEnv(t, "40", "20") + + handle, err := db.OpenDB(unreachablePostgresDSN, db.WithPool(3, 2)) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + defer handle.Close() + + if got := handle.Stats().MaxOpenConnections; got != 3 { + t.Errorf("MaxOpenConnections = %d; want 3 (WithPool, not the environment)", got) + } +} + +// TestOpenDB_sqlitePoolIsNotConfigurable is the correctness guarantee, not a +// preference: SQLite's single connection is what serialises write transactions +// (ARCHITECTURE §17, and it is why booking.lockHosts is a no-op there), and the +// pragmas are connection-scoped. An operator who sets DB_MAX_OPEN_CONNS for +// their Postgres instance and later moves the same environment onto a SQLite one +// must not silently lose that. +func TestOpenDB_sqlitePoolIsNotConfigurable(t *testing.T) { + setPoolEnv(t, "40", "20") + + for _, url := range []string{ + "sqlite://:memory:", + "sqlite://" + filepath.Join(t.TempDir(), "calnode.db"), + } { + handle, err := db.OpenDB(url, db.WithPool(40, 20)) + if err != nil { + t.Fatalf("OpenDB(%s): %v", url, err) + } + if got := handle.Stats().MaxOpenConnections; got != 1 { + t.Errorf("OpenDB(%s): MaxOpenConnections = %d; want 1 whatever the environment says", url, got) + } + handle.Close() + } +} + +// TestPostgres_idleLimitApplied measures the idle half against a real server. +// database/sql exposes the open limit through Stats() but not the idle one, so +// the only honest way to check SetMaxIdleConns took effect is to occupy several +// connections at once and count what the pool keeps when they are handed back. +func TestPostgres_idleLimitApplied(t *testing.T) { + dsn := dbtest.PostgresDSN() + if dsn == "" { + t.Skipf("%s is not set; nothing to run against PostgreSQL", dbtest.DSNEnv) + } + + const maxOpen, maxIdle = 4, 1 + handle, err := db.OpenDB(dsn, db.WithPool(maxOpen, maxIdle)) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + defer handle.Close() + + ctx := context.Background() + + // A transaction holds a connection for its lifetime, so four of them force + // four real connections open. No schema is touched: this is the pool, not the + // database. + txs := make([]*db.Tx, 0, maxOpen) + for i := 0; i < maxOpen; i++ { + tx, err := handle.BeginTx(ctx, nil) + if err != nil { + t.Fatalf("BeginTx %d: %v", i, err) + } + var one int + if err := tx.QueryRowContext(ctx, `SELECT 1`).Scan(&one); err != nil { + t.Fatalf("SELECT 1 in tx %d: %v", i, err) + } + txs = append(txs, tx) + } + if got := handle.Stats().OpenConnections; got != maxOpen { + t.Errorf("OpenConnections while %d transactions are live = %d; want %d", maxOpen, got, maxOpen) + } + + for i, tx := range txs { + if err := tx.Commit(); err != nil { + t.Fatalf("Commit %d: %v", i, err) + } + } + + // Every connection is back in the pool now; the idle limit decides how many + // are kept rather than closed. + if got := handle.Stats().Idle; got > maxIdle { + t.Errorf("Idle after returning %d connections = %d; want at most %d", maxOpen, got, maxIdle) + } + t.Logf("pool: max open %d, max idle %d, idle after release %d", + handle.Stats().MaxOpenConnections, maxIdle, handle.Stats().Idle) +} + +// setPoolEnv sets or unsets both knobs for one test. t.Setenv restores the +// previous value at the end and refuses to run in a parallel test, which is what +// keeps these from leaking into the rest of the package. +func setPoolEnv(t *testing.T, open, idle string) { + t.Helper() + for _, kv := range []struct{ key, value string }{ + {"DB_MAX_OPEN_CONNS", open}, + {"DB_MAX_IDLE_CONNS", idle}, + } { + if kv.value == "" { + // t.Setenv has no "unset" mode; do it by hand and restore by hand. + previous, had := os.LookupEnv(kv.key) + os.Unsetenv(kv.key) + t.Cleanup(func() { + if had { + os.Setenv(kv.key, previous) + } + }) + continue + } + t.Setenv(kv.key, kv.value) + } +} diff --git a/internal/db/postgres_test.go b/internal/db/postgres_test.go new file mode 100644 index 0000000..4e75e69 --- /dev/null +++ b/internal/db/postgres_test.go @@ -0,0 +1,622 @@ +package db_test + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "net/url" + "os" + "regexp" + "slices" + "testing" + + "github.com/calnode/calnode/internal/db" +) + +// The PostgreSQL tests need a server, so they are opt-in: with +// CALNODE_TEST_POSTGRES_DSN unset they skip, and upstream CI and anyone running +// the SQLite build see no change. Point it at a database you are happy for a test +// to create and drop schemas in, e.g. +// +// CALNODE_TEST_POSTGRES_DSN=postgres://postgres:pw@127.0.0.1:5432/calnode?sslmode=disable +const postgresDSNEnv = "CALNODE_TEST_POSTGRES_DSN" + +// knownMigrationCount is the version a fully-migrated database must report. It is a +// sanity check on the embedded set, not a property of PostgreSQL, so it moves with +// every migration added — one named constant rather than the same literal in two +// assertions, so adding one is a single edit that cannot be half-done. +const knownMigrationCount = 59 + +// openTestPostgres returns a handle scoped to a schema of its own, created for +// this test and dropped when it finishes. A schema rather than a database because +// CREATE DATABASE cannot run inside a transaction and cannot be reached over the +// same connection, and a private schema is enough: goose_db_version and every +// table land inside it, so tests neither collide nor leave anything behind. +func openTestPostgres(t *testing.T) *db.DB { + t.Helper() + + dsn := os.Getenv(postgresDSNEnv) + if dsn == "" { + t.Skipf("%s not set; skipping PostgreSQL tests", postgresDSNEnv) + } + + admin, err := db.OpenDB(dsn) + if err != nil { + t.Fatalf("db.OpenDB(admin): %v", err) + } + defer admin.Close() + + // Random hex, not a test name: this is interpolated into DDL, and an + // identifier we generated byte by byte cannot carry a quote or a keyword. + buf := make([]byte, 8) + if _, err := rand.Read(buf); err != nil { + t.Fatalf("rand: %v", err) + } + schema := "calnode_test_" + hex.EncodeToString(buf) + + if _, err := admin.Exec(`CREATE SCHEMA ` + schema); err != nil { + t.Fatalf("create schema %s: %v", schema, err) + } + + handle, err := db.OpenDB(withSearchPath(t, dsn, schema)) + if err != nil { + t.Fatalf("db.OpenDB(%s): %v", schema, err) + } + + t.Cleanup(func() { + handle.Close() + + cleanup, err := db.OpenDB(dsn) + if err != nil { + t.Errorf("reopen to drop schema %s: %v", schema, err) + return + } + defer cleanup.Close() + if _, err := cleanup.Exec(`DROP SCHEMA ` + schema + ` CASCADE`); err != nil { + t.Errorf("drop schema %s: %v", schema, err) + } + }) + + return handle +} + +// withSearchPath adds search_path to the DSN. pgx forwards unrecognised URL +// parameters as server runtime parameters, so this pins every session on the +// handle to the test's schema without a per-connection hook. +func withSearchPath(t *testing.T, dsn, schema string) string { + t.Helper() + + u, err := url.Parse(dsn) + if err != nil { + t.Fatalf("parse %s: %v", postgresDSNEnv, err) + } + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + return u.String() +} + +func TestPostgres_openDialectAndPool(t *testing.T) { + handle := openTestPostgres(t) + + if got := handle.Dialect(); got != db.DialectPostgres { + t.Errorf("dialect = %v; want %v", got, db.DialectPostgres) + } + // The SQLite single-connection rule must not have followed us here. + if got := handle.Stats().MaxOpenConnections; got != 10 { + t.Errorf("MaxOpenConnections = %d; want 10", got) + } + if err := handle.Ping(); err != nil { + t.Fatalf("Ping: %v", err) + } +} + +func TestPostgres_migrateToTargetVersion(t *testing.T) { + handle := openTestPostgres(t) + ctx := context.Background() + + // Before migrating, the goose bookkeeping table is absent → not ready. + if ready, _ := db.SchemaReady(ctx, handle.DB); ready { + t.Error("SchemaReady = true before migrations ran; want false") + } + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + target, err := db.TargetVersion() + if err != nil { + t.Fatalf("TargetVersion: %v", err) + } + applied, err := db.AppliedVersion(ctx, handle.DB) + if err != nil { + t.Fatalf("AppliedVersion: %v", err) + } + if applied != target { + t.Errorf("applied version = %d; want target %d", applied, target) + } + if target != knownMigrationCount { + t.Errorf("target version = %d; want %d (sanity check against the known migration set)", target, knownMigrationCount) + } + + ready, err := db.SchemaReady(ctx, handle.DB) + if err != nil { + t.Fatalf("SchemaReady after migrate: %v", err) + } + if !ready { + t.Error("SchemaReady = false after migrations ran; want true") + } +} + +func TestPostgres_migrateIdempotent(t *testing.T) { + handle := openTestPostgres(t) + + for range 2 { + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + } +} + +// TestPostgres_migrateViaPackageFunc covers the compatibility path: Migrate on a +// bare *sql.DB has to recover the dialect from the driver, and getting that wrong +// would run the SQLite migrations against Postgres. +func TestPostgres_migrateViaPackageFunc(t *testing.T) { + handle := openTestPostgres(t) + + if err := db.Migrate(handle.DB); err != nil { + t.Fatalf("db.Migrate: %v", err) + } + + applied, err := db.AppliedVersion(context.Background(), handle.DB) + if err != nil { + t.Fatalf("AppliedVersion: %v", err) + } + if applied != knownMigrationCount { + t.Errorf("applied version = %d; want %d", applied, knownMigrationCount) + } +} + +func TestPostgres_tablesExist(t *testing.T) { + handle := openTestPostgres(t) + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + tables := []string{ + "users", "api_keys", "teams", "team_members", + "event_types", "event_type_questions", + "availability_rules", "availability_overrides", + "calendar_connections", + "bookings", "booking_attendees", "booking_answers", + "webhooks", "webhook_deliveries", + "jobs", + // Tables added by later migrations, so this also proves the whole set ran. + "server_settings", "crypto_keystore", "event_type_hosts", "booking_hosts", + "idempotency_keys", "oauth_access_tokens", "zoom_connections", + "magic_link_tokens", "recordings", "meeting_consents", "transcripts", + "notes", "connection_calendars", + } + + for _, table := range tables { + var name string + err := handle.QueryRow( + `SELECT table_name FROM information_schema.tables + WHERE table_schema = current_schema() AND table_name = ?`, table, + ).Scan(&name) + if err != nil { + t.Errorf("table %q not found after migration: %v", table, err) + } + } +} + +// TestPostgres_doubleBookingIndex checks the partial unique index carried over as +// a partial index. Without the WHERE clause it would block a re-book of a +// cancelled slot, which is the behaviour the SQLite index deliberately avoids. +func TestPostgres_doubleBookingIndex(t *testing.T) { + handle := openTestPostgres(t) + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + var indexdef string + err := handle.QueryRow( + `SELECT indexdef FROM pg_indexes + WHERE schemaname = current_schema() AND indexname = 'idx_bookings_no_double'`, + ).Scan(&indexdef) + if err != nil { + t.Fatalf("double-booking guard index not found: %v", err) + } + + for _, want := range []string{"UNIQUE", "host_id", "start_at", "WHERE"} { + if !regexp.MustCompile(want).MatchString(indexdef) { + t.Errorf("index definition %q is missing %q", indexdef, want) + } + } +} + +// TestPostgres_flagColumnsStayIntegers is the load-bearing type check: the 0/1 +// flag columns are read into Go ints all over the codebase, so a translation that +// turned them into BOOLEAN would break every one of those scans. +func TestPostgres_flagColumnsStayIntegers(t *testing.T) { + handle := openTestPostgres(t) + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + flags := []struct{ table, column string }{ + {"users", "is_admin"}, + {"users", "is_owner"}, + {"users", "email_login"}, + {"users", "notify_confirmation"}, + {"event_types", "is_active"}, + {"event_types", "is_public"}, + {"event_types", "show_taken_slots"}, + {"event_type_questions", "required"}, + {"availability_overrides", "is_available"}, + {"calendar_connections", "check_conflicts"}, + {"calendar_connections", "is_destination"}, + {"connection_calendars", "check_conflicts"}, + {"booking_attendees", "is_organizer"}, + {"booking_hosts", "is_primary"}, + {"booking_hosts", "needs_sync"}, + {"server_settings", "smtp_tls"}, + {"server_settings", "smtp_starttls"}, + {"server_settings", "llm_enabled"}, + {"server_settings", "recordings_enabled"}, + {"server_settings", "notetaker_enabled"}, + } + + for _, f := range flags { + var dataType string + err := handle.QueryRow( + `SELECT data_type FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?`, + f.table, f.column, + ).Scan(&dataType) + if err != nil { + t.Errorf("%s.%s: %v", f.table, f.column, err) + continue + } + if dataType != "smallint" && dataType != "integer" { + t.Errorf("%s.%s is %q; want an integer type (763 call sites scan these into Go ints)", + f.table, f.column, dataType) + } + } + + // Prove the scan, not just the catalogue: server_settings is seeded by 00011. + var smtpTLS, smtpStartTLS int + if err := handle.QueryRow( + `SELECT smtp_tls, smtp_starttls FROM server_settings WHERE id = ?`, 1, + ).Scan(&smtpTLS, &smtpStartTLS); err != nil { + t.Fatalf("scan flag columns into int: %v", err) + } + if smtpTLS != 0 || smtpStartTLS != 1 { + t.Errorf("seeded flags = (%d, %d); want (0, 1)", smtpTLS, smtpStartTLS) + } +} + +// TestPostgres_timestampDefaultsMatchSQLite pins the format of the defaulted +// timestamp columns. They stay TEXT holding the same strings SQLite writes, +// because every time column in this schema is compared and sorted as a string. +func TestPostgres_timestampDefaultsMatchSQLite(t *testing.T) { + handle := openTestPostgres(t) + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + // users.created_at defaults to SQLite's strftime('%Y-%m-%dT%H:%M:%fZ','now'). + if _, err := handle.Exec( + `INSERT INTO users (id, email, name) VALUES (?, ?, ?)`, + "u1", "a@example.com", "A"); err != nil { + t.Fatalf("insert user: %v", err) + } + + var createdAt string + if err := handle.QueryRow(`SELECT created_at FROM users WHERE id = ?`, "u1").Scan(&createdAt); err != nil { + t.Fatalf("read created_at: %v", err) + } + const isoMillis = `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$` + if !regexp.MustCompile(isoMillis).MatchString(createdAt) { + t.Errorf("users.created_at = %q; want SQLite's %s form", createdAt, isoMillis) + } + + // server_settings.updated_at defaults to SQLite's datetime('now'). + var updatedAt string + if err := handle.QueryRow(`SELECT updated_at FROM server_settings WHERE id = ?`, 1).Scan(&updatedAt); err != nil { + t.Fatalf("read updated_at: %v", err) + } + const secondsUTC = `^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$` + if !regexp.MustCompile(secondsUTC).MatchString(updatedAt) { + t.Errorf("server_settings.updated_at = %q; want SQLite's %s form", updatedAt, secondsUTC) + } +} + +// TestPostgres_identityPrimaryKey covers the one column that relied on SQLite's +// rowid: keyvault.go inserts a keystore entry without an id. +func TestPostgres_identityPrimaryKey(t *testing.T) { + handle := openTestPostgres(t) + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + if _, err := handle.Exec(` + INSERT INTO crypto_keystore + (label, wrapped_dek, kdf, kdf_salt, kdf_params, dek_version, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 1, ?, ?)`, + "primary", []byte("wrapped"), "argon2id", []byte("salt"), `{"m":65536,"t":3,"p":2}`, + "2026-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z"); err != nil { + t.Fatalf("insert keystore entry without an id: %v", err) + } + + var id int64 + var wrapped []byte + if err := handle.QueryRow( + `SELECT id, wrapped_dek FROM crypto_keystore WHERE label = ?`, "primary", + ).Scan(&id, &wrapped); err != nil { + t.Fatalf("read keystore entry: %v", err) + } + if id == 0 { + t.Error("id was not assigned; the identity column is missing") + } + if string(wrapped) != "wrapped" { + t.Errorf("wrapped_dek = %q; want %q (BYTEA round trip)", wrapped, "wrapped") + } +} + +// TestPostgres_wrapperRebinds is the end-to-end proof that the wrapper is what +// makes ?-placeholder SQL work here: the same statement through the bare handle +// must fail. +func TestPostgres_wrapperRebinds(t *testing.T) { + handle := openTestPostgres(t) + ctx := context.Background() + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + if _, err := handle.ExecContext(ctx, + `INSERT INTO users (id, email, name) VALUES (?, ?, ?)`, + "u1", "a@example.com", "A"); err != nil { + t.Fatalf("ExecContext through the wrapper: %v", err) + } + + if _, err := handle.DB.ExecContext(ctx, + `INSERT INTO users (id, email, name) VALUES (?, ?, ?)`, + "u2", "b@example.com", "B"); err == nil { + t.Error("? placeholders succeeded on the bare *sql.DB; the rebinding test proves nothing") + } + + var name string + if err := handle.QueryRowContext(ctx, + `SELECT name FROM users WHERE email = ? AND is_admin = ?`, "a@example.com", 0).Scan(&name); err != nil { + t.Fatalf("QueryRowContext: %v", err) + } + if name != "A" { + t.Errorf("name = %q; want %q", name, "A") + } + + rows, err := handle.QueryContext(ctx, `SELECT id FROM users WHERE email = ?`, "a@example.com") + if err != nil { + t.Fatalf("QueryContext: %v", err) + } + count := 0 + for rows.Next() { + count++ + } + if err := rows.Err(); err != nil { + t.Fatalf("rows.Err: %v", err) + } + rows.Close() + if count != 1 { + t.Errorf("rows returned = %d; want 1", count) + } + + tx, err := handle.BeginTx(ctx, nil) + if err != nil { + t.Fatalf("BeginTx: %v", err) + } + if _, err := tx.ExecContext(ctx, `UPDATE users SET name = ? WHERE id = ?`, "B", "u1"); err != nil { + tx.Rollback() + t.Fatalf("tx.ExecContext: %v", err) + } + if err := tx.QueryRowContext(ctx, `SELECT name FROM users WHERE id = ?`, "u1").Scan(&name); err != nil { + tx.Rollback() + t.Fatalf("tx.QueryRowContext: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("tx.Commit: %v", err) + } + if name != "B" { + t.Errorf("name after tx update = %q; want %q", name, "B") + } + + stmt, err := handle.PrepareContext(ctx, `SELECT COUNT(*) FROM users WHERE email = ?`) + if err != nil { + t.Fatalf("PrepareContext: %v", err) + } + defer stmt.Close() + var n int + if err := stmt.QueryRowContext(ctx, "a@example.com").Scan(&n); err != nil { + t.Fatalf("stmt.QueryRowContext: %v", err) + } + if n != 1 { + t.Errorf("count = %d; want 1", n) + } +} + +// TestPostgres_partialUniqueIndexEnforced exercises the double-booking guard +// itself rather than its definition: the index is the only thing standing between +// two concurrent bookings and a double-booked host now that Postgres has a +// connection pool (see openPostgres). +func TestPostgres_partialUniqueIndexEnforced(t *testing.T) { + handle := openTestPostgres(t) + ctx := context.Background() + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + seed := []struct { + query string + args []any + }{ + {`INSERT INTO users (id, email, name) VALUES (?, ?, ?)`, []any{"h1", "h@example.com", "Host"}}, + {`INSERT INTO event_types (id, user_id, slug, name, duration_minutes) VALUES (?, ?, ?, ?, ?)`, + []any{"e1", "h1", "intro", "Intro", 30}}, + {`INSERT INTO bookings (id, event_type_id, host_id, start_at, end_at) VALUES (?, ?, ?, ?, ?)`, + []any{"b1", "e1", "h1", "2026-01-01T10:00:00.000Z", "2026-01-01T10:30:00.000Z"}}, + } + for _, s := range seed { + if _, err := handle.ExecContext(ctx, s.query, s.args...); err != nil { + t.Fatalf("seed %q: %v", s.query, err) + } + } + + // Same host, same start: refused. + if _, err := handle.ExecContext(ctx, + `INSERT INTO bookings (id, event_type_id, host_id, start_at, end_at) VALUES (?, ?, ?, ?, ?)`, + "b2", "e1", "h1", "2026-01-01T10:00:00.000Z", "2026-01-01T10:30:00.000Z"); err == nil { + t.Error("second booking at the same start time was accepted; the guard index is not enforcing") + } + + // Cancelling the first frees the slot — the point of the WHERE clause. + if _, err := handle.ExecContext(ctx, + `UPDATE bookings SET status = 'cancelled' WHERE id = ?`, "b1"); err != nil { + t.Fatalf("cancel booking: %v", err) + } + if _, err := handle.ExecContext(ctx, + `INSERT INTO bookings (id, event_type_id, host_id, start_at, end_at) VALUES (?, ?, ?, ?, ?)`, + "b3", "e1", "h1", "2026-01-01T10:00:00.000Z", "2026-01-01T10:30:00.000Z"); err != nil { + t.Errorf("re-booking a cancelled slot was refused: %v", err) + } +} + +// TestPostgres_schemaMatchesSQLite migrates both engines and compares the result, +// table by table and column by column. It is the check the translation actually +// needs: every other test here says a specific thing survived, and this one says +// nothing was quietly dropped, renamed or added along the way. +// +// Names only, not types: TEXT versus text and SMALLINT versus integer are the +// translation working as intended, and the types that do matter are pinned by +// TestPostgres_flagColumnsStayIntegers. +func TestPostgres_schemaMatchesSQLite(t *testing.T) { + postgres := openTestPostgres(t) + if err := postgres.Migrate(); err != nil { + t.Fatalf("Migrate(postgres): %v", err) + } + + sqlite, err := db.OpenDB("sqlite://:memory:") + if err != nil { + t.Fatalf("db.OpenDB(sqlite): %v", err) + } + defer sqlite.Close() + if err := sqlite.Migrate(); err != nil { + t.Fatalf("Migrate(sqlite): %v", err) + } + + sqliteSchema := sqliteColumns(t, sqlite) + postgresSchema := postgresColumns(t, postgres) + + // Guard against a vacuous pass: an empty map on either side would satisfy + // every comparison below. + if len(sqliteSchema) < 30 { + t.Fatalf("only %d tables found on SQLite; the comparison would prove nothing", len(sqliteSchema)) + } + + for table, want := range sqliteSchema { + got, ok := postgresSchema[table] + if !ok { + t.Errorf("table %q exists on SQLite and not on Postgres", table) + continue + } + if !slices.Equal(want, got) { + t.Errorf("table %q columns differ\n sqlite: %v\n pgsql: %v", table, want, got) + } + } + for table := range postgresSchema { + if _, ok := sqliteSchema[table]; !ok { + t.Errorf("table %q exists on Postgres and not on SQLite", table) + } + } +} + +// sqliteColumns maps table name to sorted column names, skipping SQLite's own +// bookkeeping tables (sqlite_sequence appears because goose's version table uses +// AUTOINCREMENT). +func sqliteColumns(t *testing.T, handle *db.DB) map[string][]string { + t.Helper() + + rows, err := handle.Query( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name`) + if err != nil { + t.Fatalf("list sqlite tables: %v", err) + } + tables := scanStrings(t, rows) + + schema := make(map[string][]string, len(tables)) + for _, table := range tables { + // PRAGMA table_info takes no placeholder; the name comes from + // sqlite_master, not from input. + rows, err := handle.Query(`SELECT name FROM pragma_table_info('` + table + `')`) + if err != nil { + t.Fatalf("columns of %q: %v", table, err) + } + cols := scanStrings(t, rows) + slices.Sort(cols) + schema[table] = cols + } + return schema +} + +// postgresColumns maps table name to sorted column names for the test schema. +func postgresColumns(t *testing.T, handle *db.DB) map[string][]string { + t.Helper() + + rows, err := handle.Query( + `SELECT table_name FROM information_schema.tables + WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' + ORDER BY table_name`) + if err != nil { + t.Fatalf("list postgres tables: %v", err) + } + tables := scanStrings(t, rows) + + schema := make(map[string][]string, len(tables)) + for _, table := range tables { + rows, err := handle.Query( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = ?`, table) + if err != nil { + t.Fatalf("columns of %q: %v", table, err) + } + cols := scanStrings(t, rows) + slices.Sort(cols) + schema[table] = cols + } + return schema +} + +func scanStrings(t *testing.T, rows *sql.Rows) []string { + t.Helper() + defer rows.Close() + + var out []string + for rows.Next() { + var s string + if err := rows.Scan(&s); err != nil { + t.Fatalf("scan: %v", err) + } + out = append(out, s) + } + if err := rows.Err(); err != nil { + t.Fatalf("rows.Err: %v", err) + } + return out +} diff --git a/internal/db/rebind.go b/internal/db/rebind.go new file mode 100644 index 0000000..f0d5639 --- /dev/null +++ b/internal/db/rebind.go @@ -0,0 +1,113 @@ +package db + +import ( + "strconv" + "strings" +) + +// Rebind rewrites the ? placeholders in query to PostgreSQL's $1…$n form, +// numbering them left to right so the caller's argument order is unchanged. +// +// It is a small lexer rather than a string replace because a ? inside a string +// literal, a quoted identifier or a comment is data, not a placeholder. +// Renumbering one of those corrupts the statement, and the corruption surfaces +// at runtime — as a wrong result or a confusing type error — a long way from the +// code that caused it. +// +// Not recognised, because Calnode's SQL contains none of them and guessing would +// be worse than saying so: PostgreSQL escape strings (E'...', where a backslash +// escapes the closing quote), dollar-quoted bodies ($tag$…$tag$), and +// MSSQL-style [bracketed] identifiers. Statements using those must be written per +// dialect with Dialect.SQL and their own $n. +func Rebind(query string) string { + // Overwhelmingly the common case for DDL and for already-converted SQL. + if !strings.Contains(query, "?") { + return query + } + + var b strings.Builder + b.Grow(len(query) + 8) + + n := 0 + for i := 0; i < len(query); { + switch c := query[i]; { + case c == '\'' || c == '"' || c == '`': + // A quoted run is copied verbatim: single quotes are string + // literals, and both double quotes and backticks are identifier + // quotes SQLite accepts. + end := endOfQuoted(query, i) + b.WriteString(query[i:end]) + i = end + case c == '-' && i+1 < len(query) && query[i+1] == '-': + end := endOfLineComment(query, i) + b.WriteString(query[i:end]) + i = end + case c == '/' && i+1 < len(query) && query[i+1] == '*': + end := endOfBlockComment(query, i) + b.WriteString(query[i:end]) + i = end + case c == '?': + n++ + b.WriteByte('$') + b.WriteString(strconv.Itoa(n)) + i++ + default: + b.WriteByte(c) + i++ + } + } + + return b.String() +} + +// endOfQuoted returns the index just past the quoted run starting at i, where +// query[i] is the opening quote. A doubled quote is an escaped quote and does not +// close the run — that is SQL's only escape under both engines' default settings. +// An unterminated run extends to the end of the string, which hands the malformed +// statement to the engine to reject instead of mangling the remainder. +func endOfQuoted(query string, i int) int { + quote := query[i] + for j := i + 1; j < len(query); j++ { + if query[j] != quote { + continue + } + if j+1 < len(query) && query[j+1] == quote { + j++ // skip the escaped quote; the loop's j++ skips its partner + continue + } + return j + 1 + } + return len(query) +} + +// endOfLineComment returns the index of the newline ending the -- comment at i, +// so the newline itself is copied by the caller and line structure survives. +func endOfLineComment(query string, i int) int { + if k := strings.IndexByte(query[i:], '\n'); k >= 0 { + return i + k + } + return len(query) +} + +// endOfBlockComment returns the index just past the /* */ comment at i. Nesting +// is honoured: PostgreSQL nests block comments, so treating the first */ as the +// end would drop back into "SQL" while still inside a comment. +func endOfBlockComment(query string, i int) int { + depth := 0 + for j := i; j+1 < len(query); { + switch { + case query[j] == '/' && query[j+1] == '*': + depth++ + j += 2 + case query[j] == '*' && query[j+1] == '/': + depth-- + j += 2 + if depth == 0 { + return j + } + default: + j++ + } + } + return len(query) +} diff --git a/internal/db/rebind_test.go b/internal/db/rebind_test.go new file mode 100644 index 0000000..e469f61 --- /dev/null +++ b/internal/db/rebind_test.go @@ -0,0 +1,147 @@ +package db_test + +import ( + "strings" + "testing" + + "github.com/calnode/calnode/internal/db" +) + +func TestRebind(t *testing.T) { + tests := []struct { + name string + query string + want string + }{{ + name: "no placeholders", + query: `SELECT id FROM users`, + want: `SELECT id FROM users`, + }, { + name: "one placeholder", + query: `SELECT id FROM users WHERE email = ?`, + want: `SELECT id FROM users WHERE email = $1`, + }, { + name: "numbered left to right", + query: `UPDATE users SET name = ?, email = ? WHERE id = ?`, + want: `UPDATE users SET name = $1, email = $2 WHERE id = $3`, + }, { + name: "string literal holding a question mark", + query: `SELECT id FROM event_types WHERE name = 'why?' AND slug = ?`, + want: `SELECT id FROM event_types WHERE name = 'why?' AND slug = $1`, + }, { + name: "escaped quote inside a literal does not end it", + query: `SELECT ? WHERE reason = 'it''s a ?' AND id = ?`, + want: `SELECT $1 WHERE reason = 'it''s a ?' AND id = $2`, + }, { + name: "double-quoted identifier", + query: `SELECT "odd?column" FROM t WHERE id = ?`, + want: `SELECT "odd?column" FROM t WHERE id = $1`, + }, { + name: "escaped double quote inside an identifier", + query: `SELECT "a""?b" FROM t WHERE id = ?`, + want: `SELECT "a""?b" FROM t WHERE id = $1`, + }, { + name: "backtick identifier", + query: "SELECT `weird?` FROM t WHERE id = ?", + want: "SELECT `weird?` FROM t WHERE id = $1", + }, { + name: "line comment", + query: "SELECT id -- what about ?\nFROM t WHERE id = ?", + want: "SELECT id -- what about ?\nFROM t WHERE id = $1", + }, { + name: "line comment at end of input", + query: `SELECT id FROM t WHERE id = ? -- trailing ?`, + want: `SELECT id FROM t WHERE id = $1 -- trailing ?`, + }, { + name: "block comment", + query: `SELECT /* ? not a param ? */ id FROM t WHERE id = ?`, + want: `SELECT /* ? not a param ? */ id FROM t WHERE id = $1`, + }, { + name: "nested block comment", + query: `SELECT /* outer /* inner ? */ still ? */ id FROM t WHERE id = ?`, + want: `SELECT /* outer /* inner ? */ still ? */ id FROM t WHERE id = $1`, + }, { + name: "division is not a comment", + query: `SELECT price_cents / 100 FROM event_types WHERE id = ?`, + want: `SELECT price_cents / 100 FROM event_types WHERE id = $1`, + }, { + name: "negative number is not a comment", + query: `SELECT id FROM t WHERE n = -1 AND id = ?`, + want: `SELECT id FROM t WHERE n = -1 AND id = $1`, + }, { + name: "unterminated literal swallows the rest", + query: `SELECT id FROM t WHERE s = 'oops ? and id = ?`, + want: `SELECT id FROM t WHERE s = 'oops ? and id = ?`, + }, { + name: "unterminated block comment swallows the rest", + query: `SELECT id FROM t /* oops ? and id = ?`, + want: `SELECT id FROM t /* oops ? and id = ?`, + }, { + name: "already rebound is left alone", + query: `SELECT id FROM t WHERE id = $1`, + want: `SELECT id FROM t WHERE id = $1`, + }, { + name: "multi-line statement with a comment block", + query: "-- +goose Up\nINSERT INTO t (a, b) VALUES (?, ?)\n -- ? in a comment\n ON CONFLICT DO NOTHING", + want: "-- +goose Up\nINSERT INTO t (a, b) VALUES ($1, $2)\n -- ? in a comment\n ON CONFLICT DO NOTHING", + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := db.Rebind(tt.query); got != tt.want { + t.Errorf("Rebind(%q)\n got %q\nwant %q", tt.query, got, tt.want) + } + }) + } +} + +// TestRebind_manyPlaceholders covers the two-digit boundary: a naive +// implementation that scanned for "$1" or replaced in a fixed order would go +// wrong at ten. +func TestRebind_manyPlaceholders(t *testing.T) { + const n = 12 + query := "INSERT INTO t VALUES (" + strings.Repeat("?, ", n-1) + "?)" + + got := db.Rebind(query) + + want := "INSERT INTO t VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)" + if got != want { + t.Errorf("Rebind(%q)\n got %q\nwant %q", query, got, want) + } +} + +func TestDialectRebind(t *testing.T) { + const query = `SELECT id FROM users WHERE email = ? AND is_admin = ?` + + if got := db.DialectSQLite.Rebind(query); got != query { + t.Errorf("DialectSQLite.Rebind rewrote the query: %q", got) + } + + want := `SELECT id FROM users WHERE email = $1 AND is_admin = $2` + if got := db.DialectPostgres.Rebind(query); got != want { + t.Errorf("DialectPostgres.Rebind = %q; want %q", got, want) + } +} + +func TestDialectSQL(t *testing.T) { + const ( + sqlite = `UPDATE server_settings SET updated_at = datetime('now') WHERE id = 1` + postgres = `UPDATE server_settings SET updated_at = to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') WHERE id = 1` + ) + + if got := db.DialectSQLite.SQL(sqlite, postgres); got != sqlite { + t.Errorf("DialectSQLite.SQL = %q; want the sqlite statement", got) + } + if got := db.DialectPostgres.SQL(sqlite, postgres); got != postgres { + t.Errorf("DialectPostgres.SQL = %q; want the postgres statement", got) + } +} + +func TestDialectString(t *testing.T) { + if got := db.DialectSQLite.String(); got != "sqlite" { + t.Errorf("DialectSQLite.String() = %q; want %q", got, "sqlite") + } + if got := db.DialectPostgres.String(); got != "postgres" { + t.Errorf("DialectPostgres.String() = %q; want %q", got, "postgres") + } +} diff --git a/internal/dbtest/dbtest.go b/internal/dbtest/dbtest.go new file mode 100644 index 0000000..b0cbba4 --- /dev/null +++ b/internal/dbtest/dbtest.go @@ -0,0 +1,189 @@ +// Package dbtest opens a migrated database for a test, on whichever engine the +// environment selects. +// +// Unset CALNODE_TEST_POSTGRES_DSN — the default, and what upstream CI and every +// other contributor sees — means in-memory SQLite, exactly as before. Set it, and +// the same tests run against that PostgreSQL server, each in a schema of its own +// that is created before the migrations and dropped afterwards. Nothing here changes +// what a test asserts; it changes which engine has to satisfy it. +package dbtest + +import ( + "context" + "crypto/rand" + "encoding/hex" + "net/url" + "os" + "testing" + "time" + + "github.com/calnode/calnode/internal/db" +) + +// DSNEnv names the environment variable that switches the suite onto PostgreSQL. +const DSNEnv = "CALNODE_TEST_POSTGRES_DSN" + +// PostgresDSN returns the configured PostgreSQL DSN, or "" when the suite should +// run on SQLite. +func PostgresDSN() string { return os.Getenv(DSNEnv) } + +// Open returns a migrated handle for t, on Postgres when DSNEnv is set and on +// in-memory SQLite otherwise. The handle is closed when t finishes. +func Open(t *testing.T) *db.DB { + t.Helper() + if dsn := PostgresDSN(); dsn != "" { + return openPostgres(t, dsn) + } + return openSQLite(t) +} + +// RequirePostgres returns a migrated Postgres handle, skipping t when DSNEnv is +// unset. For the tests that are only meaningful on Postgres — a race the SQLite +// pool makes impossible, say. +func RequirePostgres(t *testing.T) *db.DB { + t.Helper() + dsn := PostgresDSN() + if dsn == "" { + t.Skipf("%s is not set; nothing to run against PostgreSQL", DSNEnv) + } + return openPostgres(t, dsn) +} + +func openSQLite(t *testing.T) *db.DB { + t.Helper() + h, err := db.OpenDB("sqlite://:memory:") + if err != nil { + t.Fatalf("dbtest: open sqlite: %v", err) + } + t.Cleanup(func() { h.Close() }) + if err := h.Migrate(); err != nil { + t.Fatalf("dbtest: migrate sqlite: %v", err) + } + return h +} + +// openPostgres gives t its own schema on the shared server. +// +// A schema rather than a database: CREATE DATABASE cannot run inside a +// transaction, takes seconds on a busy server, and needs rights a CI service +// container may not grant the test role. A schema is one statement, isolates +// tables and goose's own bookkeeping equally well, and disappears whole with +// DROP ... CASCADE. Everything reaches it through search_path on the connection, +// so no query in the tree needs to name it. +func openPostgres(t *testing.T, dsn string) *db.DB { + t.Helper() + + schema := "calnode_test_" + randomSuffix(t) + + // A second handle on the default search_path, kept open only to create and drop + // the schema: the drop cannot run through a connection whose search_path points + // at the schema being dropped. + admin, err := db.OpenDB(dsn) + if err != nil { + t.Fatalf("dbtest: open postgres (admin): %v", err) + } + if _, err := admin.Exec(`CREATE SCHEMA ` + quoteIdent(schema)); err != nil { + admin.Close() + t.Fatalf("dbtest: create schema %s: %v", schema, err) + } + // Registered first, so it runs last: the schema is dropped after the handle + // below has been closed. + t.Cleanup(func() { + defer admin.Close() + if err := dropSchema(admin, schema); err != nil { + t.Errorf("dbtest: drop schema %s: %v", schema, err) + } + }) + + h, err := db.OpenDB(withSearchPath(t, dsn, schema)) + if err != nil { + t.Fatalf("dbtest: open postgres: %v", err) + } + t.Cleanup(func() { h.Close() }) + + if err := h.Migrate(); err != nil { + t.Fatalf("dbtest: migrate postgres: %v", err) + } + return h +} + +// dropSchema drops the test's schema, retrying while work the test started is still +// finishing. +// +// Calnode's handlers do several things fire-and-forget: a booking spawns goroutines +// to notify hosts, enqueue the webhook and enqueue reminders. Those outlive the test +// BODY, and closing the pool does not stop them — database/sql closes idle +// connections and lets in-flight statements run to completion. DROP SCHEMA CASCADE +// needs an exclusive lock on every object in the schema, so it meets those +// statements and PostgreSQL reports "deadlock detected" (SQLSTATE 40P01). It showed +// up only under `go test ./...`, where packages run concurrently and everything is +// slower — two handler tests out of several hundred, in a different pair each run. +// +// lock_timeout is what makes retrying viable: without it the DROP either waits +// indefinitely or is chosen as a deadlock victim. With it the attempt fails fast and +// cheaply, and the background work it was contending with has finished by the next +// one. The budget is bounded so a schema that genuinely cannot be dropped is +// reported rather than waited on forever — a leaked schema accumulates on a shared +// server, so it is worth failing the test over. +// +// The statements run on ONE pinned connection because lock_timeout is per-session +// and the pool would otherwise be free to apply the SET to a different connection +// than the DROP. Neither statement has placeholders, so nothing needs rebinding. +func dropSchema(admin *db.DB, schema string) error { + ctx := context.Background() + conn, err := admin.Conn(ctx) + if err != nil { + return err + } + defer conn.Close() //nolint:errcheck // returning the drop's error is more useful + + if _, err := conn.ExecContext(ctx, `SET lock_timeout = '250ms'`); err != nil { + return err + } + + const attempts = 20 // ~5s of wall clock, against background work that takes ms + var lastErr error + for i := 0; i < attempts; i++ { + if _, lastErr = conn.ExecContext(ctx, `DROP SCHEMA `+quoteIdent(schema)+` CASCADE`); lastErr == nil { + return nil + } + time.Sleep(250 * time.Millisecond) + } + return lastErr +} + +// withSearchPath returns dsn with search_path pointed at schema. pgx passes +// unrecognised query parameters through as PostgreSQL runtime parameters, so this +// is the whole of the isolation: goose writes goose_db_version there, the +// migrations create their tables there, and every unqualified name in the tree +// resolves there. +func withSearchPath(t *testing.T, dsn, schema string) string { + t.Helper() + u, err := url.Parse(dsn) + if err != nil { + t.Fatalf("dbtest: parse %s: %v", DSNEnv, err) + } + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + return u.String() +} + +// randomSuffix keeps concurrent runs — two packages under `go test ./...`, or two +// CI jobs against one server — out of each other's schemas. +func randomSuffix(t *testing.T) string { + t.Helper() + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + t.Fatalf("dbtest: random schema name: %v", err) + } + return hex.EncodeToString(b) +} + +// quoteIdent quotes a generated schema name. The names are hex from the line +// above, so this is belt and braces rather than a defence — but a schema name +// interpolated into DDL unquoted is a bad habit to leave lying around for the next +// person who passes something else in. +func quoteIdent(name string) string { + return `"` + name + `"` +} diff --git a/internal/dbtest/dbtest_test.go b/internal/dbtest/dbtest_test.go new file mode 100644 index 0000000..25f188c --- /dev/null +++ b/internal/dbtest/dbtest_test.go @@ -0,0 +1,76 @@ +package dbtest + +import ( + "testing" + + "github.com/calnode/calnode/internal/db" +) + +// TestSearchPathIsolation checks the mechanism openPostgres relies on, without +// going through it: that a search_path in the DSN really does land unqualified +// objects in the per-test schema, and that they are invisible from the default +// path. If pgx ever stopped forwarding the parameter, openPostgres would silently +// migrate into the public schema and two packages would fight over one set of +// tables — which reads as flaky tests, not as a broken harness. +func TestSearchPathIsolation(t *testing.T) { + dsn := PostgresDSN() + if dsn == "" { + t.Skipf("%s is not set", DSNEnv) + } + + schema := "calnode_test_" + randomSuffix(t) + + admin, err := db.OpenDB(dsn) + if err != nil { + t.Fatalf("open admin: %v", err) + } + defer admin.Close() + if _, err := admin.Exec(`CREATE SCHEMA ` + quoteIdent(schema)); err != nil { + t.Fatalf("create schema: %v", err) + } + defer func() { + if _, err := admin.Exec(`DROP SCHEMA ` + quoteIdent(schema) + ` CASCADE`); err != nil { + t.Errorf("drop schema: %v", err) + } + }() + + scoped, err := db.OpenDB(withSearchPath(t, dsn, schema)) + if err != nil { + t.Fatalf("open scoped: %v", err) + } + defer scoped.Close() + + var got string + if err := scoped.QueryRow(`SELECT current_schema()`).Scan(&got); err != nil { + t.Fatalf("current_schema: %v", err) + } + if got != schema { + t.Fatalf("current_schema() = %q; want %q — search_path did not reach the server", got, schema) + } + + if _, err := scoped.Exec(`CREATE TABLE isolated (id text)`); err != nil { + t.Fatalf("create table: %v", err) + } + var visible int + if err := admin.QueryRow( + `SELECT COUNT(*) FROM pg_tables WHERE schemaname = current_schema() AND tablename = 'isolated'`). + Scan(&visible); err != nil { + t.Fatalf("count on default path: %v", err) + } + if visible != 0 { + t.Errorf("table 'isolated' is visible on the default search_path; the schema is not isolating anything") + } +} + +// TestOpen returns a handle that has been migrated, on whichever engine is +// configured. goose's bookkeeping table is the engine-independent proof. +func TestOpen(t *testing.T) { + h := Open(t) + var n int + if err := h.QueryRow(`SELECT COUNT(*) FROM goose_db_version`).Scan(&n); err != nil { + t.Fatalf("goose_db_version: %v", err) + } + if n == 0 { + t.Error("goose_db_version is empty; Open returned an unmigrated handle") + } +} diff --git a/internal/dbtime/dbtime.go b/internal/dbtime/dbtime.go new file mode 100644 index 0000000..a47dd76 --- /dev/null +++ b/internal/dbtime/dbtime.go @@ -0,0 +1,35 @@ +// Package dbtime formats "now" for Calnode's TEXT timestamp columns. +// +// Those columns used to be filled by the engine, with datetime('now') or +// strftime('%Y-%m-%dT%H:%M:%fZ','now'). Neither function exists in PostgreSQL, so +// the value is computed here and bound as an ordinary parameter instead: one +// statement that both engines accept, and a timestamp a test can control by +// comparing against a value it computed itself. +// +// The two layouts are the two shapes already in the schema, kept byte-identical to +// what SQLite wrote before. Normalising them to one would be tidier and wrong: the +// stored text is compared lexicographically (recordings scope consents to a time +// window that way) and handed to clients verbatim (GET /v1/notes/{id} returns +// updated_at), so a shape change would silently alter both. +package dbtime + +import "time" + +const ( + // DateTime is SQLite's datetime('now') output: "2006-01-02 15:04:05", UTC, + // second resolution, no zone suffix. + DateTime = "2006-01-02 15:04:05" + + // RFC3339Milli is SQLite's strftime('%Y-%m-%dT%H:%M:%fZ','now') output: + // RFC 3339 with exactly three fractional digits. %f is "SS.SSS", so the + // milliseconds are always present, including a trailing zero. + RFC3339Milli = "2006-01-02T15:04:05.000Z" +) + +// Now returns the current UTC time in the DateTime layout — the replacement for +// datetime('now'). +func Now() string { return time.Now().UTC().Format(DateTime) } + +// NowMilli returns the current UTC time in the RFC3339Milli layout — the +// replacement for strftime('%Y-%m-%dT%H:%M:%fZ','now'). +func NowMilli() string { return time.Now().UTC().Format(RFC3339Milli) } diff --git a/internal/dbtime/dbtime_test.go b/internal/dbtime/dbtime_test.go new file mode 100644 index 0000000..a0ec829 --- /dev/null +++ b/internal/dbtime/dbtime_test.go @@ -0,0 +1,51 @@ +package dbtime_test + +import ( + "regexp" + "testing" + + _ "modernc.org/sqlite" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" +) + +// The whole point of this package is that a Go-computed timestamp is +// indistinguishable from the one SQLite used to write. Assert that against SQLite +// itself rather than against a hand-read of its docs: if the shapes ever diverge, +// existing rows and new rows stop comparing lexicographically and the recordings +// consent window silently returns the wrong set. +func TestLayoutsMatchSQLite(t *testing.T) { + handle, err := db.OpenDB("sqlite://:memory:") + if err != nil { + t.Fatalf("db.OpenDB: %v", err) + } + t.Cleanup(func() { handle.Close() }) + + cases := []struct { + name string + expr string + got string + }{ + {"datetime('now')", `SELECT datetime('now')`, dbtime.Now()}, + {"strftime millis", `SELECT strftime('%Y-%m-%dT%H:%M:%fZ','now')`, dbtime.NowMilli()}, + } + + for _, c := range cases { + var want string + if err := handle.QueryRow(c.expr).Scan(&want); err != nil { + t.Fatalf("%s: %v", c.name, err) + } + // Compare shape, not value: the two clocks are read microseconds apart, so + // the digits legitimately differ. A digit-for-digit mask is what catches a + // layout mistake (missing "Z", " " where "T" belongs, 6 fractional digits). + if mask(want) != mask(c.got) { + t.Errorf("%s: sqlite wrote %q (shape %q), dbtime produced %q (shape %q)", + c.name, want, mask(want), c.got, mask(c.got)) + } + } +} + +var digits = regexp.MustCompile(`[0-9]`) + +func mask(s string) string { return digits.ReplaceAllString(s, "N") } diff --git a/internal/demo/demo.go b/internal/demo/demo.go index e37c6ef..2a50c7f 100644 --- a/internal/demo/demo.go +++ b/internal/demo/demo.go @@ -7,10 +7,11 @@ package demo import ( "context" - "database/sql" "fmt" + "strings" "time" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/uid" ) @@ -29,15 +30,18 @@ const ( // Monday-Friday availability, and a few upcoming sample bookings. Rows are // inserted directly via SQL, bypassing the HTTP layer — the same pattern // already used by this package's handler test fixtures. -func Seed(ctx context.Context, db *sql.DB) error { +func Seed(ctx context.Context, db *db.DB) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("demo seed: begin tx: %w", err) } defer tx.Rollback() //nolint:errcheck + // ON CONFLICT DO NOTHING rather than SQLite's INSERT OR IGNORE: both engines + // accept it. The conflict target is omitted, which covers any unique + // constraint, exactly as OR IGNORE did. if _, err := tx.ExecContext(ctx, - `INSERT OR IGNORE INTO server_settings (id) VALUES (1)`); err != nil { + `INSERT INTO server_settings (id) VALUES (1) ON CONFLICT DO NOTHING`); err != nil { return fmt.Errorf("demo seed: server_settings: %w", err) } @@ -181,25 +185,33 @@ func nextWeekdayAt(now time.Time, minDaysOut, hour int) time.Time { // dynamically rather than hardcoded, since this actively deletes data — // see docs/ARCHITECTURE.md's hardcoded-column-count incident for why a // stale hardcoded list is worth avoiding here specifically. -func Reset(ctx context.Context, db *sql.DB) (err error) { +func Reset(ctx context.Context, db *db.DB) (err error) { tables, err := listTables(ctx, db) if err != nil { return err } - // foreign_keys is connection-scoped and can't be toggled mid-transaction, - // so it's set here, before BeginTx — safe only because the pool is a - // single persistent connection (db.SetMaxOpenConns(1), internal/db/db.go). - // Needed because the delete order below is arbitrary relative to the - // schema's 46 migrations' worth of foreign-key relationships. - if _, ferr := db.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); ferr != nil { - return fmt.Errorf("demo reset: disable foreign keys: %w", ferr) - } - defer func() { - if _, ferr := db.ExecContext(ctx, `PRAGMA foreign_keys = ON`); ferr != nil && err == nil { - err = fmt.Errorf("demo reset: re-enable foreign keys: %w", ferr) + // The wipe order below is arbitrary relative to the schema's 46 migrations' + // worth of foreign keys, so referential integrity has to be stood down for it. + // The engines do that differently and neither way exists on the other: + // + // SQLite — PRAGMA foreign_keys is connection-scoped and cannot be toggled + // mid-transaction, so it is set before BeginTx. Safe only because the pool + // is one persistent connection (db.SetMaxOpenConns(1), internal/db/db.go). + // + // Postgres — there is no equivalent switch short of superuser rights, so a + // single TRUNCATE naming every table CASCADEs through the foreign keys + // instead. TRUNCATE is transactional there, so it stays inside the tx. + if isSQLite(db) { + if _, ferr := db.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); ferr != nil { + return fmt.Errorf("demo reset: disable foreign keys: %w", ferr) } - }() + defer func() { + if _, ferr := db.ExecContext(ctx, `PRAGMA foreign_keys = ON`); ferr != nil && err == nil { + err = fmt.Errorf("demo reset: re-enable foreign keys: %w", ferr) + } + }() + } tx, err := db.BeginTx(ctx, nil) if err != nil { @@ -207,9 +219,21 @@ func Reset(ctx context.Context, db *sql.DB) (err error) { } defer tx.Rollback() //nolint:errcheck - for _, t := range tables { - if _, err = tx.ExecContext(ctx, fmt.Sprintf(`DELETE FROM %q`, t)); err != nil { - return fmt.Errorf("demo reset: delete from %s: %w", t, err) + if isSQLite(db) { + for _, t := range tables { + if _, err = tx.ExecContext(ctx, fmt.Sprintf(`DELETE FROM %q`, t)); err != nil { + return fmt.Errorf("demo reset: delete from %s: %w", t, err) + } + } + } else if len(tables) > 0 { + quoted := make([]string, len(tables)) + for i, t := range tables { + quoted[i] = `"` + t + `"` + } + // #nosec G202 -- every name came from pg_tables in this schema, not from a request. + if _, err = tx.ExecContext(ctx, + `TRUNCATE TABLE `+strings.Join(quoted, ", ")+` CASCADE`); err != nil { + return fmt.Errorf("demo reset: truncate: %w", err) } } if err = tx.Commit(); err != nil { @@ -219,10 +243,20 @@ func Reset(ctx context.Context, db *sql.DB) (err error) { return Seed(ctx, db) } -func listTables(ctx context.Context, db *sql.DB) ([]string, error) { - rows, err := db.QueryContext(ctx, ` - SELECT name FROM sqlite_master - WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name != 'goose_db_version'`) +// isSQLite is a free function rather than an inline comparison because Reset and +// listTables both name their handle "db", which shadows the package the Dialect +// constants live in. +func isSQLite(h *db.DB) bool { return h.Dialect() == db.DialectSQLite } + +func listTables(ctx context.Context, db *db.DB) ([]string, error) { + // sqlite_master has no Postgres counterpart. pg_tables scoped to + // current_schema() is the equivalent, and honouring the current schema is what + // lets a test run inside its own isolated one. + rows, err := db.QueryContext(ctx, db.Dialect().SQL( + `SELECT name FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name != 'goose_db_version'`, + `SELECT tablename FROM pg_tables + WHERE schemaname = current_schema() AND tablename != 'goose_db_version'`)) if err != nil { return nil, fmt.Errorf("demo reset: list tables: %w", err) } diff --git a/internal/demo/demo_test.go b/internal/demo/demo_test.go index 183300c..cc7ecaf 100644 --- a/internal/demo/demo_test.go +++ b/internal/demo/demo_test.go @@ -2,27 +2,20 @@ package demo_test import ( "context" - "database/sql" "testing" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" "github.com/calnode/calnode/internal/demo" ) -func newMigratedDB(t *testing.T) *sql.DB { +func newMigratedDB(t *testing.T) *db.DB { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("db.Open: %v", err) - } - t.Cleanup(func() { database.Close() }) - if err := db.Migrate(database); err != nil { - t.Fatalf("db.Migrate: %v", err) - } + database := dbtest.Open(t) return database } -func assertCount(t *testing.T, ctx context.Context, database *sql.DB, table string, want int) { +func assertCount(t *testing.T, ctx context.Context, database *db.DB, table string, want int) { t.Helper() var got int if err := database.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table).Scan(&got); err != nil { diff --git a/internal/gcal/gcal.go b/internal/gcal/gcal.go index 29b13e0..ed3e5eb 100644 --- a/internal/gcal/gcal.go +++ b/internal/gcal/gcal.go @@ -20,6 +20,7 @@ import ( "github.com/calnode/calnode/internal/calendar" "github.com/calnode/calnode/internal/connstore" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/oauthstore" "github.com/calnode/calnode/internal/secret" "github.com/calnode/calnode/internal/uid" @@ -39,13 +40,13 @@ func (c *Client) InvitesGuests() bool { return true } type Client struct { config *oauth2.Config key [32]byte - db *sql.DB + db *db.DB logger *slog.Logger apiBase string // base URL for Calendar API; overridable in tests } // New creates a Client. encKeyHex is the 64-char hex AES-256 encryption key. -func New(db *sql.DB, clientID, clientSecret, redirectURL, encKeyHex string) (*Client, error) { +func New(db *db.DB, clientID, clientSecret, redirectURL, encKeyHex string) (*Client, error) { b, err := hex.DecodeString(encKeyHex) if err != nil || len(b) != 32 { return nil, fmt.Errorf("gcal: invalid encryption key") diff --git a/internal/gcal/gcal_test.go b/internal/gcal/gcal_test.go index e5c06d1..31780d6 100644 --- a/internal/gcal/gcal_test.go +++ b/internal/gcal/gcal_test.go @@ -2,7 +2,6 @@ package gcal import ( "context" - "database/sql" "strings" "testing" "time" @@ -10,20 +9,16 @@ import ( "golang.org/x/oauth2" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" + "github.com/calnode/calnode/internal/uid" ) // testKeyHex is a valid 64-char hex key (32 bytes) used across tests. const testKeyHex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" -func newTestDB(t *testing.T) *sql.DB { +func newTestDB(t *testing.T) *db.DB { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("newTestDB: open: %v", err) - } - if err := db.Migrate(database); err != nil { - t.Fatalf("newTestDB: migrate: %v", err) - } + database := dbtest.Open(t) t.Cleanup(func() { database.Close() }) return database } @@ -38,7 +33,7 @@ func newTestClient(t *testing.T) *Client { } // seedUser inserts a minimal user row so that calendar_connections FK is satisfied. -func seedUser(t *testing.T, db *sql.DB, userID string) { +func seedUser(t *testing.T, db *db.DB, userID string) { t.Helper() _, err := db.ExecContext(context.Background(), ` INSERT INTO users (id, email, name, iana_timezone, is_admin, created_at) @@ -314,16 +309,19 @@ func TestFreeBusyConnections_returnsConnectedCalendars(t *testing.T) { } // insertConnCal adds a per-account sub-calendar selection row for the google provider. -func insertConnCal(t *testing.T, db *sql.DB, userID, account, calID string, check bool) { +func insertConnCal(t *testing.T, db *db.DB, userID, account, calID string, check bool) { t.Helper() cc := 0 if check { cc = 1 } + // uid.New() rather than lower(hex(randomblob(16))): randomblob is a SQLite + // built-in with no PostgreSQL counterpart, and every other row in these tests + // already gets its id from uid. _, err := db.ExecContext(context.Background(), ` INSERT INTO connection_calendars (id, user_id, provider, account_email, calendar_id, name, check_conflicts, is_destination) - VALUES (lower(hex(randomblob(16))), ?, 'google', ?, ?, '', ?, 0)`, - userID, account, calID, cc) + VALUES (?, ?, 'google', ?, ?, '', ?, 0)`, + uid.New(), userID, account, calID, cc) if err != nil { t.Fatalf("insertConnCal(%q): %v", calID, err) } diff --git a/internal/handler/auth_google_test.go b/internal/handler/auth_google_test.go index 79b9b5a..13f2325 100644 --- a/internal/handler/auth_google_test.go +++ b/internal/handler/auth_google_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "log/slog" "net/http" "net/http/httptest" @@ -10,20 +9,15 @@ import ( "time" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" "github.com/calnode/calnode/internal/handler" ) // authTestSetup opens an in-memory DB, runs migrations, seeds one user, and // returns the handler, database handle, and the seeded user ID. -func authTestSetup(t *testing.T) (*handler.Handler, *sql.DB, string) { +func authTestSetup(t *testing.T) (*handler.Handler, *db.DB, string) { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("db.Open: %v", err) - } - if err := db.Migrate(database); err != nil { - t.Fatalf("db.Migrate: %v", err) - } + database := dbtest.Open(t) t.Cleanup(func() { database.Close() }) userID := "user-auth-test" @@ -35,7 +29,7 @@ func authTestSetup(t *testing.T) (*handler.Handler, *sql.DB, string) { } // seedSession inserts a session row and returns the session id (cookie value). -func seedSession(t *testing.T, db *sql.DB, userID string, ttl time.Duration) string { +func seedSession(t *testing.T, db *db.DB, userID string, ttl time.Duration) string { t.Helper() sessID := "test-session-" + userID expiresAt := time.Now().UTC().Add(ttl).Format(time.RFC3339) diff --git a/internal/handler/availability.go b/internal/handler/availability.go index 6577260..3eb766e 100644 --- a/internal/handler/availability.go +++ b/internal/handler/availability.go @@ -4,8 +4,8 @@ import ( "database/sql" "encoding/json" "net/http" - "strings" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/uid" ) @@ -47,7 +47,7 @@ func (h *Handler) CreateAvailabilityRule(w http.ResponseWriter, r *http.Request) VALUES (?, ?, ?, ?, ?, ?)`, id, user.ID, req.EventTypeID, req.DayOfWeek, req.StartTime, req.EndTime) if err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") { + if db.IsUniqueViolation(err) { h.writeError(w, http.StatusConflict, "a rule for this day and time already exists") return } @@ -177,7 +177,7 @@ func (h *Handler) UpdateAvailabilityRule(w http.ResponseWriter, r *http.Request) `UPDATE availability_rules SET day_of_week=?, start_time=?, end_time=? WHERE id=? AND user_id=?`, current.DayOfWeek, current.StartTime, current.EndTime, id, user.ID) if err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") { + if db.IsUniqueViolation(err) { h.writeError(w, http.StatusConflict, "a rule for this day and time already exists") return } diff --git a/internal/handler/booking_filter_test.go b/internal/handler/booking_filter_test.go index 249adbb..40d1c99 100644 --- a/internal/handler/booking_filter_test.go +++ b/internal/handler/booking_filter_test.go @@ -1,13 +1,13 @@ package handler_test import ( - "database/sql" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/handler" ) @@ -53,7 +53,7 @@ func listIDs(r listResp) []string { } // seedBooking inserts one booking directly. start/end are RFC3339 UTC. -func seedBooking(t *testing.T, db *sql.DB, id, etID, hostID, start, end, status string) { +func seedBooking(t *testing.T, db *db.DB, id, etID, hostID, start, end, status string) { t.Helper() if _, err := db.Exec( `INSERT INTO bookings (id, event_type_id, host_id, start_at, end_at, status) diff --git a/internal/handler/booking_handler.go b/internal/handler/booking_handler.go index 7f0c5d9..2f558d3 100644 --- a/internal/handler/booking_handler.go +++ b/internal/handler/booking_handler.go @@ -15,6 +15,7 @@ import ( "github.com/calnode/calnode/internal/booking" "github.com/calnode/calnode/internal/calendar" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/i18n" "github.com/calnode/calnode/internal/mailer" "github.com/calnode/calnode/internal/slots" @@ -734,7 +735,7 @@ func (h *Handler) CreateBooking(w http.ResponseWriter, r *http.Request) { if err := h.db.QueryRowContext(r.Context(), ` SELECT COUNT(*) FROM bookings b JOIN booking_attendees a ON a.booking_id = b.id AND a.is_organizer = 1 - WHERE a.email = ? COLLATE NOCASE AND b.created_at > ?`, + WHERE LOWER(a.email) = LOWER(?) AND b.created_at > ?`, req.Email, windowStart).Scan(&recent); err != nil { h.logger.ErrorContext(r.Context(), "create booking: per-email throttle", "error", err) } else if recent >= maxBookingsPerEmailPerHour { @@ -1843,10 +1844,9 @@ func (h *Handler) loadHostPrefs(ctx context.Context, hostID string) (hostPrefs, return p, nil } -// isForeignKeyViolation reports whether err is a SQLite FOREIGN KEY constraint failure. -func isForeignKeyViolation(err error) bool { - return strings.Contains(err.Error(), "FOREIGN KEY constraint failed") -} +// isForeignKeyViolation reports whether err is a foreign-key violation, on either +// engine. A thin wrapper so the two call sites keep reading as a local predicate. +func isForeignKeyViolation(err error) bool { return db.IsForeignKeyViolation(err) } // enqueueReminder inserts a reminder.send job scheduled hoursBefore hours before startAt. // If the computed run_at has already passed, the job fires on the next poll cycle. @@ -1862,9 +1862,13 @@ func (h *Handler) enqueueReminder(ctx context.Context, bookingID string, startAt return fmt.Errorf("enqueue reminder: marshal payload: %w", err) } + // ON CONFLICT DO NOTHING is the portable spelling of SQLite's INSERT OR + // IGNORE, and drops the duplicate that jobs' UNIQUE(type, payload) rejects + // when the same reminder is enqueued twice. _, err = h.db.ExecContext(ctx, ` - INSERT OR IGNORE INTO jobs (id, type, payload, run_at, status, attempts, max_attempts) - VALUES (?, 'reminder.send', ?, ?, 'pending', 0, 3)`, + INSERT INTO jobs (id, type, payload, run_at, status, attempts, max_attempts) + VALUES (?, 'reminder.send', ?, ?, 'pending', 0, 3) + ON CONFLICT DO NOTHING`, uid.New(), string(payload), runAt.Format(time.RFC3339)) return err } @@ -1934,11 +1938,19 @@ func (h *Handler) replaceReminderJobs(ctx context.Context, bookingID, etID strin } defer tx.Rollback() //nolint:errcheck - if _, err := tx.ExecContext(ctx, ` - DELETE FROM jobs - WHERE type = 'reminder.send' - AND json_extract(payload, '$.booking_id') = ? - AND status != 'running'`, bookingID); err != nil { + // jobs.payload is TEXT holding JSON, and extracting a field out of it is the one + // operation here with no spelling both engines accept: json_extract is SQLite's + // JSON1 function, ->> needs an explicit cast because the column is TEXT rather + // than json. Hence a dialect pair rather than one statement. + if _, err := tx.ExecContext(ctx, tx.Dialect().SQL( + `DELETE FROM jobs + WHERE type = 'reminder.send' + AND json_extract(payload, '$.booking_id') = ? + AND status != 'running'`, + `DELETE FROM jobs + WHERE type = 'reminder.send' + AND payload::json ->> 'booking_id' = ? + AND status != 'running'`), bookingID); err != nil { return fmt.Errorf("replace reminder jobs: delete: %w", err) } @@ -1953,8 +1965,9 @@ func (h *Handler) replaceReminderJobs(ctx context.Context, bookingID, etID strin return fmt.Errorf("replace reminder jobs: marshal payload: %w", err) } if _, err := tx.ExecContext(ctx, ` - INSERT OR IGNORE INTO jobs (id, type, payload, run_at, status, attempts, max_attempts) - VALUES (?, 'reminder.send', ?, ?, 'pending', 0, 3)`, + INSERT INTO jobs (id, type, payload, run_at, status, attempts, max_attempts) + VALUES (?, 'reminder.send', ?, ?, 'pending', 0, 3) + ON CONFLICT DO NOTHING`, uid.New(), string(payload), runAt.Format(time.RFC3339)); err != nil { return fmt.Errorf("replace reminder jobs: insert: %w", err) } diff --git a/internal/handler/branding_settings.go b/internal/handler/branding_settings.go index 98e3f18..b59cfc3 100644 --- a/internal/handler/branding_settings.go +++ b/internal/handler/branding_settings.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/i18n" "github.com/calnode/calnode/internal/mailer" "github.com/disintegration/imaging" @@ -219,8 +220,8 @@ func (h *Handler) PatchBranding(w http.ResponseWriter, r *http.Request) { } if _, err := h.db.ExecContext(r.Context(), ` UPDATE server_settings SET business_name = ?, logo_height = ?, logo_opacity = ?, - banner_opacity = ?, privacy_url = ?, terms_url = ?, fallback_locale = ?, updated_at = datetime('now') - WHERE id = 1`, req.BusinessName, req.LogoHeight, req.LogoOpacity, req.BannerOpacity, privacyURL, termsURL, req.FallbackLocale); err != nil { + banner_opacity = ?, privacy_url = ?, terms_url = ?, fallback_locale = ?, updated_at = ? + WHERE id = 1`, req.BusinessName, req.LogoHeight, req.LogoOpacity, req.BannerOpacity, privacyURL, termsURL, req.FallbackLocale, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "branding settings: update", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -316,7 +317,7 @@ func (h *Handler) UploadBrandingLogo(w http.ResponseWriter, r *http.Request) { logoURL := fmt.Sprintf("%s?v=%d", logoServePath, time.Now().Unix()) if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET logo_url = ?, updated_at = datetime('now') WHERE id = 1`, logoURL); err != nil { + `UPDATE server_settings SET logo_url = ?, updated_at = ? WHERE id = 1`, logoURL, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "logo: update db", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -331,7 +332,7 @@ func (h *Handler) DeleteBrandingLogo(w http.ResponseWriter, r *http.Request) { } _ = os.Remove(filepath.Join(h.brandingDir(), "logo.png")) if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET logo_url = '', updated_at = datetime('now') WHERE id = 1`); err != nil { + `UPDATE server_settings SET logo_url = '', updated_at = ? WHERE id = 1`, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "logo: delete db", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -443,7 +444,7 @@ func (h *Handler) UploadBrandingBanner(w http.ResponseWriter, r *http.Request) { bannerURL := fmt.Sprintf("%s?v=%d", bannerServePath, time.Now().Unix()) if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET banner_url = ?, updated_at = datetime('now') WHERE id = 1`, bannerURL); err != nil { + `UPDATE server_settings SET banner_url = ?, updated_at = ? WHERE id = 1`, bannerURL, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "banner: update db", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -458,7 +459,7 @@ func (h *Handler) DeleteBrandingBanner(w http.ResponseWriter, r *http.Request) { } _ = os.Remove(filepath.Join(h.brandingDir(), "banner.png")) if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET banner_url = '', updated_at = datetime('now') WHERE id = 1`); err != nil { + `UPDATE server_settings SET banner_url = '', updated_at = ? WHERE id = 1`, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "banner: delete db", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return diff --git a/internal/handler/calendar_test.go b/internal/handler/calendar_test.go index 5358ff2..fb112b2 100644 --- a/internal/handler/calendar_test.go +++ b/internal/handler/calendar_test.go @@ -9,7 +9,7 @@ import ( "testing" "github.com/calnode/calnode/internal/calendar" - "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" "github.com/calnode/calnode/internal/gcal" "github.com/calnode/calnode/internal/handler" ) @@ -21,13 +21,7 @@ const testGCalKeyHex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef // Returns (handler, gcalClient, plainAPIKey, userID). func newHandlerWithGCal(t *testing.T) (*handler.Handler, *gcal.Client, string, string) { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("db.Open: %v", err) - } - if err := db.Migrate(database); err != nil { - t.Fatalf("db.Migrate: %v", err) - } + database := dbtest.Open(t) t.Cleanup(func() { database.Close() }) h := handler.New(database, slog.Default()) diff --git a/internal/handler/checkbox_answer_test.go b/internal/handler/checkbox_answer_test.go index 335a66e..9e0714a 100644 --- a/internal/handler/checkbox_answer_test.go +++ b/internal/handler/checkbox_answer_test.go @@ -1,7 +1,6 @@ package handler_test import ( - "database/sql" "encoding/json" "fmt" "net/http" @@ -9,6 +8,7 @@ import ( "strings" "testing" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/handler" ) @@ -118,7 +118,7 @@ func TestCheckboxAnswer_normalisedToYesNo(t *testing.T) { } // ownerIDOf returns the sole workspace user's id. -func ownerIDOf(t *testing.T, database *sql.DB) string { +func ownerIDOf(t *testing.T, database *db.DB) string { t.Helper() var id string if err := database.QueryRow(`SELECT id FROM users ORDER BY created_at LIMIT 1`).Scan(&id); err != nil { diff --git a/internal/handler/email_settings.go b/internal/handler/email_settings.go index b8adc73..7dbf9b5 100644 --- a/internal/handler/email_settings.go +++ b/internal/handler/email_settings.go @@ -10,6 +10,8 @@ import ( "strconv" "time" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/mailer" "github.com/calnode/calnode/internal/secret" ) @@ -67,7 +69,7 @@ func BuildMailer(cfg SMTPConfig) (mailer.Mailer, EmailTransport) { // LoadEmailSettingsFromDB reads SMTP settings from server_settings and decrypts // the password. Returns nil (not an error) when smtp_host is empty — meaning // the settings have not been configured yet. -func LoadEmailSettingsFromDB(db *sql.DB, encKey [32]byte) (*SMTPConfig, error) { +func LoadEmailSettingsFromDB(db *db.DB, encKey [32]byte) (*SMTPConfig, error) { var host, port, user, passEnc, from, fromName, resendEnc string var smtpTLS, startTLS int err := db.QueryRow(` @@ -163,7 +165,7 @@ func (s emailSecret) String() string { return "smtp_pass_enc" } -// execer is the subset of *sql.DB / *sql.Tx the settings writes need, so they can be run +// execer is the subset of *db.DB / *db.Tx the settings writes need, so they can be run // inside a transaction. Saving email settings touches up to three columns across separate // statements; without a transaction a failure partway leaves the instance holding, say, a // new SMTP host with the previous password. @@ -185,14 +187,14 @@ func (h *Handler) storeEmailSecret(ctx context.Context, db execer, which emailSe var q string switch which { case secretResendAPIKey: - q = `UPDATE server_settings SET resend_api_key_enc = ?, updated_at = datetime('now') WHERE id = 1` + q = `UPDATE server_settings SET resend_api_key_enc = ?, updated_at = ? WHERE id = 1` case secretSMTPPass: - q = `UPDATE server_settings SET smtp_pass_enc = ?, updated_at = datetime('now') WHERE id = 1` + q = `UPDATE server_settings SET smtp_pass_enc = ?, updated_at = ? WHERE id = 1` default: return fmt.Errorf("unknown email secret %d", int(which)) } - if _, err := db.ExecContext(ctx, q, enc); err != nil { + if _, err := db.ExecContext(ctx, q, enc, dbtime.Now()); err != nil { return fmt.Errorf("store %s: %w", which, err) } return nil @@ -265,11 +267,11 @@ func (h *Handler) PatchEmailSettings(w http.ResponseWriter, r *http.Request) { smtp_host = ?, smtp_port = ?, smtp_user = ?, smtp_tls = ?, smtp_starttls = ?, email_from = ?, email_from_name = ?, - updated_at = datetime('now') + updated_at = ? WHERE id = 1`, req.SMTPHost, req.SMTPPort, req.SMTPUser, boolToInt(req.SMTPTLS), boolToInt(req.SMTPStartTLS), - req.EmailFrom, req.EmailFromName); err != nil { + req.EmailFrom, req.EmailFromName, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "email settings: update", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return diff --git a/internal/handler/email_settings_test.go b/internal/handler/email_settings_test.go index c290685..3df55f1 100644 --- a/internal/handler/email_settings_test.go +++ b/internal/handler/email_settings_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/mailer" ) @@ -61,7 +62,7 @@ func TestGetEmailSettings_nonAdminForbidden(t *testing.T) { rawKey := "non-admin-get-email-test-key" hash := sha256HexForTest(rawKey) db.Exec(`INSERT INTO users (id, email, name, iana_timezone, is_admin) VALUES ('u4','other3@example.com','Other3','UTC',0)`) - db.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k4','u4','test',?,datetime('now'))`, hash) + db.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k4','u4','test',?,?)`, hash, dbtime.Now()) req := authReq(http.MethodGet, "/v1/settings/email", "", rawKey) rec := httptest.NewRecorder() @@ -216,7 +217,7 @@ func TestPatchEmailSettings_nonAdminForbidden(t *testing.T) { rawKey := "non-admin-test-key-xyz" hash := sha256HexForTest(rawKey) db.Exec(`INSERT INTO users (id, email, name, iana_timezone, is_admin) VALUES ('u2','other@example.com','Other','UTC',0)`) - db.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k2','u2','test',?,datetime('now'))`, hash) + db.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k2','u2','test',?,?)`, hash, dbtime.Now()) req := authReq(http.MethodPatch, "/v1/settings/email", `{"smtp_host":"evil.smtp.example.com"}`, rawKey) rec := httptest.NewRecorder() @@ -286,7 +287,7 @@ func TestTestEmailConnection_nonAdminForbidden(t *testing.T) { rawKey := "non-admin-conn-test-key" hash := sha256HexForTest(rawKey) db.Exec(`INSERT INTO users (id, email, name, iana_timezone, is_admin) VALUES ('u3','other2@example.com','Other2','UTC',0)`) - db.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k3','u3','test',?,datetime('now'))`, hash) + db.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k3','u3','test',?,?)`, hash, dbtime.Now()) req := authReq(http.MethodPost, "/v1/settings/email/test", "", rawKey) rec := httptest.NewRecorder() diff --git a/internal/handler/event_type.go b/internal/handler/event_type.go index 40a9e98..b97ae09 100644 --- a/internal/handler/event_type.go +++ b/internal/handler/event_type.go @@ -7,6 +7,8 @@ import ( "net/http" "strings" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/uid" ) @@ -160,8 +162,12 @@ const selectETCols = "SELECT " + etColumns + " FROM event_types" // listEventTypesQuery returns every event type the user owns OR is an assigned // host on, with an `owned` flag (the owner is also seeded into event_type_hosts, // so ownership is keyed on event_types.user_id, not host membership). -const listEventTypesQuery = "SELECT " + etColumns + `, (user_id = ?) AS owned, - (archived_at IS NOT NULL) AS archived +// +// The two flags are CASE expressions rather than bare comparisons because a bare +// comparison's type is engine-dependent: SQLite yields 0/1 and PostgreSQL yields a +// boolean, and the boolean does not scan into the int the 0/1 columns beside it use. +const listEventTypesQuery = "SELECT " + etColumns + `, CASE WHEN user_id = ? THEN 1 ELSE 0 END AS owned, + CASE WHEN archived_at IS NOT NULL THEN 1 ELSE 0 END AS archived FROM event_types WHERE user_id = ? OR id IN (SELECT event_type_id FROM event_type_hosts WHERE user_id = ?) @@ -170,10 +176,10 @@ ORDER BY created_at` // getEventTypeQuery fetches one event type by slug if the user owns it or hosts // it, with the `owned` flag and the owner's name/email (so a read-only host knows // who to contact for changes). -const getEventTypeQuery = "SELECT " + etColumns + `, (user_id = ?) AS owned, +const getEventTypeQuery = "SELECT " + etColumns + `, CASE WHEN user_id = ? THEN 1 ELSE 0 END AS owned, (SELECT name FROM users WHERE id = event_types.user_id) AS owner_name, (SELECT email FROM users WHERE id = event_types.user_id) AS owner_email, - (archived_at IS NOT NULL) AS archived + CASE WHEN archived_at IS NOT NULL THEN 1 ELSE 0 END AS archived FROM event_types WHERE slug = ? AND (user_id = ? OR id IN (SELECT event_type_id FROM event_type_hosts WHERE user_id = ?))` @@ -315,11 +321,11 @@ func (h *Handler) CreateEventType(w http.ResponseWriter, r *http.Request) { routingMode, bufBefore, bufAfter, minNotice, maxFuture, maxActive, showTaken, defaultMsgConfirmation, defaultMsgCancellation, defaultMsgReschedule, defaultMsgReminder) if err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") { + if db.IsUniqueViolation(err) { h.writeError(w, http.StatusConflict, "slug already in use") return } - if strings.Contains(err.Error(), "CHECK constraint failed") { + if db.IsCheckViolation(err) { h.writeError(w, http.StatusBadRequest, "invalid location_type or routing_mode value") return } @@ -596,9 +602,10 @@ func (h *Handler) PatchEventType(w http.ResponseWriter, r *http.Request) { } if req.Archived != nil { if *req.Archived { - // strftime literal (no bound value) — matches the DB's timestamp format; - // also force is_active off so the archived type stops taking bookings. - setClauses = append(setClauses, "archived_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')") + // Bound value in the DB's millisecond timestamp format, which is what + // strftime('%Y-%m-%dT%H:%M:%fZ','now') wrote here before; also force + // is_active off so the archived type stops taking bookings. + set("archived_at", dbtime.NowMilli()) set("is_active", 0) } else { setClauses = append(setClauses, "archived_at = NULL") @@ -701,7 +708,7 @@ func (h *Handler) PatchEventType(w http.ResponseWriter, r *http.Request) { "UPDATE event_types SET "+strings.Join(setClauses, ", ")+" WHERE slug = ? AND user_id = ?", // #nosec G202 -- setClauses is built by set()/the literal col list above; every column name is a hardcoded string, every value is bound via args... args...) if err != nil { - if strings.Contains(err.Error(), "CHECK constraint failed") { + if db.IsCheckViolation(err) { h.writeError(w, http.StatusBadRequest, "invalid location_type or routing_mode value") return } @@ -780,7 +787,7 @@ func (h *Handler) DeleteEventType(w http.ResponseWriter, r *http.Request) { res, err := h.db.ExecContext(r.Context(), `DELETE FROM event_types WHERE slug = ? AND user_id = ?`, slug, user.ID) if err != nil { - if strings.Contains(err.Error(), "FOREIGN KEY constraint failed") { + if db.IsForeignKeyViolation(err) { h.writeError(w, http.StatusConflict, "this event type has bookings in its history (including cancelled ones) and can't be deleted — deactivate it instead") return } diff --git a/internal/handler/google_settings.go b/internal/handler/google_settings.go index 965aa5e..cd70949 100644 --- a/internal/handler/google_settings.go +++ b/internal/handler/google_settings.go @@ -8,6 +8,8 @@ import ( "net/http" "github.com/calnode/calnode/internal/calendar" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/gcal" "github.com/calnode/calnode/internal/secret" ) @@ -21,7 +23,7 @@ type GoogleOAuthConfig struct { // LoadGoogleSettingsFromDB reads Google OAuth credentials from server_settings // and decrypts the client secret. Returns nil (not an error) when client_id is empty. -func LoadGoogleSettingsFromDB(db *sql.DB, encKey [32]byte) (*GoogleOAuthConfig, error) { +func LoadGoogleSettingsFromDB(db *db.DB, encKey [32]byte) (*GoogleOAuthConfig, error) { var clientID, secretEnc string err := db.QueryRow(` SELECT google_client_id, google_client_secret_enc @@ -97,8 +99,8 @@ func (h *Handler) PatchGoogleSettings(w http.ResponseWriter, r *http.Request) { if _, err := h.db.ExecContext(r.Context(), ` UPDATE server_settings SET google_client_id = '', google_client_secret_enc = '', - updated_at = datetime('now') - WHERE id = 1`); err != nil { + updated_at = ? + WHERE id = 1`, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "google settings: clear", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -121,8 +123,8 @@ func (h *Handler) PatchGoogleSettings(w http.ResponseWriter, r *http.Request) { if _, err = h.db.ExecContext(r.Context(), ` UPDATE server_settings SET google_client_id = ?, google_client_secret_enc = ?, - updated_at = datetime('now') - WHERE id = 1`, req.ClientID, enc); err != nil { + updated_at = ? + WHERE id = 1`, req.ClientID, enc, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "google settings: update", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -131,8 +133,8 @@ func (h *Handler) PatchGoogleSettings(w http.ResponseWriter, r *http.Request) { if _, err := h.db.ExecContext(r.Context(), ` UPDATE server_settings SET google_client_id = ?, - updated_at = datetime('now') - WHERE id = 1`, req.ClientID); err != nil { + updated_at = ? + WHERE id = 1`, req.ClientID, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "google settings: update (keep secret)", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return diff --git a/internal/handler/google_settings_test.go b/internal/handler/google_settings_test.go index 0b8554d..56b012f 100644 --- a/internal/handler/google_settings_test.go +++ b/internal/handler/google_settings_test.go @@ -1,19 +1,20 @@ package handler_test import ( - "database/sql" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/handler" ) // newGoogleHandler sets up a handler with a valid enc key and returns it // alongside the DB and API key for an admin user. -func newGoogleHandler(t *testing.T) (*handler.Handler, *sql.DB, string) { +func newGoogleHandler(t *testing.T) (*handler.Handler, *db.DB, string) { t.Helper() h, database, apiKey, _ := setupWorkspaceWithDB(t) // Use the same key as calendar tests so gcal.New works if needed. @@ -80,7 +81,7 @@ func TestGetGoogleSettings_nonAdminForbidden(t *testing.T) { rawKey := "non-admin-google-test-key" hash := sha256HexForTest(rawKey) database.Exec(`INSERT INTO users (id, email, name, iana_timezone, is_admin) VALUES ('u-ng','ng@example.com','NG','UTC',0)`) - database.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k-ng','u-ng','test',?,datetime('now'))`, hash) + database.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k-ng','u-ng','test',?,?)`, hash, dbtime.Now()) req := authReq(http.MethodGet, "/v1/settings/google", "", rawKey) rec := httptest.NewRecorder() @@ -245,7 +246,7 @@ func TestPatchGoogleSettings_nonAdminForbidden(t *testing.T) { rawKey := "non-admin-patch-google-key" hash := sha256HexForTest(rawKey) database.Exec(`INSERT INTO users (id, email, name, iana_timezone, is_admin) VALUES ('u-npg','npg@example.com','NPG','UTC',0)`) - database.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k-npg','u-npg','test',?,datetime('now'))`, hash) + database.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k-npg','u-npg','test',?,?)`, hash, dbtime.Now()) rec := patchGoogleSettings(t, h, `{"client_id":"evil","client_secret":"evil"}`, rawKey) if rec.Code != http.StatusForbidden { diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 6adbb08..cf68ce8 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -1,7 +1,6 @@ package handler import ( - "database/sql" "encoding/hex" "log/slog" "net/http" @@ -12,6 +11,8 @@ import ( "github.com/calnode/calnode/internal/booking" "github.com/calnode/calnode/internal/calendar" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/livekit" "github.com/calnode/calnode/internal/llm" "github.com/calnode/calnode/internal/mailer" @@ -21,7 +22,7 @@ import ( ) type Handler struct { - db *sql.DB + db *db.DB logger *slog.Logger bookingSvc *booking.Service mailer mailer.Mailer @@ -62,7 +63,8 @@ func (h *Handler) SetLiveKit(c *livekit.Client) { // is no longer tracked), and would otherwise block the idempotent guard on its room forever. if c != nil { if _, err := h.db.Exec( - `UPDATE recordings SET status = 'complete', updated_at = datetime('now') WHERE status = 'active'`); err != nil { + `UPDATE recordings SET status = 'complete', updated_at = ? WHERE status = 'active'`, + dbtime.Now()); err != nil { h.logger.Warn("livekit: sweep stale recordings", "error", err) } } @@ -121,7 +123,7 @@ func (h *Handler) getLLM() *llm.Client { return h.llm } -func New(db *sql.DB, logger *slog.Logger) *Handler { +func New(db *db.DB, logger *slog.Logger) *Handler { whs, _ := webhook.New(db, "") // ephemeral key when no encryption key configured return &Handler{ db: db, diff --git a/internal/handler/health.go b/internal/handler/health.go index 2d9d007..43ccf2d 100644 --- a/internal/handler/health.go +++ b/internal/handler/health.go @@ -5,7 +5,6 @@ import ( "net/http" "github.com/calnode/calnode/internal/buildinfo" - "github.com/calnode/calnode/internal/db" ) func (h *Handler) Healthz(w http.ResponseWriter, r *http.Request) { @@ -32,7 +31,7 @@ func (h *Handler) Readyz(w http.ResponseWriter, r *http.Request) { // Gate readiness on migrations: report not-ready until the schema is at the // embedded target version, so a provisioner polling /readyz never routes // traffic to an instance still mid-migration (or one that failed to migrate). - ready, err := db.SchemaReady(r.Context(), h.db) + ready, err := h.db.SchemaReady(r.Context()) if err != nil || !ready { if err != nil { h.logger.ErrorContext(r.Context(), "readyz: migration check failed", "error", err) diff --git a/internal/handler/health_test.go b/internal/handler/health_test.go index 08ae220..596d08a 100644 --- a/internal/handler/health_test.go +++ b/internal/handler/health_test.go @@ -8,18 +8,13 @@ import ( "testing" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" "github.com/calnode/calnode/internal/handler" ) func newTestHandler(t *testing.T) *handler.Handler { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("db.Open: %v", err) - } - if err := db.Migrate(database); err != nil { - t.Fatalf("db.Migrate: %v", err) - } + database := dbtest.Open(t) t.Cleanup(func() { database.Close() }) return handler.New(database, slog.Default()) } @@ -76,7 +71,7 @@ func TestReadyz_returns200_whenDBHealthy(t *testing.T) { func TestReadyz_returns503_whenNotMigrated(t *testing.T) { // Open a DB but do NOT migrate it — the goose bookkeeping table is absent, // so the schema-readiness gate must report not-ready. - database, err := db.Open("sqlite://:memory:") + database, err := db.OpenDB("sqlite://:memory:") if err != nil { t.Fatalf("db.Open: %v", err) } @@ -115,7 +110,7 @@ func TestVersion_returns200(t *testing.T) { } func TestReadyz_returns503_whenDBClosed(t *testing.T) { - database, err := db.Open("sqlite://:memory:") + database, err := db.OpenDB("sqlite://:memory:") if err != nil { t.Fatalf("db.Open: %v", err) } diff --git a/internal/handler/idempotency.go b/internal/handler/idempotency.go index 91717f9..8991666 100644 --- a/internal/handler/idempotency.go +++ b/internal/handler/idempotency.go @@ -5,8 +5,9 @@ import ( "crypto/sha256" "database/sql" "encoding/hex" - "strings" "time" + + "github.com/calnode/calnode/internal/db" ) // idempotencyRecord is a previously-seen Idempotency-Key's stored outcome. @@ -38,7 +39,7 @@ func (h *Handler) claimIdempotencyKey(ctx context.Context, key, reqHash string) if err == nil { return nil, false, nil } - if !strings.Contains(err.Error(), "UNIQUE constraint failed") { + if !db.IsUniqueViolation(err) { return nil, false, err } diff --git a/internal/handler/livekit_recording.go b/internal/handler/livekit_recording.go index 33eafb4..d8f6b37 100644 --- a/internal/handler/livekit_recording.go +++ b/internal/handler/livekit_recording.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/livekit" "github.com/calnode/calnode/internal/uid" "github.com/calnode/calnode/internal/webhook" @@ -117,10 +118,13 @@ func (h *Handler) RecordStart(w http.ResponseWriter, r *http.Request) { } h.logger.InfoContext(r.Context(), "livekit: egress started", "room", room, "egress_id", egressID, "filepath", filepath) bookingID := strings.TrimPrefix(room, "booking-") + // created_at keeps the datetime('now') shape: parseRecordingTime reads it back + // and consentWindow turns it into the millisecond form the consent rows use. + now := dbtime.Now() if _, err := h.db.ExecContext(r.Context(), ` INSERT INTO recordings (id, booking_id, room, egress_id, status, object_key, created_at, updated_at) - VALUES (?, ?, ?, ?, 'active', ?, datetime('now'), datetime('now'))`, - uid.New(), bookingID, room, egressID, filepath); err != nil { + VALUES (?, ?, ?, ?, 'active', ?, ?, ?)`, + uid.New(), bookingID, room, egressID, filepath, now, now); err != nil { h.logger.ErrorContext(r.Context(), "livekit: save recording", "error", err) } h.mergeRoomMeta(r.Context(), room, "recording", true) // drives the consent banner @@ -167,8 +171,8 @@ func (h *Handler) finalizeActiveRecording(ctx context.Context, room string) { h.logger.ErrorContext(ctx, "livekit: stop egress", "error", err, "egress", egressID) } if _, err := h.db.ExecContext(ctx, - `UPDATE recordings SET status = 'complete', updated_at = datetime('now') - WHERE room = ? AND status = 'active'`, room); err != nil { + `UPDATE recordings SET status = 'complete', updated_at = ? + WHERE room = ? AND status = 'active'`, dbtime.Now(), room); err != nil { h.logger.ErrorContext(ctx, "livekit: close recording row", "error", err, "room", room) } } @@ -211,8 +215,8 @@ func (h *Handler) RecordConsent(w http.ResponseWriter, r *http.Request) { VALUES (?, ?, ?, ?) ON CONFLICT(room, participant_identity) DO UPDATE SET name = excluded.name, decision = excluded.decision, - decided_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, - room, identity, name, decision); err != nil { + decided_at = ?`, + room, identity, name, decision, dbtime.NowMilli()); err != nil { h.logger.ErrorContext(r.Context(), "livekit: record consent", "error", err, "room", room) h.writeError(w, http.StatusInternalServerError, "could not record consent") return @@ -603,8 +607,8 @@ func (h *Handler) LiveKitWebhook(w http.ResponseWriter, r *http.Request) { } if _, err := h.db.ExecContext(r.Context(), ` UPDATE recordings SET status = ?, object_key = COALESCE(NULLIF(?,''), object_key), - duration_s = ?, updated_at = datetime('now') WHERE egress_id = ?`, - status, key, durSec, info.EgressID); err != nil { + duration_s = ?, updated_at = ? WHERE egress_id = ?`, + status, key, durSec, dbtime.Now(), info.EgressID); err != nil { h.logger.ErrorContext(r.Context(), "livekit: finalize recording", "error", err) } if info.RoomName != "" { diff --git a/internal/handler/livekit_settings.go b/internal/handler/livekit_settings.go index daf4965..9606ccf 100644 --- a/internal/handler/livekit_settings.go +++ b/internal/handler/livekit_settings.go @@ -8,6 +8,8 @@ import ( "encoding/json" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/livekit" "github.com/calnode/calnode/internal/secret" ) @@ -21,7 +23,7 @@ type LiveKitConfig struct { // LoadLiveKitSettingsFromDB reads the LiveKit server config from server_settings and decrypts // the API secret. Returns nil (not an error) when the URL or key is empty (= not configured). -func LoadLiveKitSettingsFromDB(db *sql.DB, encKey [32]byte) (*LiveKitConfig, error) { +func LoadLiveKitSettingsFromDB(db *db.DB, encKey [32]byte) (*LiveKitConfig, error) { var url, apiKey, secretEnc string err := db.QueryRow(` SELECT livekit_url, livekit_api_key, livekit_api_secret_enc @@ -90,7 +92,7 @@ func (h *Handler) PatchLiveKitSettings(w http.ResponseWriter, r *http.Request) { if req.URL == "" { if _, err := h.db.ExecContext(r.Context(), ` UPDATE server_settings SET livekit_url = '', livekit_api_key = '', - livekit_api_secret_enc = '', updated_at = datetime('now') WHERE id = 1`); err != nil { + livekit_api_secret_enc = '', updated_at = ? WHERE id = 1`, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "livekit settings: clear", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -118,15 +120,15 @@ func (h *Handler) PatchLiveKitSettings(w http.ResponseWriter, r *http.Request) { } if _, err = h.db.ExecContext(r.Context(), ` UPDATE server_settings SET livekit_url = ?, livekit_api_key = ?, - livekit_api_secret_enc = ?, updated_at = datetime('now') WHERE id = 1`, - req.URL, req.APIKey, enc); err != nil { + livekit_api_secret_enc = ?, updated_at = ? WHERE id = 1`, + req.URL, req.APIKey, enc, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "livekit settings: update", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return } } else if _, err := h.db.ExecContext(r.Context(), ` UPDATE server_settings SET livekit_url = ?, livekit_api_key = ?, - updated_at = datetime('now') WHERE id = 1`, req.URL, req.APIKey); err != nil { + updated_at = ? WHERE id = 1`, req.URL, req.APIKey, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "livekit settings: update (keep secret)", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return diff --git a/internal/handler/llm_settings.go b/internal/handler/llm_settings.go index c5019f9..97bf4e8 100644 --- a/internal/handler/llm_settings.go +++ b/internal/handler/llm_settings.go @@ -9,6 +9,8 @@ import ( "strings" "time" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/llm" "github.com/calnode/calnode/internal/secret" ) @@ -34,7 +36,7 @@ type LLMConfig struct { // LoadLLMSettingsFromDB reads the optional LLM settings from server_settings and decrypts // the api key. Returns nil (not an error) when the endpoint is empty (unconfigured). -func LoadLLMSettingsFromDB(db *sql.DB, encKey [32]byte) (*LLMConfig, error) { +func LoadLLMSettingsFromDB(db *db.DB, encKey [32]byte) (*LLMConfig, error) { var endpoint, model, keyEnc string var enabled int err := db.QueryRow(` @@ -154,8 +156,11 @@ func (h *Handler) PatchLLMSettings(w http.ResponseWriter, r *http.Request) { args = append(args, v) } if len(set) > 0 { + // updated_at's placeholder trails every "col = ?" in set, so its value + // trails every value in args. + args = append(args, dbtime.Now()) if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET `+strings.Join(set, ", ")+`, updated_at = datetime('now') WHERE id = 1`, args...); err != nil { // #nosec G202 -- set is built above from hardcoded "col = ?" literals only; every value is bound via args... + `UPDATE server_settings SET `+strings.Join(set, ", ")+`, updated_at = ? WHERE id = 1`, args...); err != nil { // #nosec G202 -- set is built above from hardcoded "col = ?" literals only; every value is bound via args... h.llmDBError(w, r, err) return } diff --git a/internal/handler/manage_test.go b/internal/handler/manage_test.go index 505ec4b..d71335a 100644 --- a/internal/handler/manage_test.go +++ b/internal/handler/manage_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "encoding/json" "fmt" "log/slog" @@ -13,27 +12,22 @@ import ( "github.com/calnode/calnode/internal/booking" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" "github.com/calnode/calnode/internal/handler" "github.com/calnode/calnode/internal/uid" ) // newTestHandlerDB creates a test handler and returns both the handler and the // underlying DB so tests can interact with the DB directly (e.g. to issue tokens). -func newTestHandlerDB(t *testing.T) (*handler.Handler, *sql.DB) { +func newTestHandlerDB(t *testing.T) (*handler.Handler, *db.DB) { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("db.Open: %v", err) - } - if err := db.Migrate(database); err != nil { - t.Fatalf("db.Migrate: %v", err) - } + database := dbtest.Open(t) t.Cleanup(func() { database.Close() }) return handler.New(database, slog.Default()), database } // setupWorkspaceWithDB bootstraps a workspace and returns (handler, db, apiKey, userID). -func setupWorkspaceWithDB(t *testing.T) (*handler.Handler, *sql.DB, string, string) { +func setupWorkspaceWithDB(t *testing.T) (*handler.Handler, *db.DB, string, string) { t.Helper() h, database := newTestHandlerDB(t) @@ -59,7 +53,7 @@ func setupWorkspaceWithDB(t *testing.T) (*handler.Handler, *sql.DB, string, stri // seedFullAvailabilityDB is seedFullAvailability's DB-direct sibling, for secondary // hosts (e.g. a round-robin rotation member) inserted straight into the DB rather than // through /v1/setup — they have no API key of their own to call the HTTP endpoint with. -func seedFullAvailabilityDB(t *testing.T, database *sql.DB, userID string) { +func seedFullAvailabilityDB(t *testing.T, database *db.DB, userID string) { t.Helper() for day := 0; day < 7; day++ { if _, err := database.Exec( @@ -95,7 +89,7 @@ func createBookingViaHTTP(t *testing.T, h *handler.Handler, slug, startAt string } // issueTestToken issues a manage token for bookingID via the booking service. -func issueTestToken(t *testing.T, database *sql.DB, bookingID string) string { +func issueTestToken(t *testing.T, database *db.DB, bookingID string) string { t.Helper() svc := booking.New(database) tok, err := svc.IssueManageToken(context.Background(), bookingID) diff --git a/internal/handler/mcp_scope_test.go b/internal/handler/mcp_scope_test.go index 5d1aa2b..568aed6 100644 --- a/internal/handler/mcp_scope_test.go +++ b/internal/handler/mcp_scope_test.go @@ -11,20 +11,13 @@ import ( "time" "github.com/calnode/calnode/internal/booking" - "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" ) // verifies MCP tools scope by role: a member sees/controls only the bookings they host, // while an admin/owner (and the unauthenticated stdio operator) see the whole workspace. func TestMCP_roleScoping(t *testing.T) { - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("db open: %v", err) - } - t.Cleanup(func() { database.Close() }) - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate: %v", err) - } + database := dbtest.Open(t) h := New(database, slog.Default()) // Owner (first user → owner+admin). diff --git a/internal/handler/notetaker.go b/internal/handler/notetaker.go index 2ef1d90..627ef26 100644 --- a/internal/handler/notetaker.go +++ b/internal/handler/notetaker.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/llm" "github.com/calnode/calnode/internal/secret" "github.com/calnode/calnode/internal/stt" @@ -59,13 +60,18 @@ func (h *Handler) deepgramKey(ctx context.Context) string { // reminders/webhooks enqueue via their own paths.) func (h *Handler) enqueueJob(ctx context.Context, typ string, payload any) error { b, _ := json.Marshal(payload) + // run_at keeps the datetime('now') shape it has always had here. It is + // deliberately not the RFC 3339 the reminder path writes: the worker's + // "run_at <= ?" compares text, and the space-separated form sorts before any + // T-separated one, which is what makes these jobs due immediately. + now := dbtime.Now() _, err := h.db.ExecContext(ctx, ` INSERT INTO jobs (id, type, payload, run_at, status, attempts, max_attempts) - VALUES (?, ?, ?, datetime('now'), 'pending', 0, 3) + VALUES (?, ?, ?, ?, 'pending', 0, 3) ON CONFLICT(type, payload) DO UPDATE SET - status = 'pending', run_at = datetime('now'), attempts = 0, + status = 'pending', run_at = ?, attempts = 0, last_error = NULL, locked_until = NULL`, - uid.New(), typ, string(b)) + uid.New(), typ, string(b), now, now) return err } @@ -212,16 +218,16 @@ func (h *Handler) summarizeBooking(ctx context.Context, bookingID string) (strin _, _ = h.db.ExecContext(ctx, ` INSERT INTO notes (id, booking_id, content, status) VALUES (?, ?, '', 'empty') - ON CONFLICT(booking_id) DO UPDATE SET status = 'empty', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, - uid.New(), bookingID) + ON CONFLICT(booking_id) DO UPDATE SET status = 'empty', updated_at = ?`, + uid.New(), bookingID, dbtime.NowMilli()) return "", nil } if _, err := h.db.ExecContext(ctx, ` INSERT INTO notes (id, booking_id, content, status) VALUES (?, ?, ?, 'complete') ON CONFLICT(booking_id) DO UPDATE SET - content = excluded.content, status = 'complete', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, - uid.New(), bookingID, content); err != nil { + content = excluded.content, status = 'complete', updated_at = ?`, + uid.New(), bookingID, content, dbtime.NowMilli()); err != nil { return "", err } h.logger.InfoContext(ctx, "notetaker: notes generated", "booking_id", bookingID, "chars", len(content)) diff --git a/internal/handler/notetaker_http.go b/internal/handler/notetaker_http.go index f5619c7..fb2ab6d 100644 --- a/internal/handler/notetaker_http.go +++ b/internal/handler/notetaker_http.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/secret" ) @@ -57,7 +58,7 @@ func (h *Handler) PatchNotetakerSettings(w http.ResponseWriter, r *http.Request) v = 1 } if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET notetaker_enabled = ?, updated_at = datetime('now') WHERE id = 1`, v); err != nil { + `UPDATE server_settings SET notetaker_enabled = ?, updated_at = ? WHERE id = 1`, v, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "notetaker settings: update enabled", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -72,7 +73,7 @@ func (h *Handler) PatchNotetakerSettings(w http.ResponseWriter, r *http.Request) return } if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET stt_api_key_enc = ?, updated_at = datetime('now') WHERE id = 1`, enc); err != nil { + `UPDATE server_settings SET stt_api_key_enc = ?, updated_at = ? WHERE id = 1`, enc, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "notetaker settings: update key", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return diff --git a/internal/handler/override.go b/internal/handler/override.go index aaa2274..d34db3a 100644 --- a/internal/handler/override.go +++ b/internal/handler/override.go @@ -4,9 +4,9 @@ import ( "database/sql" "encoding/json" "net/http" - "strings" "time" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/uid" ) @@ -155,7 +155,7 @@ func (h *Handler) CreateAvailabilityOverride(w http.ResponseWriter, r *http.Requ INSERT INTO availability_overrides (id, user_id, date, is_available, reason, start_time, end_time) VALUES (?, ?, ?, ?, ?, ?, ?)`, id, user.ID, req.Date, isAvailInt, req.Reason, req.StartTime, req.EndTime); err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") { + if db.IsUniqueViolation(err) { h.writeError(w, http.StatusConflict, "an override already exists for this date; delete it first") return } diff --git a/internal/handler/question_test.go b/internal/handler/question_test.go index 658ae12..d2fe97e 100644 --- a/internal/handler/question_test.go +++ b/internal/handler/question_test.go @@ -232,6 +232,86 @@ func TestCreateQuestion_autoPosition(t *testing.T) { } } +// TestCreateQuestion_returningPosition covers the one RETURNING clause in the +// tree (question_handler.go): when the caller sends no position, the handler +// computes it inside the INSERT — VALUES (…, (SELECT COALESCE(MAX(position)+1, 0) +// …)) RETURNING position — rather than SELECT-then-INSERT, so two concurrent +// creates cannot land on the same position. +// +// Boundary 2 left that clause as the one piece of ported SQL verified on SQLite +// and unverified on PostgreSQL. It runs on whichever engine dbtest selects, and +// it asserts the value the handler SCANNED from RETURNING, not just the row that +// ended up in the table: the two agreeing is the whole point, and a RETURNING +// that silently returned a zero value would still leave a correct row behind. +func TestCreateQuestion_returningPosition(t *testing.T) { + h, database, key, _ := setupWorkspaceWithDB(t) + ctx := context.Background() + slug, _ := seedEventTypeHTTP(t, h, key) + t.Logf("engine: %s", database.Dialect()) + + // create posts a question and returns (id, position) as the CREATE response + // reported them — i.e. what RETURNING produced on the auto-position path. + create := func(body string) (string, int) { + t.Helper() + req := authReq(http.MethodPost, "/v1/event-types/"+slug+"/questions", body, key) + req.SetPathValue("slug", slug) + rec := httptest.NewRecorder() + h.RequireAuth(h.CreateQuestion)(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("create %s: got %d — %s", body, rec.Code, rec.Body.String()) + } + var resp struct { + ID string `json:"id"` + Position int `json:"position"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode create response: %v", err) + } + return resp.ID, resp.Position + } + + stored := func(id string) int { + t.Helper() + var pos int + if err := database.QueryRowContext(ctx, + `SELECT position FROM event_type_questions WHERE id = ?`, id).Scan(&pos); err != nil { + t.Fatalf("read stored position for %s: %v", id, err) + } + return pos + } + + // First three, no position in the request: RETURNING must hand back 0, 1, 2. + for want := 0; want < 3; want++ { + id, got := create(fmt.Sprintf(`{"label":"Q%d","type":"text"}`, want)) + if got != want { + t.Errorf("auto position #%d: RETURNING gave %d; want %d", want, got, want) + } + if s := stored(id); s != got { + t.Errorf("auto position #%d: RETURNING gave %d but the row holds %d", want, got, s) + } + } + + // An explicit position takes the other branch (a plain INSERT, no RETURNING), + // and then the next auto position must be MAX+1 over it — 10, not 3. This is + // what proves the subselect inside VALUES is evaluated by the engine rather + // than the value being an accident of insertion order. + explicitID, explicitPos := create(`{"label":"Pinned","type":"text","position":9}`) + if explicitPos != 9 { + t.Errorf("explicit position: got %d; want 9", explicitPos) + } + if s := stored(explicitID); s != 9 { + t.Errorf("explicit position: row holds %d; want 9", s) + } + + nextID, nextPos := create(`{"label":"After the pinned one","type":"text"}`) + if nextPos != 10 { + t.Errorf("auto position after an explicit 9: RETURNING gave %d; want 10", nextPos) + } + if s := stored(nextID); s != nextPos { + t.Errorf("auto position after an explicit 9: RETURNING gave %d but the row holds %d", nextPos, s) + } +} + // --------------------------------------------------------------------------- // UpdateQuestion // --------------------------------------------------------------------------- diff --git a/internal/handler/reschedule_test.go b/internal/handler/reschedule_test.go index 26a01c7..7390481 100644 --- a/internal/handler/reschedule_test.go +++ b/internal/handler/reschedule_test.go @@ -106,13 +106,22 @@ func TestRescheduleBooking_updatesReminderJob(t *testing.T) { // replaceReminderJobs deletes old jobs and inserts fresh ones keyed by booking_id. // Poll until a pending reminder job with the correct run_at appears. + // + // The JSON lookup is a dialect pair for the same reason the production statement + // is one. The Scan error is captured rather than discarded: swallowing it turned a + // "json_extract does not exist" into an empty run_at and a two-second poll that + // then reported itself as a time-parsing failure. var runAt string + var lastErr error + payloadMatch := database.Dialect().SQL( + `json_extract(payload, '$.booking_id') = ?`, + `payload::json ->> 'booking_id' = ?`) deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { - database.QueryRowContext(ctx, ` + lastErr = database.QueryRowContext(ctx, ` SELECT run_at FROM jobs WHERE type = 'reminder.send' - AND json_extract(payload, '$.booking_id') = ? + AND `+payloadMatch+` AND status = 'pending'`, bookingID). Scan(&runAt) if runAt != "" && runAt != oldReminderAt.Format(time.RFC3339) { @@ -120,6 +129,9 @@ func TestRescheduleBooking_updatesReminderJob(t *testing.T) { } time.Sleep(10 * time.Millisecond) } + if runAt == "" { + t.Fatalf("no pending reminder job appeared within 2s; last query error: %v", lastErr) + } gotRunAt, err := time.Parse(time.RFC3339, runAt) if err != nil { diff --git a/internal/handler/slots_busy_test.go b/internal/handler/slots_busy_test.go index 9d4be97..62ba09a 100644 --- a/internal/handler/slots_busy_test.go +++ b/internal/handler/slots_busy_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" ) // TestHostAvailability_includesAdjacentUTCDayBooking is the regression guard for @@ -16,14 +16,7 @@ import ( // generation would offer a slot the host is already booked for (then 409 at // booking time). Everything is stored UTC — this guards the fetch *window*. func TestHostAvailability_includesAdjacentUTCDayBooking(t *testing.T) { - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("open db: %v", err) - } - defer database.Close() - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate: %v", err) - } + database := dbtest.Open(t) h := New(database, slog.New(slog.DiscardHandler)) // Host in NZ; a booking at 18 Jun 10:00 NZST = 17 Jun 22:00Z (previous UTC day). @@ -60,14 +53,7 @@ func TestHostAvailability_includesAdjacentUTCDayBooking(t *testing.T) { // bookings.host_id) — otherwise their slots on other events stay open and they // get double-booked. func TestHostAvailability_includesNonPrimaryGroupSeat(t *testing.T) { - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("open db: %v", err) - } - defer database.Close() - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate: %v", err) - } + database := dbtest.Open(t) h := New(database, slog.New(slog.DiscardHandler)) database.Exec(`INSERT INTO users (id,email,name,iana_timezone,is_admin,is_owner) VALUES ('u1','a@example.com','A','UTC',1,1)`) diff --git a/internal/handler/storage_settings.go b/internal/handler/storage_settings.go index 9254e6f..e636ced 100644 --- a/internal/handler/storage_settings.go +++ b/internal/handler/storage_settings.go @@ -5,6 +5,8 @@ import ( "net/http" "os" "strings" + + "github.com/calnode/calnode/internal/dbtime" ) // The Storage settings page surfaces both object-storage uses in one place: the Litestream DB @@ -54,7 +56,7 @@ func (h *Handler) PatchStorageSettings(w http.ResponseWriter, r *http.Request) { v = 1 } if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET recordings_enabled = ?, updated_at = datetime('now') WHERE id = 1`, v); err != nil { + `UPDATE server_settings SET recordings_enabled = ?, updated_at = ? WHERE id = 1`, v, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "storage settings: update", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return diff --git a/internal/handler/stripe_locale_internal_test.go b/internal/handler/stripe_locale_internal_test.go index efb4b64..633b9ac 100644 --- a/internal/handler/stripe_locale_internal_test.go +++ b/internal/handler/stripe_locale_internal_test.go @@ -12,7 +12,7 @@ import ( "time" "github.com/calnode/calnode/internal/booking" - "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" "github.com/calnode/calnode/internal/mailer" ) @@ -65,14 +65,7 @@ func (c *captureMailer) recipients() []string { // of the language the attendee actually booked in — even though the free-booking path (same // dispatchBookingConfirmation) got this right. See stripe_booking.go's SELECT. func TestConfirmPaidBooking_usesAttendeeLocale(t *testing.T) { - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("db.Open: %v", err) - } - defer database.Close() - if err := db.Migrate(database); err != nil { - t.Fatalf("db.Migrate: %v", err) - } + database := dbtest.Open(t) h := New(database, slog.Default()) cap := &captureMailer{} diff --git a/internal/handler/stripe_settings.go b/internal/handler/stripe_settings.go index 6c65e35..2931f35 100644 --- a/internal/handler/stripe_settings.go +++ b/internal/handler/stripe_settings.go @@ -6,6 +6,8 @@ import ( "fmt" "net/http" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/secret" "github.com/calnode/calnode/internal/stripe" ) @@ -19,7 +21,7 @@ type StripeConfig struct { // LoadStripeSettingsFromDB reads Stripe credentials from server_settings and decrypts the // secret key + webhook secret. Returns nil (not an error) when the secret key is unset. -func LoadStripeSettingsFromDB(db *sql.DB, encKey [32]byte) (*StripeConfig, error) { +func LoadStripeSettingsFromDB(db *db.DB, encKey [32]byte) (*StripeConfig, error) { var secretEnc, pubKey, whEnc string err := db.QueryRow(` SELECT stripe_secret_key_enc, stripe_publishable_key, stripe_webhook_secret_enc @@ -97,8 +99,8 @@ func (h *Handler) PatchStripeSettings(w http.ResponseWriter, r *http.Request) { if _, err := h.db.ExecContext(r.Context(), ` UPDATE server_settings SET stripe_secret_key_enc = '', stripe_publishable_key = '', stripe_webhook_secret_enc = '', - updated_at = datetime('now') - WHERE id = 1`); err != nil { + updated_at = ? + WHERE id = 1`, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "stripe settings: clear", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -111,8 +113,8 @@ func (h *Handler) PatchStripeSettings(w http.ResponseWriter, r *http.Request) { // Update publishable key (plain — it's a public value) when provided. if req.PublishableKey != nil { if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET stripe_publishable_key = ?, updated_at = datetime('now') WHERE id = 1`, - *req.PublishableKey); err != nil { + `UPDATE server_settings SET stripe_publishable_key = ?, updated_at = ? WHERE id = 1`, + *req.PublishableKey, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "stripe settings: update pub key", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -127,7 +129,7 @@ func (h *Handler) PatchStripeSettings(w http.ResponseWriter, r *http.Request) { return } if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET stripe_secret_key_enc = ?, updated_at = datetime('now') WHERE id = 1`, enc); err != nil { + `UPDATE server_settings SET stripe_secret_key_enc = ?, updated_at = ? WHERE id = 1`, enc, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "stripe settings: update secret key", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -141,7 +143,7 @@ func (h *Handler) PatchStripeSettings(w http.ResponseWriter, r *http.Request) { return } if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET stripe_webhook_secret_enc = ?, updated_at = datetime('now') WHERE id = 1`, enc); err != nil { + `UPDATE server_settings SET stripe_webhook_secret_enc = ?, updated_at = ? WHERE id = 1`, enc, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "stripe settings: update webhook secret", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return diff --git a/internal/handler/teams.go b/internal/handler/teams.go index ff32173..c096625 100644 --- a/internal/handler/teams.go +++ b/internal/handler/teams.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/uid" ) @@ -86,7 +87,7 @@ func (h *Handler) CreateTeam(w http.ResponseWriter, r *http.Request) { if _, err := h.db.ExecContext(r.Context(), `INSERT INTO teams (id, name, slug, created_at) VALUES (?, ?, ?, ?)`, id, req.Name, slug, now); err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") { + if db.IsUniqueViolation(err) { h.writeError(w, http.StatusConflict, "a team with that slug already exists") return } @@ -222,7 +223,7 @@ func (h *Handler) PatchTeam(w http.ResponseWriter, r *http.Request) { res, err := h.db.ExecContext(r.Context(), "UPDATE teams SET "+strings.Join(sets, ", ")+" WHERE id = ?", args...) // #nosec G202 -- sets is built above from hardcoded "col = ?" literals only; every value is bound via args... if err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") { + if db.IsUniqueViolation(err) { h.writeError(w, http.StatusConflict, "a team with that slug already exists") return } @@ -306,7 +307,7 @@ func (h *Handler) AddTeamMember(w http.ResponseWriter, r *http.Request) { INSERT INTO team_members (id, team_id, user_id, role, routing_priority) VALUES (?, ?, ?, 'member', ?)`, uid.New(), teamID, req.UserID, req.RoutingPriority); err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") { + if db.IsUniqueViolation(err) { h.writeError(w, http.StatusConflict, "that user is already in this team") return } diff --git a/internal/handler/tracking_settings.go b/internal/handler/tracking_settings.go index 9a29f77..87a3e9a 100644 --- a/internal/handler/tracking_settings.go +++ b/internal/handler/tracking_settings.go @@ -6,6 +6,8 @@ import ( "net/http" "regexp" "strings" + + "github.com/calnode/calnode/internal/dbtime" ) // Google tag ID formats. The ID is interpolated into a