Database Schema¶
Type: Cloudflare D1 (SQLite-compatible)
This document describes the schema produced by replaying migrations/ in
order — which, since #506, is exactly what database/setup-complete.sql
contains: its schema section is generated by
scripts/regenerate-setup-complete.mjs and CI verifies the two never diverge
(scripts/check-schema-drift.mjs in quality.yml). To change the schema,
add a migration, regenerate the bootstrap, and update this document together.
Overview¶
Schema is managed two ways:
migrations/NNNN_*.sql— numbered, sequential migrations. These are applied to the production D1 database automatically on deploy by the "Apply remote D1 migrations" step in.github/workflows/cloudflare-pages.yml(wrangler d1 migrations apply <db> --remote). Do not apply them manually.database/setup-complete.sql— a full bootstrap script (everyCREATE TABLE IF NOT EXISTS/CREATE INDEX IF NOT EXISTS, plus three seeded test-account rows) used to build a fresh database in one shot for local dev and CI/E2E. It must stay in sync with the cumulative effect ofmigrations/.database/seed-test-data.sql— additional venues/events/band test data layered on top ofsetup-complete.sqlfor local dev and E2E.
Local setup¶
Copied from the Initialize D1 database with schema step in
.github/actions/e2e-env/action.yml, which is how CI provisions D1 for E2E:
npx wrangler d1 execute settimes-production-db --local --persist-to .wrangler/state \
--file=database/setup-complete.sql
npx wrangler d1 execute settimes-production-db --local --persist-to .wrangler/state \
--file=database/seed-test-data.sql
(settimes-production-db is the database_name bound to env.DB — see
wrangler.toml.)
Conventions & gotchas¶
These are project-wide invariants; see CLAUDE.md for the full detail behind
each one.
- Datetime format: stored timestamps use SQLite's
datetime('now')format,YYYY-MM-DD HH:MM:SS(space separator) — not JStoISOString()'sT-separated format. Always normalize withtoSqliteDateTime()(functions/utils/authAttempts.js:55) before writing a computed timestamp. - Exception:
lucia_sessions.expires_atisINTEGER(Unix epoch seconds), the onlyexpires_atcolumn that isn'tTEXT. Compare withrow.expires_at * 1000 < Date.now()in JS orWHERE expires_at > unixepoch()in SQL — neverdatetime('now'). - Foreign-key enforcement is guaranteed by the middleware, not assumed:
functions/_middleware.jsrunsPRAGMA foreign_keys = ONbefore every mutating request rather than relying on driver defaults (GET/HEAD are skipped as read-only and cannot violate a constraint). Unit tests set the PRAGMA unconditionally infunctions/api/test-utils.js. - No transactions: the D1 binding has no
BEGIN/COMMIT. Useenv.DB.batch([...])for atomic multi-statement writes (all-or-nothing), or compensating deletes when a batch isn't possible — e.g. the event-duplication handler (functions/api/admin/events/[id]/duplicate.js) deletes the just-created event row if copying its performances fails, since it can't wrap both inserts in one transaction. - Password hashing is PBKDF2-SHA256, not bcrypt (
functions/utils/crypto.js). Current hashes are stored inusers.password_hashaspbkdf2$<iterations>$<salt-b64>$<hash-b64>.verifyPassword()also accepts a legacy<salt-b64>:<hash-b64>format at 100k iterations for hashes written before thepbkdf2$format existed — the three seeded test accounts at the bottom ofsetup-complete.sqlare still in that legacy format.
Tables by domain¶
Core event data¶
events¶
One row per band-crawl event (e.g. Vol. 16, Vol. 17).
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
name |
TEXT | NOT NULL |
date |
TEXT | NOT NULL, YYYY-MM-DD |
end_date |
TEXT | Nullable; added in migration 0038 for multi-day events |
slug |
TEXT | NOT NULL, UNIQUE |
is_published |
INTEGER | NOT NULL, DEFAULT 0 (0 = draft, 1 = published) |
status |
TEXT | DEFAULT 'draft' (e.g. also takes 'archived') |
archived_at |
TEXT | Nullable |
description |
TEXT | Nullable |
city |
TEXT | Nullable |
ticket_url |
TEXT | Nullable |
venue_info |
TEXT | Nullable (JSON) |
social_links |
TEXT | Nullable (JSON) |
theme_colors |
TEXT | Nullable (JSON) |
reveal_mode |
INTEGER | NOT NULL, DEFAULT 0 — added in migration 0034; gates whether unannounced performances show in the public schedule |
created_by_user_id |
INTEGER | Nullable, FK → users(id) |
updated_by_user_id |
INTEGER | Nullable, FK → users(id) |
created_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
updated_at |
TEXT | DEFAULT datetime('now'), bumped on UPDATE by the update_events_timestamp trigger |
Indexes: idx_events_published(is_published), idx_events_slug(slug),
idx_events_status(status), idx_events_archived(archived_at),
idx_events_published_date(is_published, date) (added in migration 0036).
venues¶
Shared physical venues, reused across events.
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
name |
TEXT | NOT NULL, UNIQUE |
address |
TEXT | Nullable |
created_by_user_id |
INTEGER | Nullable, FK → users(id) |
updated_by_user_id |
INTEGER | Nullable, FK → users(id) |
updated_at |
TEXT | DEFAULT datetime('now'), bumped on UPDATE by the update_venues_timestamp trigger |
website |
TEXT | Nullable |
instagram |
TEXT | Nullable — added in migration 0010 |
facebook |
TEXT | Nullable — added in migration 0010 |
created_at |
TEXT | Nullable, DEFAULT datetime('now') (no NOT NULL on this column) |
address_line1 |
TEXT | Nullable |
address_line2 |
TEXT | Nullable |
region |
TEXT | Nullable |
postal_code |
TEXT | Nullable |
country |
TEXT | Nullable |
phone |
TEXT | Nullable |
contact_email |
TEXT | Nullable |
city |
TEXT | Nullable — added in migration 0021 |
latitude |
REAL | Nullable — added in migration 0043, seeded for the 6 Vol. 17 King St N venues in 0044 |
longitude |
REAL | Nullable — same as above |
There is no capacity column (that was a phantom bootstrap-only field before
506). No indexes beyond the implicit one on the UNIQUE name column.¶
band_profiles¶
The reusable band identity — separate from any single event's schedule.
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
name |
TEXT | NOT NULL — display name, preserves artistic capitalization |
name_normalized |
TEXT | NOT NULL, UNIQUE — lowercased/trimmed form used for duplicate detection |
description |
TEXT | Nullable — bio/description (markdown supported) |
photo_url |
TEXT | Nullable — hero image URL (uploaded or external) |
genre |
TEXT | Nullable — comma-separated for MVP |
social_links |
TEXT | Nullable (JSON) |
created_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
updated_at |
TEXT | NOT NULL, DEFAULT datetime('now'), bumped on UPDATE by the update_band_profile_timestamp trigger |
created_by_user_id |
INTEGER | Nullable, FK → users(id) |
origin |
TEXT | Nullable |
origin_city |
TEXT | Nullable — added in migration 0020 |
origin_region |
TEXT | Nullable — added in migration 0020 |
contact_email |
TEXT | Nullable |
is_active |
INTEGER | NOT NULL, DEFAULT 1 |
total_views |
INTEGER | DEFAULT 0 — aggregated by functions/scheduled/aggregate-stats.js |
total_social_clicks |
INTEGER | DEFAULT 0 — same aggregation job |
popularity_score |
REAL | DEFAULT 0 — page_views*1 + social_clicks*3 (formula updated in migration 0048) |
photo_alt_text |
TEXT | Nullable — added in migration 0042 |
There are no url, band_name, bio, genres, hometown, formed_year, or
website columns — those were phantom bootstrap-only fields before #506.
Indexes: idx_band_profiles_name(name),
idx_band_profiles_normalized(name_normalized) (UNIQUE),
idx_band_profiles_genre(genre).
performances¶
Links a band_profiles row to an events row with scheduling detail — the
event-specific "who's playing when/where."
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
event_id |
INTEGER | NOT NULL, FK → events(id) ON DELETE CASCADE |
band_profile_id |
INTEGER | NOT NULL, FK → band_profiles(id) ON DELETE RESTRICT |
venue_id |
INTEGER | Nullable, FK → venues(id) ON DELETE SET NULL |
start_time |
TEXT | Nullable (made optional in migration 0032) |
end_time |
TEXT | Nullable (made optional in migration 0032) |
notes |
TEXT | Nullable |
is_announced |
INTEGER | NOT NULL, DEFAULT 1 — gates the public schedule and triggers follower emails on 0→1 |
band_follow_notified |
INTEGER | NOT NULL, DEFAULT 0 |
created_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
created_by_user_id |
INTEGER | Nullable, FK → users(id) |
updated_by_user_id |
INTEGER | Nullable, FK → users(id) |
updated_at |
TEXT | DEFAULT datetime('now'), bumped on UPDATE by the update_performances_timestamp trigger (defined in migrations/0033_restore_performances_trigger.sql, present in setup-complete.sql) |
There are no band_id, band_name, or stage columns — those were phantom
bootstrap-only fields before #506; no migration ever created them. Indexes:
idx_performances_event(event_id),
idx_performances_band(band_profile_id), idx_performances_venue(venue_id),
idx_performances_event_time(event_id, start_time),
idx_performances_announced(event_id, is_announced).
The table was dropped and recreated in migration 0032 (SQLite has no
ALTER COLUMN) to make venue_id/start_time/end_time nullable for
schedule-first entry flows.
Legacy / compatibility¶
bands and rate_limit (removed from the bootstrap, #506)¶
Two pre-v2 tables (bands: event-scoped band+performance rows, superseded by
band_profiles + performances; rate_limit: one-row-per-IP limiter,
superseded by rate_limits in migration 0028) existed only in the
hand-maintained bootstrap — no migration creates them, production does not
have them, and no code reads or writes them. They were removed from
database/setup-complete.sql when it was regenerated from the migrations
replay (#506).
sessions¶
A pre-Lucia-migration session table. Still created by setup-complete.sql
but no current endpoint reads from or writes to it — session management now
goes entirely through lucia_sessions (see below and
functions/utils/auth.js).
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
session_token |
TEXT | UNIQUE, NOT NULL |
user_id |
INTEGER | NOT NULL, FK → users(id) ON DELETE CASCADE |
ip_address |
TEXT | Nullable |
user_agent |
TEXT | Nullable |
remember_me |
INTEGER | DEFAULT 0 |
created_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
last_activity_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
expires_at |
TEXT | NOT NULL |
Indexes: idx_sessions_token(session_token), idx_sessions_user_id(user_id),
idx_sessions_expires(expires_at).
Fan engagement¶
band_follows¶
A fan's follow of a band (double opt-in — see CLAUDE.md "Band Announcements").
Added in migration 0035.
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
email |
TEXT | NOT NULL |
band_profile_id |
INTEGER | NOT NULL, FK → band_profiles(id) ON DELETE CASCADE |
verified |
INTEGER | NOT NULL, DEFAULT 0 — set to 1 only via confirm-follow |
verification_token |
TEXT | UNIQUE, nullable — cleared once confirmed |
unsubscribe_token |
TEXT | UNIQUE, NOT NULL |
created_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
consent_ip |
TEXT | Nullable — added in migration 0045 |
consent_method |
TEXT | NOT NULL, DEFAULT 'web_form' — added in migration 0045 |
batch_token |
TEXT | Nullable — added in migration 0047; ties together rows from one batch-follow submission so a multi-band follow sends a single confirmation email instead of one per band |
Constraint: UNIQUE(email, band_profile_id). Indexes:
idx_band_follows_band(band_profile_id), idx_band_follows_email(email),
idx_band_follows_batch_token(batch_token).
band_follow_notifications¶
Per-follower delivery tracking for "this band just got announced" emails. Added in migration 0041 to replace a fire-once latch that silently dropped fans whose first send failed.
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
performance_id |
INTEGER | NOT NULL, FK → performances(id) ON DELETE CASCADE |
band_follow_id |
INTEGER | NOT NULL, FK → band_follows(id) ON DELETE CASCADE |
notified_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
Constraint: UNIQUE(performance_id, band_follow_id) — an INSERT OR IGNORE
against this constraint is how notifyBandFollowers()
(functions/utils/bandFollowNotify.js) atomically claims a follower before
sending, and deletes the row again if the send fails so a later resend can
retry. Index: idx_band_follow_notifications_performance(performance_id).
band_announce_queue¶
Queue of per-follower "band announced" events awaiting a digest send. Added in migration 0046.
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
band_follow_id |
INTEGER | NOT NULL, FK → band_follows(id) ON DELETE CASCADE |
performance_id |
INTEGER | NOT NULL, FK → performances(id) ON DELETE CASCADE |
event_id |
INTEGER | NOT NULL — no FK constraint (denormalized snapshot) |
band_name |
TEXT | NOT NULL — denormalized snapshot of the band name at queue time |
event_name |
TEXT | NOT NULL — denormalized snapshot |
event_slug |
TEXT | NOT NULL — denormalized snapshot |
band_profile_id |
INTEGER | NOT NULL — no FK constraint (denormalized snapshot) |
queued_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
Constraint: UNIQUE(band_follow_id, performance_id). Indexes:
idx_band_announce_queue_event(event_id),
idx_band_announce_queue_follow(band_follow_id).
share_links¶
Server-side snapshot backing a shareable /s/[slug] schedule link. Added in
migration 0037; view_count added in migration 0040.
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
slug |
TEXT | NOT NULL, UNIQUE |
event_id |
INTEGER | NOT NULL, FK → events(id) ON DELETE CASCADE |
event_slug |
TEXT | NOT NULL |
performance_ids |
TEXT | NOT NULL (JSON array) |
band_names |
TEXT | NOT NULL (JSON array) |
created_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
expires_at |
TEXT | NOT NULL |
view_count |
INTEGER | NOT NULL, DEFAULT 0 — best-effort increment on preview view, not on the post-import refetch (see GET /api/schedule/share/[slug]) |
Indexes: idx_share_links_slug(slug), idx_share_links_expires_at(expires_at).
Per CLAUDE.md, share_links — not telemetry events — is the source of truth
for share create/view counts.
Subscriptions & schedule builds¶
These four tables are live and queried (functions/api/subscriptions/*,
functions/api/schedule/build.js, functions/api/admin/events/[id]/metrics.js,
functions/api/admin/analytics/subscriptions.js). They originate in
migrations/0006_migration-subscriptions.sql and
migrations/0007_migration-metrics.sql, and — since the database/setup-complete.sql
regeneration in #506 — are created by the bootstrap too. Columns below reflect
their final form after later ALTERs (migrations 0027 and 0045).
email_subscriptions¶
City/genre email digest subscriptions, double opt-in via a verification link.
Created by POST /api/subscriptions/subscribe.
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
email |
TEXT | NOT NULL |
city |
TEXT | NOT NULL — city name or 'all' |
genre |
TEXT | NOT NULL — genre or 'all' |
frequency |
TEXT | NOT NULL, DEFAULT 'weekly' — daily | weekly | monthly |
verified |
BOOLEAN | NOT NULL, DEFAULT 0 — declared BOOLEAN in the migration; stored as 0/1 |
verification_token |
TEXT | UNIQUE, nullable |
unsubscribe_token |
TEXT | UNIQUE, NOT NULL |
created_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
last_email_sent |
TEXT | Nullable |
consent_ip |
TEXT | Nullable — added in migration 0045 |
consent_method |
TEXT | NOT NULL, DEFAULT 'web_form' — added in migration 0045 |
Constraint: UNIQUE(email, city, genre). Indexes:
idx_subscriptions_email(email), idx_subscriptions_city_genre(city, genre),
idx_subscriptions_verified(verified),
idx_subscriptions_verification_token(verification_token),
idx_subscriptions_unsubscribe_token(unsubscribe_token).
subscription_verifications¶
Log row written when a subscription is confirmed
(functions/api/subscriptions/verify.js).
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
subscription_id |
INTEGER | NOT NULL, FK → email_subscriptions(id) ON DELETE CASCADE |
verified_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
The original ip_address column was dropped in migration 0027 (never
populated by any code path; PII storage minimisation). No indexes.
subscription_unsubscribes¶
Unsubscribe log for metrics (functions/api/subscriptions/unsubscribe.js).
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
subscription_id |
INTEGER | NOT NULL, FK → email_subscriptions(id) ON DELETE CASCADE |
unsubscribed_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
reason |
TEXT | Nullable — optional feedback |
No indexes.
schedule_builds¶
One row per (event, performance, anonymous session), recorded when a fan adds
a performance to their schedule (POST /api/schedule/build); read by the
admin event metrics endpoint.
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
event_id |
INTEGER | NOT NULL, FK → events(id) ON DELETE CASCADE |
performance_id |
INTEGER | NOT NULL, FK → performances(id) ON DELETE CASCADE |
user_session |
TEXT | NOT NULL — anonymous session identifier (no PII) |
created_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
Indexes: idx_schedule_builds_event(event_id),
idx_schedule_builds_session(user_session),
idx_schedule_builds_created(created_at), plus the UNIQUE index
idx_schedule_builds_unique(event_id, performance_id, user_session) added in
migration 0014 to deduplicate rows and prevent spam.
Auth & admin¶
users¶
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
email |
TEXT | UNIQUE, NOT NULL |
password_hash |
TEXT | NOT NULL — PBKDF2 format, see "Conventions & gotchas" |
role |
TEXT | NOT NULL, DEFAULT 'editor' — viewer | editor | admin |
name |
TEXT | Nullable |
first_name |
TEXT | Nullable — added in migration 0019 |
last_name |
TEXT | Nullable — added in migration 0019 |
is_active |
INTEGER | DEFAULT 1 (no NOT NULL) |
activation_token |
TEXT | Nullable — added in migration 0022 |
activation_token_expires_at |
TEXT | Nullable — added in migration 0022 |
activated_at |
TEXT | Nullable — added in migration 0022 |
totp_secret |
TEXT | Nullable |
totp_enabled |
INTEGER | DEFAULT 0 |
webauthn_enabled |
INTEGER | DEFAULT 0 |
email_otp_enabled |
INTEGER | DEFAULT 0 |
backup_codes |
TEXT | Nullable (JSON) |
require_2fa |
INTEGER | DEFAULT 1 |
deactivated_at |
TEXT | Nullable |
deactivated_by |
INTEGER | Nullable, FK → users(id) (self-referential) |
created_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
last_login |
TEXT | Nullable |
updated_at |
TEXT | DEFAULT datetime('now'), bumped on UPDATE by the update_users_timestamp trigger |
Indexes: idx_users_email(email), idx_users_role(role),
idx_users_activation_token(activation_token).
lucia_sessions¶
The active session store (despite the name, session management is a
hand-rolled direct-D1 manager in functions/utils/auth.js — the lucia
packages themselves were removed). Added in migration 0024.
| Column | Type | Notes |
|---|---|---|
id |
TEXT | PK (session id, not autoincrement) |
user_id |
INTEGER | NOT NULL, FK → users(id) ON DELETE CASCADE |
expires_at |
INTEGER | NOT NULL — Unix epoch seconds, not TEXT (the one exception) |
ip_address |
TEXT | Nullable |
user_agent |
TEXT | Nullable |
remember_me |
INTEGER | DEFAULT 0 |
created_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
last_activity_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
Indexes: idx_lucia_sessions_user_id(user_id),
idx_lucia_sessions_expires_at(expires_at).
mfa_challenges¶
Short-lived state for an in-progress TOTP verification. Added in migration 0015; the partial unique index enforcing a single active challenge per user was added in migration 0031.
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
token |
TEXT | NOT NULL, UNIQUE |
user_id |
INTEGER | NOT NULL, FK → users(id) ON DELETE CASCADE |
ip_address |
TEXT | Nullable |
user_agent |
TEXT | Nullable |
expires_at |
TEXT | NOT NULL |
used |
INTEGER | DEFAULT 0 |
used_at |
TEXT | Nullable |
created_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
Indexes: idx_mfa_challenges_token(token),
idx_mfa_challenges_user_id(user_id), idx_mfa_challenges_expires_at(expires_at),
and a partial unique index idx_mfa_challenges_active_user(user_id) WHERE used = 0
(at most one unused/active challenge per user).
invite_codes¶
Admin-issued signup invites (bypassed only by the test-only
ALLOW_ADMIN_SIGNUP env var — never set in production). Added in migration 0009.
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
code |
TEXT | NOT NULL, UNIQUE |
email |
TEXT | Nullable |
role |
TEXT | NOT NULL, DEFAULT 'editor' |
created_by_user_id |
INTEGER | Nullable, FK → users(id) ON DELETE SET NULL |
used_by_user_id |
INTEGER | Nullable, FK → users(id) ON DELETE SET NULL |
created_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
used_at |
TEXT | Nullable |
expires_at |
TEXT | NOT NULL — see "SQLite datetime" gotcha above (SEC-F1) |
is_active |
INTEGER | NOT NULL, DEFAULT 1 |
Indexes: idx_invite_codes_code(code),
idx_invite_codes_active(is_active, expires_at),
idx_invite_codes_email(email).
webauthn_credentials¶
Registered WebAuthn/passkey credentials, an alternate second factor to TOTP.
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
user_id |
INTEGER | NOT NULL, FK → users(id) ON DELETE CASCADE |
credential_id |
TEXT | UNIQUE, NOT NULL |
public_key |
TEXT | NOT NULL |
counter |
INTEGER | NOT NULL, DEFAULT 0 — WebAuthn signature counter |
device_name |
TEXT | Nullable |
created_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
last_used_at |
TEXT | Nullable |
Indexes: idx_webauthn_user_id(user_id),
idx_webauthn_credential_id(credential_id).
email_otp_codes¶
One-time email codes, a fallback second factor.
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
user_id |
INTEGER | NOT NULL, FK → users(id) ON DELETE CASCADE |
code_hash |
TEXT | NOT NULL — hash of the 6-digit code |
expires_at |
TEXT | NOT NULL — codes expire after 10 minutes |
verified |
INTEGER | DEFAULT 0 (no NOT NULL) — 1 if code was used |
created_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
Indexes: idx_email_otp_user_id(user_id), idx_email_otp_expires(expires_at).
password_reset_tokens¶
Admin- or self-initiated password reset tokens.
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
user_id |
INTEGER | NOT NULL, FK → users(id) ON DELETE CASCADE |
token |
TEXT | UNIQUE, NOT NULL |
created_by |
INTEGER | NOT NULL, FK → users(id) (no ON DELETE action) |
expires_at |
TEXT | NOT NULL |
used |
INTEGER | DEFAULT 0 (no NOT NULL) |
used_at |
TEXT | Nullable |
ip_address |
TEXT | Nullable |
reason |
TEXT | Nullable — e.g. admin-initiated vs. self-service |
created_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
Indexes: idx_password_reset_user_id(user_id),
idx_password_reset_token(token), idx_password_reset_expires(expires_at).
trusted_devices¶
"Remember this device" MFA bypass. Added in migration 0030.
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
user_id |
INTEGER | NOT NULL, FK → users(id) ON DELETE CASCADE |
token |
TEXT | UNIQUE, NOT NULL |
device_fingerprint |
TEXT | Nullable — hash of IP + User-Agent |
ua_hash |
TEXT | Nullable — hash of User-Agent alone, validated independently |
ip_address |
TEXT | Nullable |
user_agent |
TEXT | Nullable |
expires_at |
TEXT | NOT NULL |
last_used_at |
TEXT | Nullable |
created_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
Indexes: idx_trusted_devices_user(user_id), idx_trusted_devices_token(token),
idx_trusted_devices_expires(expires_at).
audit_log¶
General admin-action audit trail (e.g. user role changes) — read by
GET /api/admin/audit-log.
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
user_id |
INTEGER | Nullable, FK → users(id) ON DELETE SET NULL |
action |
TEXT | NOT NULL |
resource_type |
TEXT | Nullable |
resource_id |
INTEGER | Nullable |
details |
TEXT | Nullable (JSON) |
ip_address |
TEXT | Nullable |
created_at |
TEXT | DEFAULT datetime('now') |
Indexes: idx_audit_log_user(user_id), idx_audit_log_created(created_at),
idx_audit_log_action(action), idx_audit_log_resource(resource_type, resource_id).
auth_audit¶
Authentication-event audit trail (distinct from auth_attempts, which is
rate-limit bookkeeping — this table is append-only history read by
functions/api/admin/maintenance/retention.js and
functions/api/auth/reset-password-complete.js).
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
timestamp |
TEXT | NOT NULL, DEFAULT datetime('now') |
action |
TEXT | NOT NULL |
success |
INTEGER | NOT NULL (0/1) |
ip_address |
TEXT | NOT NULL |
user_agent |
TEXT | Nullable |
details |
TEXT | Nullable (JSON) |
Indexes: idx_auth_audit_timestamp(timestamp), idx_auth_audit_ip(ip_address).
auth_attempts¶
Rate-limit bookkeeping for login/MFA/etc. attempts, queried by
functions/utils/authAttempts.js.
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
user_id |
INTEGER | Nullable, FK → users(id) ON DELETE SET NULL |
email |
TEXT | Nullable |
ip_address |
TEXT | Nullable (no NOT NULL) |
user_agent |
TEXT | Nullable |
attempt_type |
TEXT | NOT NULL |
success |
INTEGER | NOT NULL (0/1) |
failure_reason |
TEXT | Nullable |
created_at |
TEXT | NOT NULL, DEFAULT datetime('now') |
Indexes: idx_auth_attempts_user(user_id), idx_auth_attempts_email(email),
idx_auth_attempts_ip(ip_address), idx_auth_attempts_created(created_at).
rate_limits¶
Globally-consistent fixed-window counters (D1-backed), superseding the
per-IP-only rate_limit table above. Added in migration 0028.
| Column | Type | Notes |
|---|---|---|
key |
TEXT | PK — composite rate-limit key (e.g. scope + identifier) |
count |
INTEGER | NOT NULL, DEFAULT 0 |
window_start |
INTEGER | NOT NULL — Unix timestamp |
updated_at |
INTEGER | NOT NULL — Unix timestamp |
Index: idx_rate_limits_updated_at(updated_at).
Metrics¶
artist_daily_stats¶
Privacy-first, per-band-per-day aggregate (no raw event log). Added in
migration 0023; profile_clicks/share_count columns were dropped in
migration 0048 as dead weight (never written, so their weight in the
popularity formula was silently inert).
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
band_profile_id |
INTEGER | NOT NULL, FK → band_profiles(id) ON DELETE CASCADE |
date |
TEXT | NOT NULL, YYYY-MM-DD |
page_views |
INTEGER | DEFAULT 0 |
social_clicks |
INTEGER | DEFAULT 0 |
created_at |
TEXT | DEFAULT datetime('now') |
Constraint: UNIQUE(band_profile_id, date). Indexes:
idx_artist_stats_date(date), idx_artist_stats_band(band_profile_id).
page_views_daily¶
Per-page, per-day aggregate view counter. Added in migration 0039.
| Column | Type | Notes |
|---|---|---|
id |
INTEGER | PK, autoincrement |
page |
TEXT | NOT NULL |
date |
TEXT | NOT NULL, YYYY-MM-DD |
views |
INTEGER | DEFAULT 0 |
Constraint: UNIQUE(page, date). Index: idx_page_views_date(date).
Relationships¶
Foreign keys as declared in database/setup-complete.sql (only columns with
an actual REFERENCES clause are FKs — several *_id columns above are
plain denormalized integers with no constraint, called out individually):
| Child | Column | → Parent | On delete |
|---|---|---|---|
events |
created_by_user_id |
users(id) |
(none) |
events |
updated_by_user_id |
users(id) |
(none) |
venues |
created_by_user_id |
users(id) |
(none) |
venues |
updated_by_user_id |
users(id) |
(none) |
band_profiles |
created_by_user_id |
users(id) |
(none) |
performances |
event_id |
events(id) |
CASCADE |
performances |
band_profile_id |
band_profiles(id) |
RESTRICT |
performances |
venue_id |
venues(id) |
SET NULL |
performances |
created_by_user_id |
users(id) |
(none) |
performances |
updated_by_user_id |
users(id) |
(none) |
artist_daily_stats |
band_profile_id |
band_profiles(id) |
CASCADE |
users |
deactivated_by |
users(id) (self) |
(none) |
sessions |
user_id |
users(id) |
CASCADE |
lucia_sessions |
user_id |
users(id) |
CASCADE |
mfa_challenges |
user_id |
users(id) |
CASCADE |
invite_codes |
created_by_user_id |
users(id) |
SET NULL |
invite_codes |
used_by_user_id |
users(id) |
SET NULL |
webauthn_credentials |
user_id |
users(id) |
CASCADE |
email_otp_codes |
user_id |
users(id) |
CASCADE |
password_reset_tokens |
user_id |
users(id) |
CASCADE |
password_reset_tokens |
created_by |
users(id) |
(none) |
trusted_devices |
user_id |
users(id) |
CASCADE |
audit_log |
user_id |
users(id) |
SET NULL |
auth_attempts |
user_id |
users(id) |
SET NULL |
band_follows |
band_profile_id |
band_profiles(id) |
CASCADE |
band_follow_notifications |
performance_id |
performances(id) |
CASCADE |
band_follow_notifications |
band_follow_id |
band_follows(id) |
CASCADE |
band_announce_queue |
band_follow_id |
band_follows(id) |
CASCADE |
band_announce_queue |
performance_id |
performances(id) |
CASCADE |
share_links |
event_id |
events(id) |
CASCADE |
subscription_verifications |
subscription_id |
email_subscriptions(id) |
CASCADE |
subscription_unsubscribes |
subscription_id |
email_subscriptions(id) |
CASCADE |
schedule_builds |
event_id |
events(id) |
CASCADE |
schedule_builds |
performance_id |
performances(id) |
CASCADE |
The old "venues → bands (1:N)" world from earlier docs is gone entirely: the
bands table was a hand-maintained-bootstrap-only relic (see "Legacy /
compatibility" above) that no migration ever created and production never
had — it was removed from database/setup-complete.sql in #506. Current
schedule data flows through events → performances ← band_profiles, with
venues → performances as an optional (nullable) third leg.
Common query patterns¶
Pulled directly from current endpoint code, not invented.
Public schedule for an event (functions/api/schedule.js) — the current
events → performances → band_profiles/venues join, filtered by
reveal_mode so unannounced performances stay hidden when the event has
reveal mode on:
SELECT
p.id as performance_id,
b.id as band_id,
b.name,
p.start_time as startTime,
p.end_time as endTime,
b.social_links,
b.photo_url,
v.name as venue,
v.latitude as venue_lat,
v.longitude as venue_lng
FROM performances p
INNER JOIN band_profiles b ON p.band_profile_id = b.id
LEFT JOIN venues v ON p.venue_id = v.id
WHERE p.event_id = ?
AND (? = 0 OR p.is_announced = 1)
ORDER BY p.start_time, v.name;
Share-link lookup (functions/api/schedule/share/[slug].js) — relies on
ON DELETE CASCADE from events so a deleted event's share links vanish with
it, and the expiry check is a plain string comparison because expires_at is
stored in the space-separated datetime('now') format:
SELECT sl.slug, sl.event_slug, sl.performance_ids, sl.band_names, e.name AS event_name
FROM share_links sl
JOIN events e ON e.id = sl.event_id AND (e.is_published = 1 OR e.status = 'archived')
WHERE sl.slug = ? AND sl.expires_at > datetime('now');
Atomic claim-before-send (functions/utils/bandFollowNotify.js) — how
band_follow_notifications prevents double-sending announcement emails under
concurrent resend requests:
INSERT OR IGNORE INTO band_follow_notifications (performance_id, band_follow_id)
VALUES (?, ?);
-- changes === 0 means another request already claimed this follower.
-- If the subsequent email send fails, the claim is released:
DELETE FROM band_follow_notifications WHERE performance_id = ? AND band_follow_id = ?;
Compensating delete on a non-batchable mutation
(functions/api/admin/events/[id]/duplicate.js) — D1 has no transactions, so
duplicating an event's performances into a copy is compensated manually if the
copy fails:
INSERT INTO performances (event_id, venue_id, band_profile_id, start_time, end_time, notes)
SELECT ?, venue_id, band_profile_id, start_time, end_time, notes
FROM performances
WHERE event_id = ?;
-- on failure:
DELETE FROM events WHERE id = ?;
Event metrics batch (functions/api/admin/events/[id]/metrics.js) — reads
schedule_builds (documented under "Subscriptions & schedule builds" above)
alongside performances and band_profiles in one DB.batch():
SELECT COUNT(*) as total_schedule_builds,
COUNT(DISTINCT user_session) as unique_visitors,
MAX(created_at) as last_updated
FROM schedule_builds WHERE event_id = ?;
Schema drift protection¶
Historical drift between database/setup-complete.sql and migrations/
(whole tables, columns, foreign keys, indexes, and triggers — see
issue #506)
was eliminated by regenerating the bootstrap from the migrations replay.
Two scripts keep it that way:
node scripts/regenerate-setup-complete.mjs— rewrites the bootstrap's schema section frommigrations/(run after adding any migration).node scripts/check-schema-drift.mjs— structurally compares the two and fails on any divergence; runs in CI (quality.yml, Schema Drift Check).
Further reading¶
migrations/ holds the authoritative, ordered history of every schema
change described above — consult it directly for the exact SQL of any
migration referenced by number in this document.