From f685ced67926879f690c9637738c863370fe82bd Mon Sep 17 00:00:00 2001
From: Sean Dean <254259913+distronode-com@users.noreply.github.com>
Date: Fri, 4 Sep 2026 03:14:45 -0400
Subject: [PATCH 01/51] db: dialect-aware Open plus a rebinding DB/Tx wrapper
Calnode's SQL is hand-written and portable apart from placeholder syntax, so
supporting a second engine is mostly a matter of not spreading "? vs $n" across
763 call sites. This adds the layer that hides it:
- Dialect (sqlite, postgres) selected from the DATABASE_URL scheme, with
Dialect.SQL for the few statements that genuinely cannot be shared.
- Rebind: a small lexer, not a string replace. A ? inside a string literal, a
quoted identifier or a comment is data, and renumbering one corrupts the
statement in a way that only shows up at runtime.
- DB/Tx wrapping sql.DB/sql.Tx, rebinding every statement on the way through.
Query, Exec, QueryRow, their Context forms, Prepare and Begin/BeginTx are all
covered, so a call site moving onto the wrapper is a type change and nothing
else. Begin returns *db.Tx rather than *sql.Tx deliberately: a transaction that
quietly stopped rebinding is the easiest way to reintroduce ? on Postgres.
- OpenDB returns the wrapper; Open still returns the bare *sql.DB so nothing
outside this package changes yet.
The SQLite path is untouched: same pragmas, and the one-connection pool stays
because it is the correctness guarantee behind the booking overlap check
(ARCHITECTURE section 17), not a tuning choice. Postgres gets an ordinary pool,
and openPostgres records what that costs so the gap is not rediscovered later.
Migrations move to migrations/sqlite/ unchanged (git detects all 57 as renames);
the postgres set lands in the next commit. is_applied is now tested for truth
rather than compared to 1, which is the one spelling both engines accept -- goose
stores it as INTEGER on SQLite and BOOLEAN on Postgres.
pgx v5.10.0 is the driver, via database/sql (stdlib), so nothing here depends on
the pgx-native API.
---
go.mod | 4 +
go.sum | 14 ++
internal/db/db.go | 145 +++++++++++++----
internal/db/db_test.go | 119 ++++++++++++++
internal/db/dialect.go | 106 +++++++++++++
internal/db/dialect_internal_test.go | 99 ++++++++++++
internal/db/handle.go | 130 ++++++++++++++++
.../{ => sqlite}/00001_initial_schema.sql | 0
.../{ => sqlite}/00002_manage_tokens.sql | 0
.../{ => sqlite}/00003_job_lock_timeout.sql | 0
.../{ => sqlite}/00004_sessions.sql | 0
.../{ => sqlite}/00005_override_unique.sql | 0
.../00006_jobs_type_payload_unique.sql | 0
.../{ => sqlite}/00007_user_prefs.sql | 0
.../{ => sqlite}/00008_date_format.sql | 0
.../{ => sqlite}/00009_override_reason.sql | 0
.../{ => sqlite}/00010_messaging_prefs.sql | 0
.../{ => sqlite}/00011_server_settings.sql | 0
.../{ => sqlite}/00012_auth_providers.sql | 0
.../00013_google_oauth_settings.sql | 0
...00014_booking_answers_question_cascade.sql | 0
.../00015_calendar_connections_expiry.sql | 0
.../00016_event_types_max_active_bookings.sql | 0
.../{ => sqlite}/00017_crypto_keystore.sql | 0
.../{ => sqlite}/00018_user_roles.sql | 0
.../{ => sqlite}/00019_user_archived.sql | 0
.../{ => sqlite}/00020_user_archived_by.sql | 0
.../{ => sqlite}/00021_event_type_hosts.sql | 0
.../{ => sqlite}/00022_booking_hosts.sql | 0
.../00023_booking_hosts_event_id.sql | 0
.../{ => sqlite}/00024_idempotency_keys.sql | 0
.../00025_booking_hosts_needs_sync.sql | 0
.../00026_event_type_subjects.sql | 0
.../{ => sqlite}/00027_webhook_fields.sql | 0
.../{ => sqlite}/00028_tracking_settings.sql | 0
.../{ => sqlite}/00029_branding_settings.sql | 0
.../{ => sqlite}/00030_logo_height.sql | 0
.../{ => sqlite}/00031_logo_opacity.sql | 0
.../00032_calendar_account_kind.sql | 0
.../{ => sqlite}/00033_oauth_mcp.sql | 0
.../{ => sqlite}/00034_llm_settings.sql | 0
.../{ => sqlite}/00035_llm_instructions.sql | 0
.../{ => sqlite}/00036_zoom_integration.sql | 0
.../{ => sqlite}/00037_stripe_payments.sql | 0
.../00038_booking_amount_paid.sql | 0
.../00039_calendar_account_email.sql | 0
.../{ => sqlite}/00040_magic_link_tokens.sql | 0
.../{ => sqlite}/00041_native_analytics.sql | 0
.../migrations/{ => sqlite}/00042_livekit.sql | 0
.../{ => sqlite}/00043_livekit_recording.sql | 0
.../{ => sqlite}/00044_meeting_consents.sql | 0
.../{ => sqlite}/00045_notetaker.sql | 0
.../{ => sqlite}/00046_legal_links.sql | 0
.../00047_event_type_archived.sql | 0
.../{ => sqlite}/00048_override_group.sql | 0
.../00049_connection_calendars.sql | 0
.../{ => sqlite}/00050_branding_banner.sql | 0
.../00051_booking_attendee_locale.sql | 0
.../{ => sqlite}/00052_msg_greeting.sql | 0
.../{ => sqlite}/00053_fallback_locale.sql | 0
.../{ => sqlite}/00054_resend_api_key.sql | 0
.../00055_booking_hosts_calendar_id.sql | 0
.../00056_bookings_list_indexes.sql | 0
.../00057_event_type_show_taken_slots.sql | 0
internal/db/rebind.go | 113 ++++++++++++++
internal/db/rebind_test.go | 147 ++++++++++++++++++
66 files changed, 849 insertions(+), 28 deletions(-)
create mode 100644 internal/db/dialect.go
create mode 100644 internal/db/dialect_internal_test.go
create mode 100644 internal/db/handle.go
rename internal/db/migrations/{ => sqlite}/00001_initial_schema.sql (100%)
rename internal/db/migrations/{ => sqlite}/00002_manage_tokens.sql (100%)
rename internal/db/migrations/{ => sqlite}/00003_job_lock_timeout.sql (100%)
rename internal/db/migrations/{ => sqlite}/00004_sessions.sql (100%)
rename internal/db/migrations/{ => sqlite}/00005_override_unique.sql (100%)
rename internal/db/migrations/{ => sqlite}/00006_jobs_type_payload_unique.sql (100%)
rename internal/db/migrations/{ => sqlite}/00007_user_prefs.sql (100%)
rename internal/db/migrations/{ => sqlite}/00008_date_format.sql (100%)
rename internal/db/migrations/{ => sqlite}/00009_override_reason.sql (100%)
rename internal/db/migrations/{ => sqlite}/00010_messaging_prefs.sql (100%)
rename internal/db/migrations/{ => sqlite}/00011_server_settings.sql (100%)
rename internal/db/migrations/{ => sqlite}/00012_auth_providers.sql (100%)
rename internal/db/migrations/{ => sqlite}/00013_google_oauth_settings.sql (100%)
rename internal/db/migrations/{ => sqlite}/00014_booking_answers_question_cascade.sql (100%)
rename internal/db/migrations/{ => sqlite}/00015_calendar_connections_expiry.sql (100%)
rename internal/db/migrations/{ => sqlite}/00016_event_types_max_active_bookings.sql (100%)
rename internal/db/migrations/{ => sqlite}/00017_crypto_keystore.sql (100%)
rename internal/db/migrations/{ => sqlite}/00018_user_roles.sql (100%)
rename internal/db/migrations/{ => sqlite}/00019_user_archived.sql (100%)
rename internal/db/migrations/{ => sqlite}/00020_user_archived_by.sql (100%)
rename internal/db/migrations/{ => sqlite}/00021_event_type_hosts.sql (100%)
rename internal/db/migrations/{ => sqlite}/00022_booking_hosts.sql (100%)
rename internal/db/migrations/{ => sqlite}/00023_booking_hosts_event_id.sql (100%)
rename internal/db/migrations/{ => sqlite}/00024_idempotency_keys.sql (100%)
rename internal/db/migrations/{ => sqlite}/00025_booking_hosts_needs_sync.sql (100%)
rename internal/db/migrations/{ => sqlite}/00026_event_type_subjects.sql (100%)
rename internal/db/migrations/{ => sqlite}/00027_webhook_fields.sql (100%)
rename internal/db/migrations/{ => sqlite}/00028_tracking_settings.sql (100%)
rename internal/db/migrations/{ => sqlite}/00029_branding_settings.sql (100%)
rename internal/db/migrations/{ => sqlite}/00030_logo_height.sql (100%)
rename internal/db/migrations/{ => sqlite}/00031_logo_opacity.sql (100%)
rename internal/db/migrations/{ => sqlite}/00032_calendar_account_kind.sql (100%)
rename internal/db/migrations/{ => sqlite}/00033_oauth_mcp.sql (100%)
rename internal/db/migrations/{ => sqlite}/00034_llm_settings.sql (100%)
rename internal/db/migrations/{ => sqlite}/00035_llm_instructions.sql (100%)
rename internal/db/migrations/{ => sqlite}/00036_zoom_integration.sql (100%)
rename internal/db/migrations/{ => sqlite}/00037_stripe_payments.sql (100%)
rename internal/db/migrations/{ => sqlite}/00038_booking_amount_paid.sql (100%)
rename internal/db/migrations/{ => sqlite}/00039_calendar_account_email.sql (100%)
rename internal/db/migrations/{ => sqlite}/00040_magic_link_tokens.sql (100%)
rename internal/db/migrations/{ => sqlite}/00041_native_analytics.sql (100%)
rename internal/db/migrations/{ => sqlite}/00042_livekit.sql (100%)
rename internal/db/migrations/{ => sqlite}/00043_livekit_recording.sql (100%)
rename internal/db/migrations/{ => sqlite}/00044_meeting_consents.sql (100%)
rename internal/db/migrations/{ => sqlite}/00045_notetaker.sql (100%)
rename internal/db/migrations/{ => sqlite}/00046_legal_links.sql (100%)
rename internal/db/migrations/{ => sqlite}/00047_event_type_archived.sql (100%)
rename internal/db/migrations/{ => sqlite}/00048_override_group.sql (100%)
rename internal/db/migrations/{ => sqlite}/00049_connection_calendars.sql (100%)
rename internal/db/migrations/{ => sqlite}/00050_branding_banner.sql (100%)
rename internal/db/migrations/{ => sqlite}/00051_booking_attendee_locale.sql (100%)
rename internal/db/migrations/{ => sqlite}/00052_msg_greeting.sql (100%)
rename internal/db/migrations/{ => sqlite}/00053_fallback_locale.sql (100%)
rename internal/db/migrations/{ => sqlite}/00054_resend_api_key.sql (100%)
rename internal/db/migrations/{ => sqlite}/00055_booking_hosts_calendar_id.sql (100%)
rename internal/db/migrations/{ => sqlite}/00056_bookings_list_indexes.sql (100%)
rename internal/db/migrations/{ => sqlite}/00057_event_type_show_taken_slots.sql (100%)
create mode 100644 internal/db/rebind.go
create mode 100644 internal/db/rebind_test.go
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/db/db.go b/internal/db/db.go
index 21c6240..97a9283 100644
--- a/internal/db/db.go
+++ b/internal/db/db.go
@@ -15,15 +15,41 @@ import (
_ "modernc.org/sqlite"
)
-//go:embed migrations/*.sql
+//go:embed migrations/sqlite/*.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.
+// Open connects to the database at the given URL and returns the bare handle.
+//
+// Kept for callers that have not moved to OpenDB yet; it is the same connection,
+// without the dialect. Statements issued through it are not rebound, so on
+// Postgres they must already use $n.
func Open(databaseURL string) (*sql.DB, error) {
+ h, err := OpenDB(databaseURL)
+ if err != nil {
+ return nil, err
+ }
+ return h.DB, nil
+}
+
+// OpenDB connects to the database named by databaseURL and configures the pool
+// for the engine it names.
+//
+// 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) (*DB, error) {
+ if dialectFromURL(databaseURL) == DialectPostgres {
+ return openPostgres(databaseURL)
+ }
+ return openSQLite(databaseURL)
+}
+
+// openSQLite opens SQLite and configures pragmas.
+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 +73,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 above 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 are deliberately modest: one
+// Calnode instance is one small process, and a self-hoster's Postgres is usually
+// sized to match.
+//
+// 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, and only
+// idx_bookings_no_double — exact start times — stops them. Closing that gap
+// belongs with the booking transaction (SERIALIZABLE, or a range exclusion
+// constraint), not with the pool.
+func openPostgres(databaseURL string) (*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(10)
+ db.SetMaxIdleConns(5)
+
+ return &DB{DB: db, dialect: DialectPostgres}, nil
+}
+
+// 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 in migrations/*.sql.
+// 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 +147,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
}
diff --git a/internal/db/db_test.go b/internal/db/db_test.go
index e07bf0b..4bc6045 100644
--- a/internal/db/db_test.go
+++ b/internal/db/db_test.go
@@ -2,6 +2,7 @@ package db_test
import (
"context"
+ "path/filepath"
"testing"
"github.com/calnode/calnode/internal/db"
@@ -140,3 +141,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/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/00003_job_lock_timeout.sql b/internal/db/migrations/sqlite/00003_job_lock_timeout.sql
similarity index 100%
rename from internal/db/migrations/00003_job_lock_timeout.sql
rename to internal/db/migrations/sqlite/00003_job_lock_timeout.sql
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/00005_override_unique.sql b/internal/db/migrations/sqlite/00005_override_unique.sql
similarity index 100%
rename from internal/db/migrations/00005_override_unique.sql
rename to internal/db/migrations/sqlite/00005_override_unique.sql
diff --git a/internal/db/migrations/00006_jobs_type_payload_unique.sql b/internal/db/migrations/sqlite/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/sqlite/00006_jobs_type_payload_unique.sql
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/00024_idempotency_keys.sql b/internal/db/migrations/sqlite/00024_idempotency_keys.sql
similarity index 100%
rename from internal/db/migrations/00024_idempotency_keys.sql
rename to internal/db/migrations/sqlite/00024_idempotency_keys.sql
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/00026_event_type_subjects.sql b/internal/db/migrations/sqlite/00026_event_type_subjects.sql
similarity index 100%
rename from internal/db/migrations/00026_event_type_subjects.sql
rename to internal/db/migrations/sqlite/00026_event_type_subjects.sql
diff --git a/internal/db/migrations/00027_webhook_fields.sql b/internal/db/migrations/sqlite/00027_webhook_fields.sql
similarity index 100%
rename from internal/db/migrations/00027_webhook_fields.sql
rename to internal/db/migrations/sqlite/00027_webhook_fields.sql
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/00029_branding_settings.sql b/internal/db/migrations/sqlite/00029_branding_settings.sql
similarity index 100%
rename from internal/db/migrations/00029_branding_settings.sql
rename to internal/db/migrations/sqlite/00029_branding_settings.sql
diff --git a/internal/db/migrations/00030_logo_height.sql b/internal/db/migrations/sqlite/00030_logo_height.sql
similarity index 100%
rename from internal/db/migrations/00030_logo_height.sql
rename to internal/db/migrations/sqlite/00030_logo_height.sql
diff --git a/internal/db/migrations/00031_logo_opacity.sql b/internal/db/migrations/sqlite/00031_logo_opacity.sql
similarity index 100%
rename from internal/db/migrations/00031_logo_opacity.sql
rename to internal/db/migrations/sqlite/00031_logo_opacity.sql
diff --git a/internal/db/migrations/00032_calendar_account_kind.sql b/internal/db/migrations/sqlite/00032_calendar_account_kind.sql
similarity index 100%
rename from internal/db/migrations/00032_calendar_account_kind.sql
rename to internal/db/migrations/sqlite/00032_calendar_account_kind.sql
diff --git a/internal/db/migrations/00033_oauth_mcp.sql b/internal/db/migrations/sqlite/00033_oauth_mcp.sql
similarity index 100%
rename from internal/db/migrations/00033_oauth_mcp.sql
rename to internal/db/migrations/sqlite/00033_oauth_mcp.sql
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/00035_llm_instructions.sql b/internal/db/migrations/sqlite/00035_llm_instructions.sql
similarity index 100%
rename from internal/db/migrations/00035_llm_instructions.sql
rename to internal/db/migrations/sqlite/00035_llm_instructions.sql
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/00046_legal_links.sql b/internal/db/migrations/sqlite/00046_legal_links.sql
similarity index 100%
rename from internal/db/migrations/00046_legal_links.sql
rename to internal/db/migrations/sqlite/00046_legal_links.sql
diff --git a/internal/db/migrations/00047_event_type_archived.sql b/internal/db/migrations/sqlite/00047_event_type_archived.sql
similarity index 100%
rename from internal/db/migrations/00047_event_type_archived.sql
rename to internal/db/migrations/sqlite/00047_event_type_archived.sql
diff --git a/internal/db/migrations/00048_override_group.sql b/internal/db/migrations/sqlite/00048_override_group.sql
similarity index 100%
rename from internal/db/migrations/00048_override_group.sql
rename to internal/db/migrations/sqlite/00048_override_group.sql
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/00050_branding_banner.sql b/internal/db/migrations/sqlite/00050_branding_banner.sql
similarity index 100%
rename from internal/db/migrations/00050_branding_banner.sql
rename to internal/db/migrations/sqlite/00050_branding_banner.sql
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/00056_bookings_list_indexes.sql b/internal/db/migrations/sqlite/00056_bookings_list_indexes.sql
similarity index 100%
rename from internal/db/migrations/00056_bookings_list_indexes.sql
rename to internal/db/migrations/sqlite/00056_bookings_list_indexes.sql
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/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")
+ }
+}
From dead65eabe39b109760c7b0533e6dafbe96161ab Mon Sep 17 00:00:00 2001
From: Sean Dean <254259913+distronode-com@users.noreply.github.com>
Date: Fri, 4 Sep 2026 03:24:07 -0400
Subject: [PATCH 02/51] db: PostgreSQL migration set, generated once from the
SQLite one
57 files in migrations/postgres, one per SQLite migration and numbered
identically, embedded alongside them. Open picks the directory and the goose
dialect from the DSN, so nothing else has to know which set is in use.
What the translation does, and what it deliberately does not:
- Flag columns stay integers (INTEGER -> SMALLINT), never BOOLEAN. Those columns
are scanned into Go ints across the codebase, so changing the type would break
every one of those scans. Columns that hold real numbers stay INTEGER. A test
reads information_schema and fails on a boolean.
- Timestamp defaults keep TEXT and keep SQLite's exact string format:
strftime('%Y-%m-%dT%H:%M:%fZ','now') becomes
to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') and
datetime('now') becomes the seconds-precision equivalent. TIMESTAMPTZ was the
alternative and is wrong here: every time value in this schema is written,
compared, sorted and paginated as a string, and database/sql would hand those
columns back formatted as RFC3339Nano, which sorts differently and would not
match a stored value. A test asserts both formats against the shapes SQLite
writes.
- Partial unique indexes carry over verbatim; a test proves idx_bookings_no_double
still refuses a second booking at the same start time and still allows re-booking
a cancelled slot.
- crypto_keystore.id relied on SQLite's rowid (keyvault.go inserts without an id),
so it becomes BIGINT GENERATED BY DEFAULT AS IDENTITY. server_settings.id does
not: it is seeded explicitly and pinned by CHECK(id = 1), where a sequence would
only mislead. BLOB becomes BYTEA.
- The three table rebuilds become what Postgres can do directly: 00012 drops the
four columns, 00014 and 00042 replace a constraint in place. 00042 therefore
needs neither NO TRANSACTION nor the foreign_keys PRAGMA toggle, both of which
existed only to make SQLite's rebuild safe.
- lower(hex(randomblob(16))) becomes replace(gen_random_uuid()::text, '-', ''),
the same 32 lowercase hex characters with no extension to install.
INSERT OR IGNORE becomes ON CONFLICT DO NOTHING.
- Downs that are no-ops on SQLite stay no-ops here even where Postgres could drop
the column. A down that lands on a different schema per engine is worse than one
that lands on none, and it would make the following up fail on one engine only.
Tests are opt-in on CALNODE_TEST_POSTGRES_DSN and skip cleanly when it is unset,
so upstream CI and any contributor running SQLite is unaffected. Each test runs in
a schema of its own, created and dropped around it, so runs neither collide nor
leave anything behind in an operator's database.
---
internal/db/db.go | 2 +-
.../postgres/00001_initial_schema.sql | 210 ++++++++
.../postgres/00002_manage_tokens.sql | 14 +
.../postgres/00003_job_lock_timeout.sql | 14 +
.../db/migrations/postgres/00004_sessions.sql | 17 +
.../postgres/00005_override_unique.sql | 6 +
.../00006_jobs_type_payload_unique.sql | 9 +
.../migrations/postgres/00007_user_prefs.sql | 12 +
.../migrations/postgres/00008_date_format.sql | 10 +
.../postgres/00009_override_reason.sql | 10 +
.../postgres/00010_messaging_prefs.sql | 29 ++
.../postgres/00011_server_settings.sql | 20 +
.../postgres/00012_auth_providers.sql | 33 ++
.../postgres/00013_google_oauth_settings.sql | 8 +
...00014_booking_answers_question_cascade.sql | 20 +
.../00015_calendar_connections_expiry.sql | 7 +
.../00016_event_types_max_active_bookings.sql | 10 +
.../postgres/00017_crypto_keystore.sql | 18 +
.../migrations/postgres/00018_user_roles.sql | 15 +
.../postgres/00019_user_archived.sql | 11 +
.../postgres/00020_user_archived_by.sql | 9 +
.../postgres/00021_event_type_hosts.sql | 36 ++
.../postgres/00022_booking_hosts.sql | 24 +
.../postgres/00023_booking_hosts_event_id.sql | 17 +
.../postgres/00024_idempotency_keys.sql | 18 +
.../00025_booking_hosts_needs_sync.sql | 10 +
.../postgres/00026_event_type_subjects.sql | 14 +
.../postgres/00027_webhook_fields.sql | 9 +
.../postgres/00028_tracking_settings.sql | 18 +
.../postgres/00029_branding_settings.sql | 12 +
.../migrations/postgres/00030_logo_height.sql | 7 +
.../postgres/00031_logo_opacity.sql | 7 +
.../postgres/00032_calendar_account_kind.sql | 10 +
.../migrations/postgres/00033_oauth_mcp.sql | 49 ++
.../postgres/00034_llm_settings.sql | 14 +
.../postgres/00035_llm_instructions.sql | 8 +
.../postgres/00036_zoom_integration.sql | 25 +
.../postgres/00037_stripe_payments.sql | 26 +
.../postgres/00038_booking_amount_paid.sql | 11 +
.../postgres/00039_calendar_account_email.sql | 11 +
.../postgres/00040_magic_link_tokens.sql | 13 +
.../postgres/00041_native_analytics.sql | 11 +
.../db/migrations/postgres/00042_livekit.sql | 26 +
.../postgres/00043_livekit_recording.sql | 23 +
.../postgres/00044_meeting_consents.sql | 15 +
.../migrations/postgres/00045_notetaker.sql | 34 ++
.../migrations/postgres/00046_legal_links.sql | 11 +
.../postgres/00047_event_type_archived.sql | 10 +
.../postgres/00048_override_group.sql | 9 +
.../postgres/00049_connection_calendars.sql | 38 ++
.../postgres/00050_branding_banner.sql | 11 +
.../00051_booking_attendee_locale.sql | 12 +
.../postgres/00052_msg_greeting.sql | 13 +
.../postgres/00053_fallback_locale.sql | 10 +
.../postgres/00054_resend_api_key.sql | 19 +
.../00055_booking_hosts_calendar_id.sql | 21 +
.../postgres/00056_bookings_list_indexes.sql | 34 ++
.../00057_event_type_show_taken_slots.sql | 18 +
internal/db/migrations_internal_test.go | 58 +++
internal/db/postgres_test.go | 491 ++++++++++++++++++
60 files changed, 1686 insertions(+), 1 deletion(-)
create mode 100644 internal/db/migrations/postgres/00001_initial_schema.sql
create mode 100644 internal/db/migrations/postgres/00002_manage_tokens.sql
create mode 100644 internal/db/migrations/postgres/00003_job_lock_timeout.sql
create mode 100644 internal/db/migrations/postgres/00004_sessions.sql
create mode 100644 internal/db/migrations/postgres/00005_override_unique.sql
create mode 100644 internal/db/migrations/postgres/00006_jobs_type_payload_unique.sql
create mode 100644 internal/db/migrations/postgres/00007_user_prefs.sql
create mode 100644 internal/db/migrations/postgres/00008_date_format.sql
create mode 100644 internal/db/migrations/postgres/00009_override_reason.sql
create mode 100644 internal/db/migrations/postgres/00010_messaging_prefs.sql
create mode 100644 internal/db/migrations/postgres/00011_server_settings.sql
create mode 100644 internal/db/migrations/postgres/00012_auth_providers.sql
create mode 100644 internal/db/migrations/postgres/00013_google_oauth_settings.sql
create mode 100644 internal/db/migrations/postgres/00014_booking_answers_question_cascade.sql
create mode 100644 internal/db/migrations/postgres/00015_calendar_connections_expiry.sql
create mode 100644 internal/db/migrations/postgres/00016_event_types_max_active_bookings.sql
create mode 100644 internal/db/migrations/postgres/00017_crypto_keystore.sql
create mode 100644 internal/db/migrations/postgres/00018_user_roles.sql
create mode 100644 internal/db/migrations/postgres/00019_user_archived.sql
create mode 100644 internal/db/migrations/postgres/00020_user_archived_by.sql
create mode 100644 internal/db/migrations/postgres/00021_event_type_hosts.sql
create mode 100644 internal/db/migrations/postgres/00022_booking_hosts.sql
create mode 100644 internal/db/migrations/postgres/00023_booking_hosts_event_id.sql
create mode 100644 internal/db/migrations/postgres/00024_idempotency_keys.sql
create mode 100644 internal/db/migrations/postgres/00025_booking_hosts_needs_sync.sql
create mode 100644 internal/db/migrations/postgres/00026_event_type_subjects.sql
create mode 100644 internal/db/migrations/postgres/00027_webhook_fields.sql
create mode 100644 internal/db/migrations/postgres/00028_tracking_settings.sql
create mode 100644 internal/db/migrations/postgres/00029_branding_settings.sql
create mode 100644 internal/db/migrations/postgres/00030_logo_height.sql
create mode 100644 internal/db/migrations/postgres/00031_logo_opacity.sql
create mode 100644 internal/db/migrations/postgres/00032_calendar_account_kind.sql
create mode 100644 internal/db/migrations/postgres/00033_oauth_mcp.sql
create mode 100644 internal/db/migrations/postgres/00034_llm_settings.sql
create mode 100644 internal/db/migrations/postgres/00035_llm_instructions.sql
create mode 100644 internal/db/migrations/postgres/00036_zoom_integration.sql
create mode 100644 internal/db/migrations/postgres/00037_stripe_payments.sql
create mode 100644 internal/db/migrations/postgres/00038_booking_amount_paid.sql
create mode 100644 internal/db/migrations/postgres/00039_calendar_account_email.sql
create mode 100644 internal/db/migrations/postgres/00040_magic_link_tokens.sql
create mode 100644 internal/db/migrations/postgres/00041_native_analytics.sql
create mode 100644 internal/db/migrations/postgres/00042_livekit.sql
create mode 100644 internal/db/migrations/postgres/00043_livekit_recording.sql
create mode 100644 internal/db/migrations/postgres/00044_meeting_consents.sql
create mode 100644 internal/db/migrations/postgres/00045_notetaker.sql
create mode 100644 internal/db/migrations/postgres/00046_legal_links.sql
create mode 100644 internal/db/migrations/postgres/00047_event_type_archived.sql
create mode 100644 internal/db/migrations/postgres/00048_override_group.sql
create mode 100644 internal/db/migrations/postgres/00049_connection_calendars.sql
create mode 100644 internal/db/migrations/postgres/00050_branding_banner.sql
create mode 100644 internal/db/migrations/postgres/00051_booking_attendee_locale.sql
create mode 100644 internal/db/migrations/postgres/00052_msg_greeting.sql
create mode 100644 internal/db/migrations/postgres/00053_fallback_locale.sql
create mode 100644 internal/db/migrations/postgres/00054_resend_api_key.sql
create mode 100644 internal/db/migrations/postgres/00055_booking_hosts_calendar_id.sql
create mode 100644 internal/db/migrations/postgres/00056_bookings_list_indexes.sql
create mode 100644 internal/db/migrations/postgres/00057_event_type_show_taken_slots.sql
create mode 100644 internal/db/migrations_internal_test.go
create mode 100644 internal/db/postgres_test.go
diff --git a/internal/db/db.go b/internal/db/db.go
index 97a9283..8bacdd2 100644
--- a/internal/db/db.go
+++ b/internal/db/db.go
@@ -15,7 +15,7 @@ import (
_ "modernc.org/sqlite"
)
-//go:embed migrations/sqlite/*.sql
+//go:embed migrations/sqlite/*.sql migrations/postgres/*.sql
var migrations embed.FS
// Open connects to the database at the given URL and returns the bare handle.
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/postgres/00003_job_lock_timeout.sql b/internal/db/migrations/postgres/00003_job_lock_timeout.sql
new file mode 100644
index 0000000..b78622d
--- /dev/null
+++ b/internal/db/migrations/postgres/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/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/postgres/00005_override_unique.sql b/internal/db/migrations/postgres/00005_override_unique.sql
new file mode 100644
index 0000000..62ebd36
--- /dev/null
+++ b/internal/db/migrations/postgres/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/postgres/00006_jobs_type_payload_unique.sql b/internal/db/migrations/postgres/00006_jobs_type_payload_unique.sql
new file mode 100644
index 0000000..46a7ae2
--- /dev/null
+++ b/internal/db/migrations/postgres/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/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/postgres/00024_idempotency_keys.sql b/internal/db/migrations/postgres/00024_idempotency_keys.sql
new file mode 100644
index 0000000..42573e8
--- /dev/null
+++ b/internal/db/migrations/postgres/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/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/postgres/00026_event_type_subjects.sql b/internal/db/migrations/postgres/00026_event_type_subjects.sql
new file mode 100644
index 0000000..4e58851
--- /dev/null
+++ b/internal/db/migrations/postgres/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/postgres/00027_webhook_fields.sql b/internal/db/migrations/postgres/00027_webhook_fields.sql
new file mode 100644
index 0000000..01a7397
--- /dev/null
+++ b/internal/db/migrations/postgres/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/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/postgres/00029_branding_settings.sql b/internal/db/migrations/postgres/00029_branding_settings.sql
new file mode 100644
index 0000000..3445c88
--- /dev/null
+++ b/internal/db/migrations/postgres/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/postgres/00030_logo_height.sql b/internal/db/migrations/postgres/00030_logo_height.sql
new file mode 100644
index 0000000..326a38b
--- /dev/null
+++ b/internal/db/migrations/postgres/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/postgres/00031_logo_opacity.sql b/internal/db/migrations/postgres/00031_logo_opacity.sql
new file mode 100644
index 0000000..6a7bdb8
--- /dev/null
+++ b/internal/db/migrations/postgres/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/postgres/00032_calendar_account_kind.sql b/internal/db/migrations/postgres/00032_calendar_account_kind.sql
new file mode 100644
index 0000000..0a66861
--- /dev/null
+++ b/internal/db/migrations/postgres/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/postgres/00033_oauth_mcp.sql b/internal/db/migrations/postgres/00033_oauth_mcp.sql
new file mode 100644
index 0000000..a17e378
--- /dev/null
+++ b/internal/db/migrations/postgres/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/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/postgres/00035_llm_instructions.sql b/internal/db/migrations/postgres/00035_llm_instructions.sql
new file mode 100644
index 0000000..1d08451
--- /dev/null
+++ b/internal/db/migrations/postgres/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/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/postgres/00046_legal_links.sql b/internal/db/migrations/postgres/00046_legal_links.sql
new file mode 100644
index 0000000..5fef0ab
--- /dev/null
+++ b/internal/db/migrations/postgres/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/postgres/00047_event_type_archived.sql b/internal/db/migrations/postgres/00047_event_type_archived.sql
new file mode 100644
index 0000000..a863f8d
--- /dev/null
+++ b/internal/db/migrations/postgres/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/postgres/00048_override_group.sql b/internal/db/migrations/postgres/00048_override_group.sql
new file mode 100644
index 0000000..e74d3f7
--- /dev/null
+++ b/internal/db/migrations/postgres/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/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/postgres/00050_branding_banner.sql b/internal/db/migrations/postgres/00050_branding_banner.sql
new file mode 100644
index 0000000..ea8a528
--- /dev/null
+++ b/internal/db/migrations/postgres/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/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/postgres/00056_bookings_list_indexes.sql b/internal/db/migrations/postgres/00056_bookings_list_indexes.sql
new file mode 100644
index 0000000..d07b8ad
--- /dev/null
+++ b/internal/db/migrations/postgres/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/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_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/postgres_test.go b/internal/db/postgres_test.go
new file mode 100644
index 0000000..f28e15a
--- /dev/null
+++ b/internal/db/postgres_test.go
@@ -0,0 +1,491 @@
+package db_test
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "net/url"
+ "os"
+ "regexp"
+ "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"
+
+// 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 != 57 {
+ t.Errorf("target version = %d; want 57 (sanity check against the known migration set)", target)
+ }
+
+ 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 != 57 {
+ t.Errorf("applied version = %d; want 57", applied)
+ }
+}
+
+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)
+ }
+}
From 65099c3e196bcb857f060cb74c5e34e402409aca Mon Sep 17 00:00:00 2001
From: Sean Dean <254259913+distronode-com@users.noreply.github.com>
Date: Fri, 4 Sep 2026 03:25:31 -0400
Subject: [PATCH 03/51] db: compare the migrated schemas across both engines
Every other cross-engine test asserts that one specific thing survived the
translation. This one asserts that nothing else changed: it migrates SQLite and
Postgres to 57 in the same process and compares the table list and each table's
column names, so a column quietly dropped, renamed or added in one set fails here
rather than in whichever handler reads it.
Names only, deliberately. TEXT versus text and SMALLINT versus integer are the
translation doing its job, and the types that do matter are already pinned by
TestPostgres_flagColumnsStayIntegers. The table-count floor is there because an
empty map on either side would satisfy every comparison in the test.
---
internal/db/postgres_test.go | 125 +++++++++++++++++++++++++++++++++++
1 file changed, 125 insertions(+)
diff --git a/internal/db/postgres_test.go b/internal/db/postgres_test.go
index f28e15a..e3b43a1 100644
--- a/internal/db/postgres_test.go
+++ b/internal/db/postgres_test.go
@@ -3,10 +3,12 @@ package db_test
import (
"context"
"crypto/rand"
+ "database/sql"
"encoding/hex"
"net/url"
"os"
"regexp"
+ "slices"
"testing"
"github.com/calnode/calnode/internal/db"
@@ -489,3 +491,126 @@ func TestPostgres_partialUniqueIndexEnforced(t *testing.T) {
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
+}
From dfd7bd734cd7a3786ba108c253c131be59276b49 Mon Sep 17 00:00:00 2001
From: Sean Dean <254259913+distronode-com@users.noreply.github.com>
Date: Fri, 4 Sep 2026 03:23:11 -0400
Subject: [PATCH 04/51] db: thread *db.DB / *db.Tx through every call site
The dialect-aware wrapper rebinds ? to $n on Postgres and is a no-op on SQLite,
so the only way the rest of Calnode benefits is to hold the wrapper rather than
the bare *sql.DB. Method names match database/sql, so this is a type change at
the declarations and almost nothing at the 763 call sites.
Three places did need a decision rather than a substitution:
- internal/connstore.Execer is an interface over QueryRowContext, so both the
wrapper and a bare *sql.DB satisfy it; only its doc comment changed.
destination_test.go opens the sqlite driver directly against a bespoke
fragment schema, and stays on *sql.DB for that reason.
- handler.Readyz passes h.db.DB to db.SchemaReady, which takes a bare pool.
Safe: that statement has no placeholders, so there is nothing to rebind.
- cmd/calnode moved from db.Open + db.Migrate(database) to db.OpenDB and the
handle's own Migrate method, which is what carries the dialect.
SQLite behaviour is unchanged: Rebind returns its input untouched there.
---
PROGRESS.md | 35 +++++++++++++++++++
cmd/calnode/main.go | 4 +--
cmd/calnode/mcp.go | 4 +--
cmd/calnode/recover_key.go | 2 +-
cmd/calnode/reset_admin.go | 2 +-
cmd/calnode/rotate_key.go | 2 +-
internal/booking/service.go | 11 +++---
internal/booking/service_test.go | 11 +++---
internal/caldav/caldav.go | 5 +--
internal/caldav/caldav_test.go | 13 ++++---
internal/calendar/calendar.go | 5 +--
internal/calendar/calendar_test.go | 8 ++---
internal/calendar/conflicts.go | 5 +--
internal/calendar/connid_staleness_test.go | 10 +++---
internal/calendar/microsoft/microsoft.go | 5 +--
internal/calendar/microsoft/microsoft_test.go | 9 +++--
internal/connstore/connstore.go | 2 +-
internal/connstore/connstore_test.go | 11 +++---
internal/demo/demo.go | 8 ++---
internal/demo/demo_test.go | 9 +++--
internal/gcal/gcal.go | 5 +--
internal/gcal/gcal_test.go | 11 +++---
internal/handler/auth_google_test.go | 9 +++--
internal/handler/booking_filter_test.go | 4 +--
internal/handler/calendar_test.go | 4 +--
internal/handler/checkbox_answer_test.go | 4 +--
internal/handler/email_settings.go | 5 +--
internal/handler/google_settings.go | 3 +-
internal/handler/google_settings_test.go | 4 +--
internal/handler/handler.go | 6 ++--
internal/handler/health.go | 5 ++-
internal/handler/health_test.go | 8 ++---
internal/handler/livekit_settings.go | 3 +-
internal/handler/llm_settings.go | 3 +-
internal/handler/manage_test.go | 13 ++++---
internal/handler/mcp_scope_test.go | 4 +--
internal/handler/slots_busy_test.go | 8 ++---
.../handler/stripe_locale_internal_test.go | 4 +--
internal/handler/stripe_settings.go | 3 +-
internal/handler/zoom_settings.go | 3 +-
internal/keyvault/keyvault.go | 17 ++++-----
internal/keyvault/keyvault_test.go | 7 ++--
internal/server/server.go | 8 ++---
internal/webhook/webhook.go | 5 +--
internal/webhook/webhook_test.go | 15 ++++----
internal/worker/delivery_retention_test.go | 7 ++--
internal/worker/worker.go | 5 +--
internal/worker/worker_test.go | 9 +++--
internal/zoom/zoom.go | 5 +--
internal/zoom/zoom_test.go | 11 +++---
50 files changed, 201 insertions(+), 158 deletions(-)
create mode 100644 PROGRESS.md
diff --git a/PROGRESS.md b/PROGRESS.md
new file mode 100644
index 0000000..8bbca8d
--- /dev/null
+++ b/PROGRESS.md
@@ -0,0 +1,35 @@
+# PROGRESS — Postgres call sites (`feat/postgres-callsites`)
+
+Converting Calnode's call sites onto the dialect-aware `*db.DB` / `*db.Tx` wrapper
+(`internal/db`, built on the branch's base commit) so the app runs on PostgreSQL as
+well as SQLite. SQLite behaviour must not change.
+
+## Boundary 1 — thread the type through — DONE
+
+Every struct field, constructor, package-level helper and test helper that held
+`*sql.DB` now holds `*db.DB`; `*sql.Tx` became `*db.Tx`. Call sites needed no edit:
+the wrapper's method names match `database/sql`'s.
+
+- `db.Open` → `db.OpenDB` and `db.Migrate(x)` → `x.Migrate()` in `cmd/calnode/*` and
+ every test helper.
+- `internal/connstore`: `Execer` is an interface, so it needed no change; its doc
+ comment now names `*db.DB` / `*db.Tx`. `destination_test.go` deliberately opens a
+ bare `sql.Open("sqlite", ":memory:")` against a bespoke fragment schema and stays
+ `*sql.DB` — `Execer` accepts it.
+- `internal/handler/health.go` passes `h.db.DB` to `db.SchemaReady`, which takes a
+ bare `*sql.DB`. Legitimate: its one statement carries no placeholders.
+
+Gates: `go build ./...`, `go vet ./...`, `gofmt -l`, `go test ./...` all clean on SQLite.
+
+## Boundary 2 — non-portable SQL — TODO
+
+## Boundary 3 — advisory lock replacing the single-writer guarantee — TODO
+
+## Boundary 4 — Postgres test harness, docs, CI — TODO
+
+## Blocked on the database layer
+
+`internal/db/migrations/postgres` does not exist yet (`db.go` embeds
+`migrations/sqlite/*.sql` only), so nothing can migrate on Postgres and no
+Postgres-side run can be verified from this branch yet. The harness added in
+Boundary 4 calls `h.Migrate()` and starts working the moment those land.
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/internal/booking/service.go b/internal/booking/service.go
index da604f9..2f4b3ad 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}
}
@@ -279,7 +280,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 +378,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 +388,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)
diff --git a/internal/booking/service_test.go b/internal/booking/service_test.go
index 9f656a0..5cc1e5c 100644
--- a/internal/booking/service_test.go
+++ b/internal/booking/service_test.go
@@ -2,7 +2,6 @@ package booking_test
import (
"context"
- "database/sql"
"testing"
"time"
@@ -11,20 +10,20 @@ import (
"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:")
+ database, err := db.OpenDB("sqlite://:memory:")
if err != nil {
t.Fatalf("open test db: %v", err)
}
t.Cleanup(func() { database.Close() })
- if err := db.Migrate(database); err != nil {
+ if err := database.Migrate(); err != nil {
t.Fatalf("migrate test db: %v", err)
}
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 +36,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..89bba63 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"
@@ -16,13 +15,13 @@ import (
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:")
+ database, err := db.OpenDB("sqlite://:memory:")
if err != nil {
t.Fatalf("open: %v", err)
}
- if err := db.Migrate(database); err != nil {
+ if err := database.Migrate(); err != nil {
t.Fatalf("migrate: %v", err)
}
t.Cleanup(func() { database.Close() })
@@ -41,7 +40,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 +266,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 +277,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..b9df253 100644
--- a/internal/calendar/calendar_test.go
+++ b/internal/calendar/calendar_test.go
@@ -8,12 +8,12 @@ import (
)
func TestCanAutoGenerate(t *testing.T) {
- database, err := db.Open("sqlite://:memory:")
+ database, err := db.OpenDB("sqlite://:memory:")
if err != nil {
t.Fatalf("open: %v", err)
}
defer database.Close()
- if err := db.Migrate(database); err != nil {
+ if err := database.Migrate(); err != nil {
t.Fatalf("migrate: %v", err)
}
ctx := context.Background()
@@ -66,12 +66,12 @@ 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:")
+ database, err := db.OpenDB("sqlite://:memory:")
if err != nil {
t.Fatalf("open: %v", err)
}
defer database.Close()
- if err := db.Migrate(database); err != nil {
+ if err := database.Migrate(); err != nil {
t.Fatalf("migrate: %v", err)
}
ctx := context.Background()
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..9a40641 100644
--- a/internal/calendar/connid_staleness_test.go
+++ b/internal/calendar/connid_staleness_test.go
@@ -10,14 +10,14 @@ import (
)
// 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:")
+ database, err := db.OpenDB("sqlite://:memory:")
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { database.Close() })
- if err := db.Migrate(database); err != nil {
+ if err := database.Migrate(); err != nil {
t.Fatalf("migrate: %v", err)
}
if _, err := database.Exec(
@@ -28,7 +28,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 +50,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..919c149 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"
@@ -19,13 +18,13 @@ import (
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:")
+ database, err := db.OpenDB("sqlite://:memory:")
if err != nil {
t.Fatalf("open: %v", err)
}
- if err := db.Migrate(database); err != nil {
+ if err := database.Migrate(); err != nil {
t.Fatalf("migrate: %v", err)
}
t.Cleanup(func() { database.Close() })
@@ -41,7 +40,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/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..d36be87 100644
--- a/internal/connstore/connstore_test.go
+++ b/internal/connstore/connstore_test.go
@@ -2,26 +2,25 @@ package connstore
import (
"context"
- "database/sql"
"testing"
"github.com/calnode/calnode/internal/db"
)
-func newTestDB(t *testing.T) *sql.DB {
+func newTestDB(t *testing.T) *db.DB {
t.Helper()
- database, err := db.Open("sqlite://:memory:")
+ database, err := db.OpenDB("sqlite://:memory:")
if err != nil {
t.Fatalf("newTestDB: open: %v", err)
}
- if err := db.Migrate(database); err != nil {
+ if err := database.Migrate(); err != nil {
t.Fatalf("newTestDB: migrate: %v", err)
}
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 +30,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/demo/demo.go b/internal/demo/demo.go
index e37c6ef..a62c29e 100644
--- a/internal/demo/demo.go
+++ b/internal/demo/demo.go
@@ -7,10 +7,10 @@ package demo
import (
"context"
- "database/sql"
"fmt"
"time"
+ "github.com/calnode/calnode/internal/db"
"github.com/calnode/calnode/internal/uid"
)
@@ -29,7 +29,7 @@ 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)
@@ -181,7 +181,7 @@ 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
@@ -219,7 +219,7 @@ func Reset(ctx context.Context, db *sql.DB) (err error) {
return Seed(ctx, db)
}
-func listTables(ctx context.Context, db *sql.DB) ([]string, error) {
+func listTables(ctx context.Context, db *db.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'`)
diff --git a/internal/demo/demo_test.go b/internal/demo/demo_test.go
index 183300c..ffd8d65 100644
--- a/internal/demo/demo_test.go
+++ b/internal/demo/demo_test.go
@@ -2,27 +2,26 @@ package demo_test
import (
"context"
- "database/sql"
"testing"
"github.com/calnode/calnode/internal/db"
"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:")
+ database, err := db.OpenDB("sqlite://:memory:")
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { database.Close() })
- if err := db.Migrate(database); err != nil {
+ if err := database.Migrate(); err != nil {
t.Fatalf("db.Migrate: %v", err)
}
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..89a8926 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"
@@ -15,13 +14,13 @@ import (
// 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:")
+ database, err := db.OpenDB("sqlite://:memory:")
if err != nil {
t.Fatalf("newTestDB: open: %v", err)
}
- if err := db.Migrate(database); err != nil {
+ if err := database.Migrate(); err != nil {
t.Fatalf("newTestDB: migrate: %v", err)
}
t.Cleanup(func() { database.Close() })
@@ -38,7 +37,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,7 +313,7 @@ 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 {
diff --git a/internal/handler/auth_google_test.go b/internal/handler/auth_google_test.go
index 79b9b5a..1f2a05a 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"
@@ -15,13 +14,13 @@ import (
// 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:")
+ database, err := db.OpenDB("sqlite://:memory:")
if err != nil {
t.Fatalf("db.Open: %v", err)
}
- if err := db.Migrate(database); err != nil {
+ if err := database.Migrate(); err != nil {
t.Fatalf("db.Migrate: %v", err)
}
t.Cleanup(func() { database.Close() })
@@ -35,7 +34,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/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/calendar_test.go b/internal/handler/calendar_test.go
index 5358ff2..6edfb09 100644
--- a/internal/handler/calendar_test.go
+++ b/internal/handler/calendar_test.go
@@ -21,11 +21,11 @@ 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:")
+ database, err := db.OpenDB("sqlite://:memory:")
if err != nil {
t.Fatalf("db.Open: %v", err)
}
- if err := db.Migrate(database); err != nil {
+ if err := database.Migrate(); err != nil {
t.Fatalf("db.Migrate: %v", err)
}
t.Cleanup(func() { database.Close() })
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..8285a27 100644
--- a/internal/handler/email_settings.go
+++ b/internal/handler/email_settings.go
@@ -10,6 +10,7 @@ import (
"strconv"
"time"
+ "github.com/calnode/calnode/internal/db"
"github.com/calnode/calnode/internal/mailer"
"github.com/calnode/calnode/internal/secret"
)
@@ -67,7 +68,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 +164,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.
diff --git a/internal/handler/google_settings.go b/internal/handler/google_settings.go
index 965aa5e..4a5fea2 100644
--- a/internal/handler/google_settings.go
+++ b/internal/handler/google_settings.go
@@ -8,6 +8,7 @@ import (
"net/http"
"github.com/calnode/calnode/internal/calendar"
+ "github.com/calnode/calnode/internal/db"
"github.com/calnode/calnode/internal/gcal"
"github.com/calnode/calnode/internal/secret"
)
@@ -21,7 +22,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
diff --git a/internal/handler/google_settings_test.go b/internal/handler/google_settings_test.go
index 0b8554d..3d5ccdb 100644
--- a/internal/handler/google_settings_test.go
+++ b/internal/handler/google_settings_test.go
@@ -1,19 +1,19 @@
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/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.
diff --git a/internal/handler/handler.go b/internal/handler/handler.go
index 6adbb08..59b1809 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,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/livekit"
"github.com/calnode/calnode/internal/llm"
"github.com/calnode/calnode/internal/mailer"
@@ -21,7 +21,7 @@ import (
)
type Handler struct {
- db *sql.DB
+ db *db.DB
logger *slog.Logger
bookingSvc *booking.Service
mailer mailer.Mailer
@@ -121,7 +121,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..a36eb03 100644
--- a/internal/handler/health.go
+++ b/internal/handler/health.go
@@ -32,7 +32,10 @@ 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)
+ //
+ // SchemaReady takes the bare pool: its one statement carries no placeholders,
+ // so there is nothing for the wrapper to rebind.
+ ready, err := db.SchemaReady(r.Context(), h.db.DB)
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..d042234 100644
--- a/internal/handler/health_test.go
+++ b/internal/handler/health_test.go
@@ -13,11 +13,11 @@ import (
func newTestHandler(t *testing.T) *handler.Handler {
t.Helper()
- database, err := db.Open("sqlite://:memory:")
+ database, err := db.OpenDB("sqlite://:memory:")
if err != nil {
t.Fatalf("db.Open: %v", err)
}
- if err := db.Migrate(database); err != nil {
+ if err := database.Migrate(); err != nil {
t.Fatalf("db.Migrate: %v", err)
}
t.Cleanup(func() { database.Close() })
@@ -76,7 +76,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 +115,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/livekit_settings.go b/internal/handler/livekit_settings.go
index daf4965..3dbafb3 100644
--- a/internal/handler/livekit_settings.go
+++ b/internal/handler/livekit_settings.go
@@ -8,6 +8,7 @@ import (
"encoding/json"
+ "github.com/calnode/calnode/internal/db"
"github.com/calnode/calnode/internal/livekit"
"github.com/calnode/calnode/internal/secret"
)
@@ -21,7 +22,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
diff --git a/internal/handler/llm_settings.go b/internal/handler/llm_settings.go
index c5019f9..967505a 100644
--- a/internal/handler/llm_settings.go
+++ b/internal/handler/llm_settings.go
@@ -9,6 +9,7 @@ import (
"strings"
"time"
+ "github.com/calnode/calnode/internal/db"
"github.com/calnode/calnode/internal/llm"
"github.com/calnode/calnode/internal/secret"
)
@@ -34,7 +35,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(`
diff --git a/internal/handler/manage_test.go b/internal/handler/manage_test.go
index 505ec4b..4a743f7 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"
@@ -19,13 +18,13 @@ import (
// 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:")
+ database, err := db.OpenDB("sqlite://:memory:")
if err != nil {
t.Fatalf("db.Open: %v", err)
}
- if err := db.Migrate(database); err != nil {
+ if err := database.Migrate(); err != nil {
t.Fatalf("db.Migrate: %v", err)
}
t.Cleanup(func() { database.Close() })
@@ -33,7 +32,7 @@ func newTestHandlerDB(t *testing.T) (*handler.Handler, *sql.DB) {
}
// 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 +58,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 +94,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..4614ab1 100644
--- a/internal/handler/mcp_scope_test.go
+++ b/internal/handler/mcp_scope_test.go
@@ -17,12 +17,12 @@ import (
// 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:")
+ database, err := db.OpenDB("sqlite://:memory:")
if err != nil {
t.Fatalf("db open: %v", err)
}
t.Cleanup(func() { database.Close() })
- if err := db.Migrate(database); err != nil {
+ if err := database.Migrate(); err != nil {
t.Fatalf("migrate: %v", err)
}
h := New(database, slog.Default())
diff --git a/internal/handler/slots_busy_test.go b/internal/handler/slots_busy_test.go
index 9d4be97..c415d96 100644
--- a/internal/handler/slots_busy_test.go
+++ b/internal/handler/slots_busy_test.go
@@ -16,12 +16,12 @@ 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:")
+ database, err := db.OpenDB("sqlite://:memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
defer database.Close()
- if err := db.Migrate(database); err != nil {
+ if err := database.Migrate(); err != nil {
t.Fatalf("migrate: %v", err)
}
h := New(database, slog.New(slog.DiscardHandler))
@@ -60,12 +60,12 @@ 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:")
+ database, err := db.OpenDB("sqlite://:memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
defer database.Close()
- if err := db.Migrate(database); err != nil {
+ if err := database.Migrate(); err != nil {
t.Fatalf("migrate: %v", err)
}
h := New(database, slog.New(slog.DiscardHandler))
diff --git a/internal/handler/stripe_locale_internal_test.go b/internal/handler/stripe_locale_internal_test.go
index efb4b64..7af442f 100644
--- a/internal/handler/stripe_locale_internal_test.go
+++ b/internal/handler/stripe_locale_internal_test.go
@@ -65,12 +65,12 @@ 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:")
+ database, err := db.OpenDB("sqlite://:memory:")
if err != nil {
t.Fatalf("db.Open: %v", err)
}
defer database.Close()
- if err := db.Migrate(database); err != nil {
+ if err := database.Migrate(); err != nil {
t.Fatalf("db.Migrate: %v", err)
}
diff --git a/internal/handler/stripe_settings.go b/internal/handler/stripe_settings.go
index 6c65e35..003c16b 100644
--- a/internal/handler/stripe_settings.go
+++ b/internal/handler/stripe_settings.go
@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
+ "github.com/calnode/calnode/internal/db"
"github.com/calnode/calnode/internal/secret"
"github.com/calnode/calnode/internal/stripe"
)
@@ -19,7 +20,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
diff --git a/internal/handler/zoom_settings.go b/internal/handler/zoom_settings.go
index a6c79d8..91033db 100644
--- a/internal/handler/zoom_settings.go
+++ b/internal/handler/zoom_settings.go
@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
+ "github.com/calnode/calnode/internal/db"
"github.com/calnode/calnode/internal/secret"
"github.com/calnode/calnode/internal/zoom"
)
@@ -20,7 +21,7 @@ type ZoomOAuthConfig struct {
// LoadZoomSettingsFromDB reads the Zoom OAuth app credentials from server_settings and
// decrypts the client secret. Returns nil (not an error) when client_id is empty.
-func LoadZoomSettingsFromDB(db *sql.DB, encKey [32]byte) (*ZoomOAuthConfig, error) {
+func LoadZoomSettingsFromDB(db *db.DB, encKey [32]byte) (*ZoomOAuthConfig, error) {
var clientID, secretEnc string
err := db.QueryRow(`
SELECT zoom_client_id, zoom_client_secret_enc
diff --git a/internal/keyvault/keyvault.go b/internal/keyvault/keyvault.go
index 2a799a2..c16b3f4 100644
--- a/internal/keyvault/keyvault.go
+++ b/internal/keyvault/keyvault.go
@@ -23,6 +23,7 @@ import (
"fmt"
"log/slog"
+ "github.com/calnode/calnode/internal/db"
"golang.org/x/crypto/argon2"
)
@@ -69,7 +70,7 @@ func (v *Vault) DEKHex() string { return hex.EncodeToString(v.dek[:]) }
// working; wrap and store it.
// 4. platformSecret empty + devMode → ephemeral random DEK; warn; never stored.
// 5. platformSecret empty + !devMode → fatal error.
-func Open(db *sql.DB, platformSecret, recoverySecret string, devMode bool) (*Vault, error) {
+func Open(db *db.DB, platformSecret, recoverySecret string, devMode bool) (*Vault, error) {
if platformSecret == "" {
if devMode {
slog.Warn("keyvault: CALNODE_ENCRYPTION_KEY is not set — using an ephemeral key that will change on restart; stored secrets will become unreadable")
@@ -115,7 +116,7 @@ func Open(db *sql.DB, platformSecret, recoverySecret string, devMode bool) (*Vau
// RotatePrimary re-wraps the DEK under a new platform secret. The data columns
// are never touched. oldSecret must match the current CALNODE_ENCRYPTION_KEY.
-func RotatePrimary(db *sql.DB, oldSecret, newSecret string) error {
+func RotatePrimary(db *db.DB, oldSecret, newSecret string) error {
row := db.QueryRow(`
SELECT wrapped_dek, kdf_salt, kdf_params
FROM crypto_keystore WHERE label = ?`, labelPrimary)
@@ -137,7 +138,7 @@ func RotatePrimary(db *sql.DB, oldSecret, newSecret string) error {
// RecoverPrimary uses the recovery secret (CALNODE_RECOVERY_SECRET) to
// establish a new platform secret when the old one is lost.
-func RecoverPrimary(db *sql.DB, recoverySecret, newPlatformSecret string) error {
+func RecoverPrimary(db *db.DB, recoverySecret, newPlatformSecret string) error {
row := db.QueryRow(`
SELECT wrapped_dek, kdf_salt, kdf_params
FROM crypto_keystore WHERE label = ?`, labelRecovery)
@@ -171,7 +172,7 @@ func openExisting(platformSecret string, wrappedDEK, kdfSalt []byte, paramsJSON
return &Vault{dek: dek}, nil
}
-func freshInstall(db *sql.DB, platformSecret, recoverySecret string) (*Vault, error) {
+func freshInstall(db *db.DB, platformSecret, recoverySecret string) (*Vault, error) {
var dek [32]byte
if _, err := rand.Read(dek[:]); err != nil {
return nil, fmt.Errorf("keyvault: generate DEK: %w", err)
@@ -182,7 +183,7 @@ func freshInstall(db *sql.DB, platformSecret, recoverySecret string) (*Vault, er
return &Vault{dek: dek}, nil
}
-func migrateLegacy(db *sql.DB, platformSecret, recoverySecret string) (*Vault, error) {
+func migrateLegacy(db *db.DB, platformSecret, recoverySecret string) (*Vault, error) {
// The existing deployment used platformSecret directly as the raw AES-256 key.
// Adopt that value as the DEK so all existing *_enc ciphertext keeps decrypting.
raw, err := hex.DecodeString(platformSecret)
@@ -197,7 +198,7 @@ func migrateLegacy(db *sql.DB, platformSecret, recoverySecret string) (*Vault, e
return &Vault{dek: dek}, nil
}
-func storeKeystore(db *sql.DB, dek [32]byte, platformSecret, recoverySecret string) error {
+func storeKeystore(db *db.DB, dek [32]byte, platformSecret, recoverySecret string) error {
if err := insertKeystoreRow(db, labelPrimary, dek, platformSecret); err != nil {
return err
}
@@ -209,7 +210,7 @@ func storeKeystore(db *sql.DB, dek [32]byte, platformSecret, recoverySecret stri
return nil
}
-func insertKeystoreRow(db *sql.DB, label string, dek [32]byte, secret string) error {
+func insertKeystoreRow(db *db.DB, label string, dek [32]byte, secret string) error {
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return fmt.Errorf("keyvault: generate salt for %s: %w", label, err)
@@ -291,7 +292,7 @@ func unwrapDEK(kek [32]byte, wrapped []byte) ([32]byte, error) {
// hasEncryptedData returns true if any *_enc column contains data, which
// signals a legacy deployment that used a raw key rather than the vault.
-func hasEncryptedData(db *sql.DB) (bool, error) {
+func hasEncryptedData(db *db.DB) (bool, error) {
checks := []string{
`SELECT 1 FROM server_settings WHERE smtp_pass_enc != '' AND smtp_pass_enc IS NOT NULL LIMIT 1`,
`SELECT 1 FROM server_settings WHERE google_client_secret_enc != '' AND google_client_secret_enc IS NOT NULL LIMIT 1`,
diff --git a/internal/keyvault/keyvault_test.go b/internal/keyvault/keyvault_test.go
index 4b689a5..00e2951 100644
--- a/internal/keyvault/keyvault_test.go
+++ b/internal/keyvault/keyvault_test.go
@@ -1,7 +1,6 @@
package keyvault_test
import (
- "database/sql"
"encoding/hex"
"testing"
@@ -9,13 +8,13 @@ import (
"github.com/calnode/calnode/internal/keyvault"
)
-func newTestDB(t *testing.T) *sql.DB {
+func newTestDB(t *testing.T) *db.DB {
t.Helper()
- database, err := db.Open("sqlite://file::memory:?cache=shared&_fk=1")
+ database, err := db.OpenDB("sqlite://file::memory:?cache=shared&_fk=1")
if err != nil {
t.Fatalf("open test db: %v", err)
}
- if err := db.Migrate(database); err != nil {
+ if err := database.Migrate(); err != nil {
t.Fatalf("migrate test db: %v", err)
}
t.Cleanup(func() { database.Close() })
diff --git a/internal/server/server.go b/internal/server/server.go
index d770024..c8289c5 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -2,7 +2,6 @@ package server
import (
"context"
- "database/sql"
"log/slog"
"net/http"
"time"
@@ -20,6 +19,7 @@ import (
"github.com/calnode/calnode/internal/mailer"
"github.com/calnode/calnode/internal/secret"
+ "github.com/calnode/calnode/internal/db"
"github.com/calnode/calnode/internal/stripe"
"github.com/calnode/calnode/internal/webhook"
"github.com/calnode/calnode/internal/worker"
@@ -36,7 +36,7 @@ import (
// uses it to back the HTTP server; the `calnode mcp` subcommand uses it to serve the
// MCP server over stdio. The returned drain func blocks until the background worker
// has finished its current poll cycle.
-func BuildHandler(ctx context.Context, cfg *config.Config, db *sql.DB, logger *slog.Logger) (*handler.Handler, func()) {
+func BuildHandler(ctx context.Context, cfg *config.Config, db *db.DB, logger *slog.Logger) (*handler.Handler, func()) {
h := handler.New(db, logger)
h.SetBaseURL(cfg.BaseURL)
h.SetPublicBaseURL(cfg.PublicBaseURL)
@@ -248,7 +248,7 @@ func BuildHandler(ctx context.Context, cfg *config.Config, db *sql.DB, logger *s
// New wires services via BuildHandler, then registers all HTTP routes. It returns the
// http.Handler and the worker drain func.
-func New(ctx context.Context, cfg *config.Config, db *sql.DB, logger *slog.Logger) (http.Handler, func()) {
+func New(ctx context.Context, cfg *config.Config, db *db.DB, logger *slog.Logger) (http.Handler, func()) {
h, drain := BuildHandler(ctx, cfg, db, logger)
mux := http.NewServeMux()
@@ -542,7 +542,7 @@ func New(ctx context.Context, cfg *config.Config, db *sql.DB, logger *slog.Logge
// seedSMTPToDB writes env-var SMTP settings into the DB on first boot so they
// appear in the UI. Uses WHERE smtp_host = ” to avoid a check-then-act race
// and to never overwrite settings the user has already saved via the UI.
-func seedSMTPToDB(db *sql.DB, cfg *config.Config, encKey [32]byte, logger *slog.Logger) {
+func seedSMTPToDB(db *db.DB, cfg *config.Config, encKey [32]byte, logger *slog.Logger) {
var passEnc string
if cfg.SMTPPass != "" {
enc, err := secret.Encrypt(encKey, cfg.SMTPPass)
diff --git a/internal/webhook/webhook.go b/internal/webhook/webhook.go
index d266df8..49d8280 100644
--- a/internal/webhook/webhook.go
+++ b/internal/webhook/webhook.go
@@ -17,6 +17,7 @@ import (
"strings"
"time"
+ "github.com/calnode/calnode/internal/db"
"github.com/calnode/calnode/internal/uid"
)
@@ -128,13 +129,13 @@ type BookingPayload struct {
}
type Service struct {
- db *sql.DB
+ db *db.DB
key [32]byte
}
// New creates a Service. If encKeyHex is empty an ephemeral key is generated
// (secrets won't survive restarts but the server still works in dev/test).
-func New(db *sql.DB, encKeyHex string) (*Service, error) {
+func New(db *db.DB, encKeyHex string) (*Service, error) {
s := &Service{db: db}
if encKeyHex != "" {
b, err := hex.DecodeString(encKeyHex)
diff --git a/internal/webhook/webhook_test.go b/internal/webhook/webhook_test.go
index b29ec0a..7f129e2 100644
--- a/internal/webhook/webhook_test.go
+++ b/internal/webhook/webhook_test.go
@@ -4,7 +4,6 @@ import (
"context"
"crypto/hmac"
"crypto/sha256"
- "database/sql"
"encoding/hex"
"testing"
@@ -19,16 +18,16 @@ const (
type env struct {
svc *webhook.Service
- db *sql.DB
+ db *db.DB
}
func newEnv(t *testing.T) *env {
t.Helper()
- database, err := db.Open("sqlite://:memory:")
+ database, err := db.OpenDB("sqlite://:memory:")
if err != nil {
t.Fatalf("db.Open: %v", err)
}
- if err := db.Migrate(database); err != nil {
+ if err := database.Migrate(); err != nil {
t.Fatalf("db.Migrate: %v", err)
}
t.Cleanup(func() { database.Close() })
@@ -54,8 +53,8 @@ func newEnv(t *testing.T) *env {
// ---------------------------------------------------------------------------
func TestNew_badKeyReturnsError(t *testing.T) {
- database, _ := db.Open("sqlite://:memory:")
- db.Migrate(database)
+ database, _ := db.OpenDB("sqlite://:memory:")
+ database.Migrate()
defer database.Close()
if _, err := webhook.New(database, "not-hex"); err == nil {
@@ -67,8 +66,8 @@ func TestNew_badKeyReturnsError(t *testing.T) {
}
func TestNew_emptyKeyUsesEphemeral(t *testing.T) {
- database, _ := db.Open("sqlite://:memory:")
- db.Migrate(database)
+ database, _ := db.OpenDB("sqlite://:memory:")
+ database.Migrate()
defer database.Close()
svc, err := webhook.New(database, "")
diff --git a/internal/worker/delivery_retention_test.go b/internal/worker/delivery_retention_test.go
index 7035ef8..06e4053 100644
--- a/internal/worker/delivery_retention_test.go
+++ b/internal/worker/delivery_retention_test.go
@@ -2,15 +2,16 @@ package worker_test
import (
"context"
- "database/sql"
"testing"
"time"
+
+ "github.com/calnode/calnode/internal/db"
)
// seedDelivery inserts one webhook_deliveries row with an explicit status and
// last_attempted_at. A nil attemptedAt leaves the column NULL, which is what a
// delivery that has never been tried looks like.
-func seedDelivery(t *testing.T, database *sql.DB, id, webhookID, status string, attemptedAt *time.Time) {
+func seedDelivery(t *testing.T, database *db.DB, id, webhookID, status string, attemptedAt *time.Time) {
t.Helper()
var at any
if attemptedAt != nil {
@@ -24,7 +25,7 @@ func seedDelivery(t *testing.T, database *sql.DB, id, webhookID, status string,
}
}
-func deliveryExists(t *testing.T, database *sql.DB, id string) bool {
+func deliveryExists(t *testing.T, database *db.DB, id string) bool {
t.Helper()
var n int
if err := database.QueryRow(
diff --git a/internal/worker/worker.go b/internal/worker/worker.go
index b44ed1b..9188957 100644
--- a/internal/worker/worker.go
+++ b/internal/worker/worker.go
@@ -11,6 +11,7 @@ import (
"net/http"
"time"
+ "github.com/calnode/calnode/internal/db"
"github.com/calnode/calnode/internal/i18n"
"github.com/calnode/calnode/internal/mailer"
"github.com/calnode/calnode/internal/netutil"
@@ -25,7 +26,7 @@ const webhookDeliveryRetention = 30 * 24 * time.Hour
// Worker polls the jobs table and processes pending jobs (webhooks, reminders).
type Worker struct {
- db *sql.DB
+ db *db.DB
svc *webhook.Service
mailer mailer.Mailer
logger *slog.Logger
@@ -51,7 +52,7 @@ func WithMailer(m mailer.Mailer) func(*Worker) {
return func(w *Worker) { w.mailer = m }
}
-func New(db *sql.DB, svc *webhook.Service, logger *slog.Logger, opts ...func(*Worker)) *Worker {
+func New(db *db.DB, svc *webhook.Service, logger *slog.Logger, opts ...func(*Worker)) *Worker {
w := &Worker{
db: db,
svc: svc,
diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go
index 916c9f1..2e7d3d8 100644
--- a/internal/worker/worker_test.go
+++ b/internal/worker/worker_test.go
@@ -4,7 +4,6 @@ import (
"context"
"crypto/hmac"
"crypto/sha256"
- "database/sql"
"encoding/hex"
"encoding/json"
"io"
@@ -19,13 +18,13 @@ import (
"github.com/calnode/calnode/internal/worker"
)
-func setup(t *testing.T) (*sql.DB, *webhook.Service) {
+func setup(t *testing.T) (*db.DB, *webhook.Service) {
t.Helper()
- database, err := db.Open("sqlite://:memory:")
+ database, err := db.OpenDB("sqlite://:memory:")
if err != nil {
t.Fatalf("db.Open: %v", err)
}
- if err := db.Migrate(database); err != nil {
+ if err := database.Migrate(); err != nil {
t.Fatalf("db.Migrate: %v", err)
}
t.Cleanup(func() { database.Close() })
@@ -49,7 +48,7 @@ func setup(t *testing.T) (*sql.DB, *webhook.Service) {
return database, svc
}
-func newWorker(t *testing.T, database *sql.DB, svc *webhook.Service) *worker.Worker {
+func newWorker(t *testing.T, database *db.DB, svc *webhook.Service) *worker.Worker {
t.Helper()
// Tests use a local httptest.Server, so bypass the production SSRF guard.
return worker.New(database, svc, slog.Default(),
diff --git a/internal/zoom/zoom.go b/internal/zoom/zoom.go
index e572e38..02075e4 100644
--- a/internal/zoom/zoom.go
+++ b/internal/zoom/zoom.go
@@ -20,6 +20,7 @@ import (
"golang.org/x/oauth2"
+ "github.com/calnode/calnode/internal/db"
"github.com/calnode/calnode/internal/oauthstore"
"github.com/calnode/calnode/internal/secret"
)
@@ -28,14 +29,14 @@ import (
type Client struct {
config *oauth2.Config
key [32]byte
- db *sql.DB
+ db *db.DB
logger *slog.Logger
apiBase string // https://api.zoom.us/v2; overridable in tests
}
// New builds a Client from the instance's Zoom OAuth app credentials. encKeyHex is the
// 64-char hex AES-256 server key (same one used for calendar tokens).
-func New(db *sql.DB, clientID, clientSecret, redirectURL, encKeyHex string) (*Client, error) {
+func New(db *db.DB, clientID, clientSecret, redirectURL, encKeyHex string) (*Client, error) {
key, err := secret.ParseKey(encKeyHex)
if err != nil {
return nil, fmt.Errorf("zoom: invalid encryption key: %w", err)
diff --git a/internal/zoom/zoom_test.go b/internal/zoom/zoom_test.go
index f1fb43d..7ecc207 100644
--- a/internal/zoom/zoom_test.go
+++ b/internal/zoom/zoom_test.go
@@ -2,7 +2,6 @@ package zoom
import (
"context"
- "database/sql"
"encoding/json"
"io"
"net/http"
@@ -16,13 +15,13 @@ import (
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:")
+ database, err := db.OpenDB("sqlite://:memory:")
if err != nil {
t.Fatalf("newTestDB: open: %v", err)
}
- if err := db.Migrate(database); err != nil {
+ if err := database.Migrate(); err != nil {
t.Fatalf("newTestDB: migrate: %v", err)
}
t.Cleanup(func() { database.Close() })
@@ -38,7 +37,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)
@@ -50,7 +49,7 @@ func seedUser(t *testing.T, database *sql.DB, userID string) {
}
// connectZoom inserts a non-expired Zoom token row for userID.
-func connectZoom(t *testing.T, database *sql.DB, userID, accessToken string) {
+func connectZoom(t *testing.T, database *db.DB, userID, accessToken string) {
t.Helper()
key, _ := secret.ParseKey(testKeyHex)
enc, err := secret.Encrypt(key, accessToken)
From 62dc81e7ccf83897aa135eb2f7765d513d0e4e98 Mon Sep 17 00:00:00 2001
From: Sean Dean <254259913+distronode-com@users.noreply.github.com>
Date: Fri, 4 Sep 2026 03:32:43 -0400
Subject: [PATCH 05/51] db: port the SQL that only SQLite accepts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Timestamps first, because they are 38 of the 44 sites. datetime('now') and
strftime('%Y-%m-%dT%H:%M:%fZ','now') are now computed in Go and bound as
parameters, which removes the dialect difference rather than encoding it and
makes the value something a test can predict.
internal/dbtime holds the two layouts. Two, not one: the schema already stores
both shapes, and they are load-bearing. recordings.created_at is the space-
separated form and meeting_consents.decided_at the millisecond form, and
consentWindow converts between them to build a lexicographic BETWEEN; notes'
updated_at is handed to a client verbatim; jobs.run_at written by enqueueJob is
deliberately the space form, which sorts before every T-separated run_at and is
what makes those jobs due immediately. Normalising would have been tidier and
would have quietly changed all three. dbtime_test asserts both layouts against
SQLite's own output rather than against a reading of its documentation.
The rest:
- INSERT OR IGNORE becomes ON CONFLICT DO NOTHING with no conflict target,
which both engines accept and which keeps OR IGNORE's "any unique
constraint" scope.
- COLLATE NOCASE becomes LOWER(col) = LOWER(?). No dialect pair, because
nothing indexes booking_attendees.email: there is no collation for an index
to depend on, so there is no plan to preserve.
- demo.Reset's PRAGMA foreign_keys is SQLite-only now. Postgres has no
equivalent without superuser rights, so that branch wipes with a single
TRUNCATE ... CASCADE over every table, which needs no delete ordering at
all. sqlite_master becomes pg_tables scoped to current_schema(), the one
Dialect.SQL pair added here — current_schema() also keeps it right inside an
isolated test schema.
RETURNING is left alone: both engines support it. No UPDATE or DELETE carries a
LIMIT; that was checked by scanning every backquoted string in the tree rather
than grepping single lines.
SQLite behaviour is unchanged, stored bytes included.
---
PROGRESS.md | 28 ++++++++-
internal/booking/service.go | 2 +-
internal/dbtime/dbtime.go | 35 ++++++++++++
internal/dbtime/dbtime_test.go | 51 +++++++++++++++++
internal/demo/demo.go | 72 +++++++++++++++++-------
internal/handler/booking_handler.go | 15 +++--
internal/handler/branding_settings.go | 13 +++--
internal/handler/email_settings.go | 11 ++--
internal/handler/email_settings_test.go | 7 ++-
internal/handler/event_type.go | 8 ++-
internal/handler/google_settings.go | 13 +++--
internal/handler/google_settings_test.go | 5 +-
internal/handler/handler.go | 4 +-
internal/handler/livekit_recording.go | 20 ++++---
internal/handler/livekit_settings.go | 9 +--
internal/handler/llm_settings.go | 6 +-
internal/handler/notetaker.go | 20 ++++---
internal/handler/notetaker_http.go | 5 +-
internal/handler/storage_settings.go | 4 +-
internal/handler/stripe_settings.go | 13 +++--
internal/handler/tracking_settings.go | 6 +-
internal/handler/zoom_settings.go | 13 +++--
internal/keyvault/keyvault.go | 6 +-
internal/server/server.go | 5 +-
24 files changed, 278 insertions(+), 93 deletions(-)
create mode 100644 internal/dbtime/dbtime.go
create mode 100644 internal/dbtime/dbtime_test.go
diff --git a/PROGRESS.md b/PROGRESS.md
index 8bbca8d..d91b79f 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -21,7 +21,33 @@ the wrapper's method names match `database/sql`'s.
Gates: `go build ./...`, `go vet ./...`, `gofmt -l`, `go test ./...` all clean on SQLite.
-## Boundary 2 — non-portable SQL — TODO
+## Boundary 2 — non-portable SQL — DONE
+
+New package `internal/dbtime`: `Now()` for `datetime('now')`, `NowMilli()` for
+`strftime('%Y-%m-%dT%H:%M:%fZ','now')`. Every one of the 38 engine-side timestamps
+became a bound parameter. The two layouts are kept distinct on purpose — the schema
+already stores both shapes, they are compared lexicographically and served to
+clients verbatim, so folding them into one would change SQLite's stored bytes.
+`dbtime_test.go` asserts both layouts against SQLite's own output.
+
+- `INSERT OR IGNORE` → `INSERT … ON CONFLICT DO NOTHING`, no conflict target, which
+ both engines accept and which matches OR IGNORE's "any unique constraint" scope
+ (3 sites: demo seed, and the two reminder enqueues).
+- `COLLATE NOCASE` → `LOWER(col) = LOWER(?)` at both sites. No `d.SQL` pair: nothing
+ indexes `booking_attendees.email`, so there is no collation for an index to depend
+ on and no plan to preserve.
+- `PRAGMA foreign_keys` in `demo.Reset` is now SQLite-only. Postgres has no
+ equivalent short of superuser rights, so that branch wipes with one
+ `TRUNCATE … CASCADE` naming every table, which needs no FK ordering.
+- `sqlite_master` in `demo.listTables` is the one `d.SQL` pair: `pg_tables` scoped to
+ `current_schema()` on Postgres, which also keeps it correct inside an isolated
+ test schema.
+- `RETURNING position` (`question_handler.go`) is unchanged. Both engines support it;
+ verified on SQLite by the existing question tests, unverified on Postgres.
+- No `LIMIT` inside any `UPDATE`/`DELETE`. Checked by scanning every backquoted
+ string in the tree, not by a line grep.
+
+Gates: `go build ./...`, `go vet ./...`, `gofmt -l`, `go test ./...` clean on SQLite.
## Boundary 3 — advisory lock replacing the single-writer guarantee — TODO
diff --git a/internal/booking/service.go b/internal/booking/service.go
index 2f4b3ad..725e7a3 100644
--- a/internal/booking/service.go
+++ b/internal/booking/service.go
@@ -125,7 +125,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)
}
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 a62c29e..2a50c7f 100644
--- a/internal/demo/demo.go
+++ b/internal/demo/demo.go
@@ -8,6 +8,7 @@ package demo
import (
"context"
"fmt"
+ "strings"
"time"
"github.com/calnode/calnode/internal/db"
@@ -36,8 +37,11 @@ func Seed(ctx context.Context, db *db.DB) error {
}
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)
}
@@ -187,19 +191,27 @@ func Reset(ctx context.Context, db *db.DB) (err error) {
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 *db.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 *db.DB) (err error) {
return Seed(ctx, db)
}
+// 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) {
- rows, err := db.QueryContext(ctx, `
- SELECT name FROM sqlite_master
- WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name != 'goose_db_version'`)
+ // 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/handler/booking_handler.go b/internal/handler/booking_handler.go
index 7f0c5d9..c221cff 100644
--- a/internal/handler/booking_handler.go
+++ b/internal/handler/booking_handler.go
@@ -734,7 +734,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 {
@@ -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
}
@@ -1953,8 +1957,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/email_settings.go b/internal/handler/email_settings.go
index 8285a27..7dbf9b5 100644
--- a/internal/handler/email_settings.go
+++ b/internal/handler/email_settings.go
@@ -11,6 +11,7 @@ import (
"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"
)
@@ -186,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
@@ -266,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..2bf220e 100644
--- a/internal/handler/event_type.go
+++ b/internal/handler/event_type.go
@@ -7,6 +7,7 @@ import (
"net/http"
"strings"
+ "github.com/calnode/calnode/internal/dbtime"
"github.com/calnode/calnode/internal/uid"
)
@@ -596,9 +597,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")
diff --git a/internal/handler/google_settings.go b/internal/handler/google_settings.go
index 4a5fea2..cd70949 100644
--- a/internal/handler/google_settings.go
+++ b/internal/handler/google_settings.go
@@ -9,6 +9,7 @@ import (
"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"
)
@@ -98,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
@@ -122,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
@@ -132,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 3d5ccdb..56b012f 100644
--- a/internal/handler/google_settings_test.go
+++ b/internal/handler/google_settings_test.go
@@ -8,6 +8,7 @@ import (
"testing"
"github.com/calnode/calnode/internal/db"
+ "github.com/calnode/calnode/internal/dbtime"
"github.com/calnode/calnode/internal/handler"
)
@@ -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 59b1809..cf68ce8 100644
--- a/internal/handler/handler.go
+++ b/internal/handler/handler.go
@@ -12,6 +12,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/dbtime"
"github.com/calnode/calnode/internal/livekit"
"github.com/calnode/calnode/internal/llm"
"github.com/calnode/calnode/internal/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)
}
}
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 3dbafb3..9606ccf 100644
--- a/internal/handler/livekit_settings.go
+++ b/internal/handler/livekit_settings.go
@@ -9,6 +9,7 @@ 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"
)
@@ -91,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
@@ -119,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 967505a..97bf4e8 100644
--- a/internal/handler/llm_settings.go
+++ b/internal/handler/llm_settings.go
@@ -10,6 +10,7 @@ import (
"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"
)
@@ -155,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/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/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_settings.go b/internal/handler/stripe_settings.go
index 003c16b..2931f35 100644
--- a/internal/handler/stripe_settings.go
+++ b/internal/handler/stripe_settings.go
@@ -7,6 +7,7 @@ import (
"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"
)
@@ -98,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
@@ -112,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
@@ -128,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
@@ -142,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/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