+++ title = "Create infra/schema.sql — idempotent D1 schema for quotes and quote_tags" priority = 7 status = "done" ticket_type = "task" dependencies = ["d0da0b", "5c0c64"] +++ TRIAGE 5c0c64 resolved: the chosen D1 migration strategy is a **separate wrangler step**. Production schema is applied once after `tofu apply` using: ```sh wrangler d1 execute quotesdb --file infra/schema.sql --remote ``` For local dev, the native `main()` calls `repo.run_migrations()` (rusqlite `execute_batch`). For tests, test setup calls `NativeRepository::run_migrations()` directly. The D1 `run_migrations()` method in `D1Repository` (wasm32 path, ticket 00aff0) may still call `CREATE TABLE IF NOT EXISTS` defensively on startup — but the canonical provisioning path for production is the wrangler CLI command above, not a startup handler. `infra/schema.sql` is the single source of truth for the SQL that wrangler applies. The Rust constants in `db/migrations.rs` (ticket 00aff0) contain the same SQL in split form suitable for the D1 `prepare().run()` API and rusqlite `execute_batch`. Create `infra/schema.sql` — a self-contained, idempotent SQL file that provisions the full `quotesdb` schema on a blank D1 database in a single wrangler command. ## 1. Create `infra/schema.sql` ```sql -- quotesdb D1 schema -- ============================================================================= -- Apply to production D1: -- wrangler d1 execute quotesdb --file infra/schema.sql --remote -- -- Apply locally (wrangler dev): -- wrangler d1 execute quotesdb --local --file infra/schema.sql -- -- For native dev/test builds, NativeRepository::run_migrations() applies -- equivalent SQL automatically via rusqlite on startup. -- ============================================================================= -- Stores individual quotes. auth_code is the 4-word passphrase for edit/delete. CREATE TABLE IF NOT EXISTS quotes ( id TEXT PRIMARY KEY, -- NanoID (~21 chars) text TEXT NOT NULL, author TEXT NOT NULL, source TEXT, -- optional: book, speech, etc. date TEXT, -- optional: ISO date YYYY-MM-DD auth_code TEXT NOT NULL, -- 4-word passphrase, stored plaintext created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- Join table linking quotes to zero or more tags. Cascades on quote deletion. CREATE TABLE IF NOT EXISTS quote_tags ( quote_id TEXT NOT NULL REFERENCES quotes(id) ON DELETE CASCADE, tag TEXT NOT NULL, PRIMARY KEY (quote_id, tag) ); ``` ## 2. Incremental migration convention (document in infra/README.md) For future schema changes, create numbered migration files: ``` infra/migrations/ 001_initial.sql -- (retroactive, same content as schema.sql) 002_add_index.sql -- future: e.g. CREATE INDEX IF NOT EXISTS ... ``` Apply individually: ```sh wrangler d1 execute quotesdb --file infra/migrations/002_add_index.sql --remote ``` No automated migration tracking is needed at this project's scale. ## 3. Full deployment workflow to document in `infra/README.md` ```sh # Step 1 — provision infrastructure (creates Worker, D1 database, Pages project) cd infra/ tofu apply # Step 2 — apply initial schema to D1 (run once after first apply) cd .. wrangler d1 execute quotesdb --file infra/schema.sql --remote # Re-running step 2 is safe (CREATE TABLE IF NOT EXISTS). ``` ## 4. Local dev workflow ```sh # Start local API (applies schema automatically via NativeRepository::run_migrations()) cargo run # or with a specific DB file: DATABASE_URL=./dev.sqlite cargo run ``` No manual wrangler step needed for local dev. **null_resource local-exec (rejected):** Provisioners are an OpenTofu anti-pattern. They don't re-run unless the resource is tainted, aren't tracked in state, are OS-dependent (requires wrangler installed on the CI runner at apply time), and hard to test. Breaking `tofu apply` idempotency is not worth the single-command convenience. **API startup migration for D1 (rejected):** Cloudflare Workers spin up per-request via V8 isolates. Calling DDL (`CREATE TABLE IF NOT EXISTS`) on every request is wasteful and fragile. The native `main()` calls `run_migrations()` at startup because it runs as a real server, but the Workers handler does NOT. The D1 provisioning path must be a separate step. - `infra/schema.sql` must use `CREATE TABLE IF NOT EXISTS` for idempotency — safe to re-run. - Schema must exactly match the design doc: NanoID PK, `auth_code` plaintext, optional `source` and `date`, CASCADE delete on `quote_tags`. - Do NOT run `wrangler d1 execute` inside OpenTofu (no `null_resource`). - `db/migrations.rs` (ticket 00aff0) contains equivalent SQL as Rust constants — keep in sync with `infra/schema.sql` manually when schema changes. This ticket has no Rust compilation artifact. Validate that the SQL is correct: ```sh # Smoke-test the schema against a local SQLite file sqlite3 /tmp/test_quotesdb.sqlite < infra/schema.sql sqlite3 /tmp/test_quotesdb.sqlite ".tables" # Should output: quote_tags quotes # Clean up rm /tmp/test_quotesdb.sqlite ``` - Resolves dependency: TRIAGE 5c0c64 (D1 migrations strategy — wrangler step chosen) - Resolves dependency: TRIAGE 580e66 (DB migration strategy for Workers — same decision) - Blocked by: d0da0b (D1 resource — need database name confirmed as "quotesdb") - Informs: 75489a (migration workflow docs — documents the wrangler command from this ticket) - Informs: 00aff0 (DB abstraction — migrations.rs constants mirror this file's SQL) - Sub-project: quotesdb/infra `feat(quotesdb): add infra/schema.sql with idempotent D1 schema for quotes and quote_tags`