+++
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`