Claude Skill

sqlite-ops

SQLite across every host and engine - query performance, concurrency, schema, feature modules, operations. Triggers on: sqlite, slow query, EXPLAIN QUERY PLAN, query plan, SCAN vs SEARCH, covering index, index not used, rows read, rows_read, sql_duration_ms, ANALYZE, sqlite_stat1

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download 0xdarkmatter-claude-mods-skills_sqlite-ops-3dfaf0b.zip · 88 KB
Part of 0xdarkmatter/claude-mods — 94 skills

Install

skills CLI npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/sqlite-ops
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
Git git clone https://github.com/0xDarkMatter/claude-mods.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

SQLite Operations

SQLite is one engine with many hosts. The SQL semantics, query planner, and pragmas are the same whether you reach it through the sqlite3 CLI, Python, node:sqlite, better-sqlite3, Bun, Cloudflare D1, or libSQL/Turso — what differs is the driver surface and the operational envelope (who owns the file, what a "connection" costs, whether you can even run PRAGMA). Reason about the engine first; then check the host section for the traps that differ.

Where does the problem live?
│
├─ A statement is slow, or scans too much
│  └─ EXPLAIN QUERY PLAN first, always → references/query-performance.md
│
├─ "database is locked" / SQLITE_BUSY / writers blocking readers
│  └─ WAL + busy_timeout + BEGIN IMMEDIATE → references/concurrency-durability.md
│
├─ Wrong data got in, or a constraint didn't fire
│  └─ Type affinity, STRICT, foreign_keys=OFF → references/schema-design.md
│
├─ Search / JSON / geo / analytics feature question
│  └─ FTS5, JSON, R-tree, window fns → references/feature-modules.md
│
├─ Running on a managed/edge engine (D1, Turso)
│  └─ references/d1-edge.md + references/hosts.md
│
└─ Corruption, size, backup, VACUUM
   └─ references/operations.md

Measurement discipline (read this before optimising anything)

Most SQLite "optimisations" are unmeasured. Four rules, in order of how often they are broken:

  1. Measure the statement, not the tool call. An expensive aggregate that ships inside a batch another query was already sending costs no extra round trip and is therefore invisible to per-call timing — while still scanning the whole table on every request. Decompose multi-part statements and time each part separately.
  2. Report latency AND rows scanned. They move independently. An optimisation can cut latency ~25x while leaving rows-read essentially unchanged (and on a billed engine like D1, rows read is the money metric — see references/d1-edge.md).
  3. Never trust wall-clock time from a CLI. Process startup dominates. Use the engine's own reported duration (.timer on in the CLI, meta.timings.sql_duration_ms on D1).
  4. Take a median of 10+ runs and report the range. First runs are cold. In one measured session a cold run hit 2,495 ms against a 171 ms median on the same statement — a 1.5–1.7x first-run penalty was routine on multi-thousand-row reads.
# sqlite3 CLI: engine-reported timing, not shell time
sqlite3 app.db '.timer on' "SELECT count(*) FROM q_product WHERE org LIKE '%acme%';"

# What the planner thinks the data looks like (empty = ANALYZE never ran)
sqlite3 app.db 'SELECT * FROM sqlite_stat1;'

Prove an index will help before you create it

The highest-leverage trick in this skill, and the one that keeps schema work inside a deploy gate: run the identical statement shape against a column an existing index already covers. Same table, same row count, same predicate shape — only the column changes. The difference is your projected payoff, measured on live production data with zero schema writes.

-- Hypothesis: a covering index on (org, product_id) makes this fast.
-- Unindexed control (what you have today):
SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%acme%';

-- Proof shot: same shape, over a column an existing index already covers.
-- If this is fast, the index is worth writing. If it isn't, the index is not your problem.
SELECT DISTINCT org FROM q_product WHERE org LIKE '%acme%';

In the worked example below the proof shot returned 6.75 ms against a 171.83 ms control — enough to justify the index without touching production schema.

EXPLAIN QUERY PLAN — the 60-second read

EXPLAIN QUERY PLAN (EQP) is the first command for any slow statement. It is cheap, safe, read-only, and available on every host that lets you run arbitrary SQL.

EXPLAIN QUERY PLAN
SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%acme%';
Plan line Means Verdict
SEARCH t USING INDEX ix (col=?) B-tree seek, touches matching rows only Best case
SEARCH t USING COVERING INDEX ix Seek, and every needed column is in the index — table never read Best case
SCAN t USING COVERING INDEX ix Full pass, but over narrow index entries, not wide rows Often fine — see below
SCAN t USING INDEX ix Full pass over the index and a row fetch per hit Suspicious: the index is buying little
SCAN t Full table scan Fix it, unless the table is tiny
USE TEMP B-TREE FOR ORDER BY Sorting because no index supplies the order Cost signal
USE TEMP B-TREE FOR GROUP BY Same, for grouping Cost signal
CORRELATED SCALAR SUBQUERY Subquery re-executed per outer row Usually the whole problem

The distinction that matters most: SCAN … USING COVERING INDEX is not a failure. A covering scan reads narrow index entries instead of paging in wide rows, which is exactly how you make an unseekable predicate fast.

Deep dive: ./references/query-performance.md — index design, column order, partial and expression indexes, ANALYZE/sqlite_stat1, and the full catalogue of planner defeats.

The unseekable-predicate trap (worked example)

A leading-wildcard LIKE '%x%' can never use a B-tree — SQLite optimises LIKE only for an anchored prefix ('x%'). So a plain index on that column changes nothing, people observe no improvement, and conclude "indexing didn't help here". The index wasn't wrong; the shape was. The fix is to make the scan covering, so the unavoidable full pass reads narrow index entries instead of wide rows.

-- Column order is load-bearing: FILTERED column first, PROJECTED column second.
CREATE INDEX q_product_org_product ON q_product(org, product_id);

Worked example — one database, not a constant. Measured 2026-08-04 against a live Cloudflare D1 (atdw-mirror, region OC, colo SYD), 12 runs each, median of server-side sql_duration_ms; 73-column table, 58k rows. Before: SCAN q_product USING INDEX q_product_org, 171.83 ms, 60,736 rows read. The identical statement shape over an already-covered column: 6.75 ms, 58,433 rows read. ~25x faster with rows-read essentially unchanged — proof that the win came from row width, not from touching fewer rows. Your table's numbers will differ; the shape of the result is what transfers.

Two further findings from the same session worth internalising:

  • Once the covering index existed, SQLite dropped the GROUP BY temp B-tree by itself. A hand-rewrite to avoid the grouping measured 5.99 ms vs 5.85 ms — noise. Don't hand-optimise around a temp B-tree until you have re-read the plan post-index.
  • An unindexed MAX() riding inside a batch another query was already sending cost 28.09 ms and 58,432 rows scanned on every response across four tools, while the statement without it cost 0.17 ms / 2 rows. The same MAX() over an indexed column: 0.17 ms / 1 row. It never showed up in per-query timing because it added no round trip.

Verify the planner's choice with and without statistics

A covering index may only be chosen once ANALYZE has populated sqlite_stat1 — and many hosted engines never run ANALYZE for you. Test both states before you rely on it:

ANALYZE;                                  -- populate sqlite_stat1
EXPLAIN QUERY PLAN SELECT ...;            -- record the plan

DELETE FROM sqlite_stat1;                 -- simulate a never-analyzed database
ANALYZE sqlite_master;                    -- force the planner to reload (now-empty) stats
EXPLAIN QUERY PLAN SELECT ...;            -- same plan? then you are safe either way

In the worked example the covering index was chosen in both states — verified, not assumed. Do the same check rather than inheriting that result.

Index design in one table

Predicate shape Indexable? What to build
col = ?, col IN (…), col > ?, BETWEEN Yes B-tree on col
a = ? AND b = ? Yes Composite (a, b) — equality columns first
a = ? ORDER BY b Yes Composite (a, b) — kills the temp B-tree
col LIKE 'x%' (anchored) Yes, if col is TEXT with BINARY collation B-tree on col
col LIKE '%x%' (leading wildcard) No seek possible Make the scan covering, or use FTS5 trigram
lower(col) = ? Not on a plain index Expression index ON t(lower(col))
status = 'open' where 2% of rows qualify Yes Partial index WHERE status = 'open'
json_extract(doc,'$.k') = ? Not on a plain index Expression index, or generated column + index

Rules that repay themselves: put the filtered column first and the projected column second in a covering index; index the column, never a function of it (unless it is an expression index); and every index you add taxes every write — audit before adding.

Concurrency and durability — the 80/20

Symptom Cause Fix
SQLITE_BUSY Another connection holds a lock; yours gave up waiting PRAGMA busy_timeout = 5000; and keep write transactions short
SQLITE_LOCKED Conflict within the same connection (or a shared cache) Fix the code — a retry loop will spin forever
"database is locked" mid-transaction BEGIN (DEFERRED) read that later writes → upgrade deadlock, not retryable BEGIN IMMEDIATE for any transaction that will write
Readers blocked by a writer Rollback journal mode PRAGMA journal_mode = WAL; (persistent, set once)
-wal file grows without bound Long-lived reader pins the checkpoint Close/refresh readers; PRAGMA wal_checkpoint(TRUNCATE);
PRAGMA journal_mode = WAL;      -- persistent; survives reconnect
PRAGMA busy_timeout = 5000;     -- per-connection; set on EVERY connection
PRAGMA foreign_keys = ON;       -- per-connection, OFF by default — see below
PRAGMA synchronous = NORMAL;    -- safe with WAL; FULL only if you fear power loss

Deep dive: ./references/concurrency-durability.md — WAL internals, the DEFERRED-upgrade deadlock, synchronous levels, checkpoint starvation, multi-process access.

Schema — the three silent bugs

  1. PRAGMA foreign_keys is OFF by default. Per connection, every connection. Your REFERENCES clauses parse, are stored, and do nothing. This is the classic silent data-integrity bug in SQLite applications.
  2. Type affinity is not a type. A TEXT column will happily store an integer; a declared type is a suggestion about conversion. Use STRICT tables (SQLite 3.37+) when you want a declared type enforced.
  3. ALTER TABLE is limited. Adding a column and renaming are supported; dropping, retyping, and changing constraints need the 12-step recreate dance.
CREATE TABLE product (
    id       INTEGER PRIMARY KEY,
    org      TEXT NOT NULL,
    price    REAL NOT NULL,
    doc      TEXT,
    -- indexable projection of a JSON field
    sku      TEXT GENERATED ALWAYS AS (json_extract(doc, '$.sku')) VIRTUAL
) STRICT;

Deep dive: ./references/schema-design.md (affinity, STRICT, generated columns, WITHOUT ROWID, constraints) and ./references/migration-patterns.md (the 12-step ALTER dance, versioned migration runners).

Feature modules at a glance

Need Reach for Note
Substring / fuzzy text search FTS5 with the trigram tokenizer The real answer to LIKE '%x%' at scale
Word/phrase search with ranking FTS5 + bm25() External-content table avoids duplicating the corpus
Semi-structured documents json_extract / -> / ->>, JSONB (3.45+) Index via generated column or expression index
Bounding-box / interval overlap R-tree virtual table Compile-time module; check availability
Running totals, ranking, gaps Window functions (3.25+) Same syntax as PostgreSQL
Insert-or-update ON CONFLICT … DO UPDATE (3.24+) excluded.col refers to the proposed row
Read back what you wrote RETURNING (3.35+) Makes atomic claim-a-job patterns single-statement

Deep dive: ./references/feature-modules.md.

Hosts

The engine is the same; the envelope is not.

Host Connection model Watch out for
sqlite3 CLI Direct file .timer on for real timings; .mode/.headers for output
Python sqlite3 Direct file, per-connection pragmas Implicit transaction handling; check_same_thread
Python aiosqlite Thread-backed async wrapper Still one writer; see ./references/async-patterns.md
node:sqlite Synchronous, built into Node No external dependency; API still stabilising
better-sqlite3 Synchronous, native addon Fastest Node option; prepared statements are the unit of reuse
bun:sqlite Synchronous, built into Bun API close to better-sqlite3, not identical
Cloudflare D1 HTTP/RPC to a managed SQLite Billed on rows read; 100-parameter cap; no PRAGMA surface
libSQL / Turso Server or embedded replica Replica staleness; syntax extensions beyond stock SQLite

Deep dive: ./references/hosts.md for per-host connection recipes and traps.

On D1 specifically, three platform features have no stock-SQLite equivalent and are the most commonly missed:

wrangler d1 insights <db> --sort-type=sum --sort-by=reads --limit=10   # rank REAL queries by cost
wrangler d1 time-travel info <db>                                      # 30-day point-in-time restore point
# Sessions API (env.DB.withSession(bookmark)) - read replicas, sequential consistency

./references/d1-edge.md covers those plus the rows-read economics, the verified limits table, the error catalogue, and import/export. For the production incident patterns — a timed-out migrations apply --remote that landed anyway, batch() treating a 0-row scoped UPDATE as success, and the opt-in-to-replica rollout shape for read replication — see ./references/d1-production-patterns.md.

Operations

sqlite3 app.db 'PRAGMA quick_check;'        # fast structural check
sqlite3 app.db 'PRAGMA integrity_check;'    # full check — slow on big DBs
sqlite3 app.db "VACUUM INTO 'backup.db';"   # consistent backup, no downtime, defragmented
sqlite3 app.db '.dump' > backup.sql         # portable text backup
sqlite3 app.db 'PRAGMA optimize;'           # run before closing a long-lived connection

Never copy a live database file with cp while a writer is active — use VACUUM INTO, the backup API, or .dump.

Deep dive: ./references/operations.md — corruption causes and recovery, VACUUM vs VACUUM INTO, page/cache sizing, size analysis.

Triage script

scripts/eqp-triage.py reads an EXPLAIN QUERY PLAN result — either by running the statement against a database, or from piped plan text — and classifies each line by severity with a fix hint. Exits 10 when it finds something (the domain signal), 0 when the plan is clean.

# Run against a database file (uses Python's bundled sqlite3 — no external binary needed)
python3 scripts/eqp-triage.py --db app.db \
  --sql "SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%acme%'"

# Triage a plan captured elsewhere (D1, a log, a colleague's paste)
wrangler d1 execute atdw-mirror --remote --json \
  --command "EXPLAIN QUERY PLAN SELECT product_id FROM q_product WHERE org LIKE '%acme%'" \
  | python3 scripts/eqp-triage.py

# Machine-readable findings
python3 scripts/eqp-triage.py --db app.db --sql "SELECT ..." --json | jq '.data[]'

Gotchas

Mistake Why it bites Fix
Adding an index for LIKE '%x%' Leading wildcard can never seek Covering index, or FTS5 trigram
Timing with a shell stopwatch CLI/driver startup dominates Engine-reported duration; median of 10+
Timing the tool call, not the statement Piggy-backed statements are invisible Decompose and time each part
Assuming REFERENCES is enforced foreign_keys is OFF per connection PRAGMA foreign_keys = ON on every connection
Assuming a declared type is enforced Affinity, not typing STRICT tables
Retrying SQLITE_LOCKED Same-connection conflict never clears Fix the code path
BEGIN then write DEFERRED→write upgrade deadlocks and is not retryable BEGIN IMMEDIATE
cp on a live database Torn copy VACUUM INTO / backup API
SELECT * Defeats covering indexes; widens every row read Project only what you need
VACUUM to "speed things up" Rewrites the whole file, needs 2x space, holds a lock PRAGMA optimize / targeted index work
Trusting one cold run 1.5–1.7x first-run penalty is routine Median of 10+, report the range
Inlining literals to dodge a parameter cap That is how injection happens Chunk the work; keep bound parameters
Re-running a timed-out remote migration The apply may have landed; the error was about the response Verify schema state read-only first — ./references/d1-production-patterns.md
Reading a committed batch() as per-statement success A conditional UPDATE matching 0 rows is not an error Check meta.changes; 0 on a scoped write = 403/conflict

Reference files

Reference Load when
./references/query-performance.md Any slow statement: EQP, index design, ANALYZE, planner defeats, measurement method
./references/d1-edge.md Cloudflare D1: rows-read economics, d1 insights, Sessions API/replication, Time Travel, limits, errors
./references/d1-production-patterns.md Running D1 in production: verifying a timed-out migration, batch() 0-row write verification, the opt-in-to-replica replication rollout
./references/concurrency-durability.md Locking, WAL, busy_timeout, transaction modes, checkpointing, durability
./references/schema-design.md Affinity, STRICT, foreign keys, generated columns, WITHOUT ROWID, constraints
./references/schema-patterns.md Ready-made table designs: state, cache, event log, queue, session, dedup
./references/migration-patterns.md Versioned migrations, the 12-step ALTER dance, host-specific runners
./references/feature-modules.md FTS5, JSON/JSONB, R-tree, window functions, upsert, RETURNING
./references/hosts.md Per-host connection recipes and driver traps (Python, Node, Bun, D1, libSQL)
./references/async-patterns.md Python aiosqlite depth: async CRUD, batching, pooling
./references/operations.md Integrity checks, corruption recovery, VACUUM, backups, size and page tuning
./references/testing.md In-memory vs file databases, fixtures, deterministic seeding, migration tests

See also

Skill When to combine
sql-ops Vendor-neutral SQL: CTEs, window functions, JOIN strategy
perf-ops The wider performance workflow — profiling, load testing, before/after protocol
cloudflare-ops Workers, bindings, and deployment around a D1 database
postgres-ops When the workload has outgrown SQLite's single-writer model
python-database-ops SQLAlchemy / ORM layers over SQLite
Files (claude-mods)
  • assets
    • .gitkeep 0 B · in bundle
  • references
    • async-patterns.md 8.4 KB
      # SQLite Async Patterns
      
      Python aiosqlite patterns for async applications.
      
      ## Async Connection
      
      ```python
      import aiosqlite
      
      async def get_async_connection(db_path: str) -> aiosqlite.Connection:
          """Create async connection with best practices."""
          conn = await aiosqlite.connect(db_path)
          conn.row_factory = aiosqlite.Row
          await conn.execute("PRAGMA journal_mode=WAL")
          await conn.execute("PRAGMA foreign_keys=ON")
          return conn
      ```
      
      ## Context Manager Pattern
      
      ```python
      async def query_items(db_path: str, status: str) -> list[dict]:
          """Query with automatic connection cleanup."""
          async with aiosqlite.connect(db_path) as db:
              db.row_factory = aiosqlite.Row
              async with db.execute(
                  "SELECT * FROM items WHERE status = ?", (status,)
              ) as cursor:
                  rows = await cursor.fetchall()
                  return [dict(row) for row in rows]
      ```
      
      ## Async CRUD Operations
      
      ### Create
      
      ```python
      async def create_item(db_path: str, name: str, data: dict) -> int:
          """Insert and return new ID."""
          async with aiosqlite.connect(db_path) as db:
              cursor = await db.execute(
                  "INSERT INTO items (name, data) VALUES (?, ?)",
                  (name, json.dumps(data))
              )
              await db.commit()
              return cursor.lastrowid
      ```
      
      ### Read
      
      ```python
      async def get_item(db_path: str, item_id: int) -> dict | None:
          """Get single item by ID."""
          async with aiosqlite.connect(db_path) as db:
              db.row_factory = aiosqlite.Row
              async with db.execute(
                  "SELECT * FROM items WHERE id = ?", (item_id,)
              ) as cursor:
                  row = await cursor.fetchone()
                  return dict(row) if row else None
      ```
      
      ### Update
      
      ```python
      async def update_item(db_path: str, item_id: int, **updates) -> bool:
          """Update item fields."""
          if not updates:
              return False
      
          set_clause = ", ".join(f"{k} = ?" for k in updates.keys())
          values = list(updates.values()) + [item_id]
      
          async with aiosqlite.connect(db_path) as db:
              cursor = await db.execute(
                  f"UPDATE items SET {set_clause} WHERE id = ?",
                  values
              )
              await db.commit()
              return cursor.rowcount > 0
      ```
      
      ### Delete
      
      ```python
      async def delete_item(db_path: str, item_id: int) -> bool:
          """Delete item by ID."""
          async with aiosqlite.connect(db_path) as db:
              cursor = await db.execute(
                  "DELETE FROM items WHERE id = ?", (item_id,)
              )
              await db.commit()
              return cursor.rowcount > 0
      ```
      
      ## Batch Operations
      
      ### Batch Insert
      
      ```python
      async def batch_insert(db_path: str, items: list[dict]) -> int:
          """Insert multiple items efficiently."""
          async with aiosqlite.connect(db_path) as db:
              await db.executemany(
                  "INSERT INTO items (name, data) VALUES (?, ?)",
                  [(i["name"], json.dumps(i.get("data", {}))) for i in items]
              )
              await db.commit()
              return len(items)
      ```
      
      ### Batch Update
      
      ```python
      async def batch_update_status(db_path: str, ids: list[int], status: str) -> int:
          """Update status for multiple items."""
          async with aiosqlite.connect(db_path) as db:
              cursor = await db.executemany(
                  "UPDATE items SET status = ? WHERE id = ?",
                  [(status, id) for id in ids]
              )
              await db.commit()
              return len(ids)
      ```
      
      ### Batch with Transaction
      
      ```python
      async def batch_transfer(db_path: str, transfers: list[tuple[int, int, float]]) -> None:
          """Transfer amounts between accounts atomically."""
          async with aiosqlite.connect(db_path) as db:
              try:
                  for from_id, to_id, amount in transfers:
                      await db.execute(
                          "UPDATE accounts SET balance = balance - ? WHERE id = ?",
                          (amount, from_id)
                      )
                      await db.execute(
                          "UPDATE accounts SET balance = balance + ? WHERE id = ?",
                          (amount, to_id)
                      )
                  await db.commit()
              except Exception:
                  await db.rollback()
                  raise
      ```
      
      ## Connection Pool Pattern
      
      ```python
      from contextlib import asynccontextmanager
      import asyncio
      
      class AsyncDBPool:
          """Simple connection pool for aiosqlite."""
      
          def __init__(self, db_path: str, max_connections: int = 5):
              self.db_path = db_path
              self.max_connections = max_connections
              self._pool: asyncio.Queue[aiosqlite.Connection] = asyncio.Queue()
              self._created = 0
              self._lock = asyncio.Lock()
      
          async def _create_connection(self) -> aiosqlite.Connection:
              conn = await aiosqlite.connect(self.db_path)
              conn.row_factory = aiosqlite.Row
              await conn.execute("PRAGMA journal_mode=WAL")
              return conn
      
          @asynccontextmanager
          async def acquire(self):
              # Try to get from pool
              try:
                  conn = self._pool.get_nowait()
              except asyncio.QueueEmpty:
                  # Create new if under limit
                  async with self._lock:
                      if self._created < self.max_connections:
                          conn = await self._create_connection()
                          self._created += 1
                      else:
                          # Wait for one to be returned
                          conn = await self._pool.get()
      
              try:
                  yield conn
              finally:
                  # Return to pool
                  await self._pool.put(conn)
      
          async def close_all(self):
              while not self._pool.empty():
                  conn = await self._pool.get()
                  await conn.close()
              self._created = 0
      
      # Usage
      pool = AsyncDBPool("mydb.sqlite")
      
      async def get_user(user_id: int):
          async with pool.acquire() as db:
              async with db.execute(
                  "SELECT * FROM users WHERE id = ?", (user_id,)
              ) as cursor:
                  return await cursor.fetchone()
      ```
      
      ## Streaming Large Results
      
      ```python
      async def stream_items(db_path: str, batch_size: int = 1000):
          """Yield items in batches to avoid memory issues."""
          async with aiosqlite.connect(db_path) as db:
              db.row_factory = aiosqlite.Row
              async with db.execute("SELECT * FROM items ORDER BY id") as cursor:
                  while True:
                      rows = await cursor.fetchmany(batch_size)
                      if not rows:
                          break
                      for row in rows:
                          yield dict(row)
      ```
      
      ## Concurrent Queries
      
      ```python
      async def get_dashboard_data(db_path: str, user_id: int) -> dict:
          """Run multiple queries concurrently."""
          async with aiosqlite.connect(db_path) as db:
              db.row_factory = aiosqlite.Row
      
              # Execute queries concurrently
              user_task = db.execute("SELECT * FROM users WHERE id = ?", (user_id,))
              orders_task = db.execute(
                  "SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 10",
                  (user_id,)
              )
              stats_task = db.execute(
                  "SELECT COUNT(*) as count, SUM(total) as total FROM orders WHERE user_id = ?",
                  (user_id,)
              )
      
              # Await all
              user_cursor, orders_cursor, stats_cursor = await asyncio.gather(
                  user_task, orders_task, stats_task
              )
      
              return {
                  "user": dict(await user_cursor.fetchone()),
                  "recent_orders": [dict(r) for r in await orders_cursor.fetchall()],
                  "stats": dict(await stats_cursor.fetchone()),
              }
      ```
      
      ## Error Handling
      
      ```python
      import aiosqlite
      from sqlite3 import IntegrityError, OperationalError
      
      async def safe_insert(db_path: str, data: dict) -> tuple[bool, str]:
          """Insert with comprehensive error handling."""
          try:
              async with aiosqlite.connect(db_path) as db:
                  await db.execute(
                      "INSERT INTO items (name, value) VALUES (?, ?)",
                      (data["name"], data["value"])
                  )
                  await db.commit()
                  return True, "Success"
      
          except IntegrityError as e:
              if "UNIQUE constraint" in str(e):
                  return False, "Duplicate entry"
              elif "FOREIGN KEY constraint" in str(e):
                  return False, "Referenced record not found"
              return False, f"Integrity error: {e}"
      
          except OperationalError as e:
              if "database is locked" in str(e):
                  return False, "Database busy, try again"
              elif "no such table" in str(e):
                  return False, "Table not found"
              return False, f"Database error: {e}"
      
          except Exception as e:
              return False, f"Unexpected error: {e}"
      ```
      
    • concurrency-durability.md 13.2 KB
      # SQLite Concurrency and Durability
      
      Engine-agnostic. The locking model, journal modes, and transaction semantics below are
      properties of SQLite itself and behave identically in every host that gives you a real
      connection. Managed engines (D1) hide most of this — see [`d1-edge.md`](d1-edge.md).
      
      ## Contents
      
      - [The one-writer model](#the-one-writer-model)
      - [Journal modes: WAL vs rollback](#journal-modes-wal-vs-rollback)
      - [SQLITE_BUSY vs SQLITE_LOCKED](#sqlite_busy-vs-sqlite_locked)
      - [busy_timeout](#busy_timeout)
      - [Transaction modes and the upgrade deadlock](#transaction-modes-and-the-upgrade-deadlock)
      - [Durability: the synchronous pragma](#durability-the-synchronous-pragma)
      - [WAL checkpointing and file growth](#wal-checkpointing-and-file-growth)
      - [Connection pragma baseline](#connection-pragma-baseline)
      - [Multi-process and networked filesystems](#multi-process-and-networked-filesystems)
      - [Retry patterns](#retry-patterns)
      
      ---
      
      ## The one-writer model
      
      SQLite allows **many concurrent readers and exactly one writer** per database. There is no
      row-level locking and no MVCC beyond WAL's single-version snapshot. Almost every
      concurrency problem in SQLite is a consequence of that sentence.
      
      | Reality | Implication |
      |---|---|
      | One writer at a time, database-wide | Write transactions must be **short**; never hold one across network I/O or user think-time |
      | Readers don't block readers | Read concurrency scales freely |
      | In WAL, readers don't block the writer and the writer doesn't block readers | WAL is the default recommendation for anything concurrent |
      | Locks are per **connection**, not per thread or per statement | Two connections in the same process contend exactly like two processes |
      
      **Design consequence:** batch writes. A thousand single-statement transactions cost a
      thousand lock acquisitions and (depending on `synchronous`) a thousand fsyncs; the same
      thousand statements inside one transaction cost one of each.
      
      ```sql
      BEGIN IMMEDIATE;
        INSERT INTO events (kind, payload) VALUES (?, ?);
        -- ... 999 more
      COMMIT;
      ```
      
      ---
      
      ## Journal modes: WAL vs rollback
      
      ```sql
      PRAGMA journal_mode = WAL;      -- returns 'wal' on success; PERSISTENT (stored in the file)
      PRAGMA journal_mode;            -- read current mode
      ```
      
      | Mode | Readers during write | Crash safety | Notes |
      |---|---|---|---|
      | `DELETE` (default) | **Blocked** | Safe | Journal file created and deleted per transaction |
      | `TRUNCATE` | Blocked | Safe | Journal truncated rather than deleted — slightly faster |
      | `PERSIST` | Blocked | Safe | Journal header zeroed rather than deleted |
      | `WAL` | **Concurrent** | Safe | Recommended default for concurrent workloads |
      | `MEMORY` | Blocked | **Unsafe** — crash corrupts | Only for throwaway data |
      | `OFF` | Blocked | **Unsafe** — no rollback at all | Only for import scratch databases |
      
      **WAL is persistent**: set it once and it survives reconnects and restarts, because the mode
      is recorded in the database header. It does *not* need to be set on every connection —
      unlike `busy_timeout` and `foreign_keys`, which do.
      
      **WAL trade-offs to know before choosing it:**
      
      - Creates two extra files: `-wal` (the log) and `-shm` (shared memory index). Backups must
        account for them, or use `VACUUM INTO`.
      - Requires shared memory, so it **does not work on most network filesystems** (see below).
      - A single database can't be in WAL mode for some connections and rollback for others.
      - Readers see a consistent snapshot from the moment their transaction started.
      
      ---
      
      ## SQLITE_BUSY vs SQLITE_LOCKED
      
      These look alike and mean opposite things. Getting them confused produces retry loops that
      spin forever.
      
      | Error | Meaning | Retryable? |
      |---|---|---|
      | `SQLITE_BUSY` (5) | Another **connection** holds a conflicting lock and yours timed out waiting | **Yes** — back off and retry |
      | `SQLITE_LOCKED` (6) | Conflict **inside your own connection** (or a shared-cache sibling) — e.g. writing to a table you are mid-scan on | **No** — retrying cannot help; fix the code |
      | `SQLITE_BUSY_SNAPSHOT` | WAL: your read snapshot is too old to upgrade to a write | Yes, but restart the whole transaction |
      
      `SQLITE_LOCKED` most often means a cursor is still open on the table being modified. Read
      the rows out fully (materialise the list) before writing to the same table.
      
      ```python
      # SQLITE_LOCKED risk: writing while iterating the same table
      for row in conn.execute("SELECT id FROM job WHERE status='pending'"):
          conn.execute("UPDATE job SET status='running' WHERE id=?", (row[0],))   # risky
      
      # Safe: materialise first
      ids = [r[0] for r in conn.execute("SELECT id FROM job WHERE status='pending'").fetchall()]
      for i in ids:
          conn.execute("UPDATE job SET status='running' WHERE id=?", (i,))
      ```
      
      ---
      
      ## busy_timeout
      
      ```sql
      PRAGMA busy_timeout = 5000;   -- milliseconds; per CONNECTION, not persistent
      ```
      
      Without it, a lock conflict raises `SQLITE_BUSY` **immediately**. With it, SQLite sleeps and
      retries internally for up to the timeout before giving up. This single pragma removes the
      majority of "database is locked" reports.
      
      | Setting | Suitable for |
      |---|---|
      | 0 (default) | Nothing concurrent — you will see spurious BUSY |
      | 1,000–5,000 ms | Typical application default |
      | 30,000 ms | Batch/migration jobs where waiting beats failing |
      
      **It must be set on every connection**, including short-lived ones and those created by
      connection pools. It is not stored in the database file.
      
      Caveat: `busy_timeout` does **not** rescue the DEFERRED-upgrade deadlock below. That case
      is architecturally unresolvable by waiting, and SQLite returns `SQLITE_BUSY` instantly
      regardless of the timeout.
      
      ---
      
      ## Transaction modes and the upgrade deadlock
      
      ```sql
      BEGIN;             -- == BEGIN DEFERRED: no lock taken until the first statement
      BEGIN IMMEDIATE;   -- takes a write lock now
      BEGIN EXCLUSIVE;   -- takes an exclusive lock now (rarely needed in WAL)
      ```
      
      **The footgun:** `BEGIN DEFERRED` followed by a read and then a write must *upgrade* from a
      read lock to a write lock. If another connection wrote to the database between your read and
      your upgrade, SQLite cannot give you a consistent view and returns `SQLITE_BUSY`
      **immediately, ignoring `busy_timeout`** — because waiting could deadlock two connections
      each holding a read lock and each wanting to upgrade.
      
      ```sql
      -- Deadlock-prone: read, then write, inside a DEFERRED transaction
      BEGIN;
        SELECT balance FROM account WHERE id = 1;
        UPDATE account SET balance = balance - 10 WHERE id = 1;   -- may fail with BUSY, instantly
      COMMIT;
      
      -- Correct: declare the intent to write up front
      BEGIN IMMEDIATE;
        SELECT balance FROM account WHERE id = 1;
        UPDATE account SET balance = balance - 10 WHERE id = 1;
      COMMIT;
      ```
      
      **Rule: if a transaction will write at any point, open it with `BEGIN IMMEDIATE`.** The cost
      is serialising writers slightly earlier; the benefit is that `busy_timeout` now actually
      applies and the failure mode becomes a retryable wait instead of an instant error.
      
      Read-only transactions should stay `DEFERRED` — they take no write lock and never block
      anyone.
      
      ### Savepoints
      
      Nested, named transaction points — useful for partial rollback inside a long operation.
      
      ```sql
      BEGIN IMMEDIATE;
        SAVEPOINT step1;
          -- risky work
        ROLLBACK TO step1;    -- undo just this step, transaction still open
        RELEASE step1;
      COMMIT;
      ```
      
      ---
      
      ## Durability: the synchronous pragma
      
      ```sql
      PRAGMA synchronous = NORMAL;   -- per connection
      ```
      
      | Level | Meaning | Risk on power loss / OS crash |
      |---|---|---|
      | `OFF` (0) | Never fsync | **Database can be corrupted** |
      | `NORMAL` (1) | Fsync at checkpoints only (in WAL) | With WAL: recent commits may be lost, **file stays consistent** |
      | `FULL` (2) | Fsync every commit | No committed data lost |
      | `EXTRA` (3) | `FULL` plus the directory sync | Marginally stronger |
      
      **`NORMAL` with WAL is the standard production choice**: it is the large majority of the
      performance win with no corruption risk — only the possibility of losing the last few
      committed transactions if the machine loses power. Application crashes are safe at `NORMAL`;
      it is only OS-level or power failure that can lose committed data.
      
      Use `FULL` when a lost commit is unacceptable (financial ledgers, anything with an external
      side effect keyed to the write). Never use `OFF` on data you care about; it is for
      regenerable scratch databases only.
      
      ---
      
      ## WAL checkpointing and file growth
      
      The `-wal` file accumulates committed pages until a **checkpoint** moves them back into the
      main database. By default SQLite auto-checkpoints when the WAL passes ~1000 pages (~4 MB at
      the default page size).
      
      ```sql
      PRAGMA wal_autocheckpoint = 1000;        -- pages; 0 disables auto-checkpointing
      PRAGMA wal_checkpoint(PASSIVE);          -- checkpoint what it can, never blocks
      PRAGMA wal_checkpoint(FULL);             -- wait for readers, checkpoint everything
      PRAGMA wal_checkpoint(TRUNCATE);         -- FULL, then shrink the -wal file to zero
      ```
      
      **Why a `-wal` file grows without bound:** a checkpoint cannot advance past the oldest
      active reader's snapshot. One long-lived read transaction — an idle connection that opened a
      transaction and never committed, a paginated report held open, an ORM session left in
      transaction — pins the WAL forever.
      
      | Symptom | Diagnosis | Fix |
      |---|---|---|
      | `-wal` grows to GBs | Long-lived reader pinning the checkpoint | Find and close it; add a statement timeout; commit read transactions promptly |
      | Periodic latency spikes on writes | A large checkpoint blocking | Lower `wal_autocheckpoint`, or run `PASSIVE` checkpoints from a background task |
      | `-wal` persists after clean shutdown | Last connection didn't close cleanly | It is recovered automatically on next open; harmless |
      
      Deleting `-wal` or `-shm` by hand while a connection is open risks corruption. Close all
      connections first — SQLite removes them on the last clean close.
      
      ---
      
      ## Connection pragma baseline
      
      Persistence differs per pragma, and it's the most common source of "I set that, why isn't it
      on":
      
      | Pragma | Scope | Set where |
      |---|---|---|
      | `journal_mode = WAL` | **Database file** — persistent | Once, at setup/migration |
      | `busy_timeout` | Connection | **Every connection** |
      | `foreign_keys` | Connection | **Every connection** |
      | `synchronous` | Connection | **Every connection** |
      | `cache_size` | Connection | Every connection |
      | `page_size` | Database file — only settable before first write or via `VACUUM` | Setup only |
      | `auto_vacuum` | Database file — set before first table, or `VACUUM` after change | Setup only |
      
      A correct connection factory sets the connection-scoped ones every time:
      
      ```sql
      PRAGMA busy_timeout = 5000;
      PRAGMA foreign_keys = ON;
      PRAGMA synchronous = NORMAL;
      PRAGMA cache_size = -64000;   -- negative = KiB, so this is 64 MB
      ```
      
      See [`hosts.md`](hosts.md) for this baseline written out per host.
      
      ---
      
      ## Multi-process and networked filesystems
      
      | Environment | Verdict |
      |---|---|
      | Multiple processes, same local disk | Fine — this is SQLite's design point. Use WAL + `busy_timeout` |
      | Threads sharing one connection | Only with correct serialisation; prefer one connection per thread |
      | NFS / SMB / CIFS | **Do not.** Advisory locking is unreliable; corruption is a documented outcome |
      | Docker volume on a local filesystem | Fine |
      | Docker volume over a network mount | Same problem as NFS |
      | WSL accessing a Windows drive (`/mnt/c`) | Locking is unreliable across the boundary — keep the database on the native filesystem |
      | Cloud object storage (S3 et al.) | Not a filesystem; use a purpose-built layer (libSQL, Litestream-style replication) |
      
      If you need SQLite semantics over a network, put a **server** in front of it (libSQL/Turso,
      rqlite, or your own service) rather than sharing the file. See [`d1-edge.md`](d1-edge.md).
      
      **Litestream-style continuous replication** is the standard answer for durability of a
      single-node SQLite database: it streams WAL frames to object storage without changing how
      the application talks to the database.
      
      ---
      
      ## Retry patterns
      
      Retry `SQLITE_BUSY`. Never retry `SQLITE_LOCKED`. Always retry the **whole transaction**,
      not the failed statement — a partial transaction cannot be resumed.
      
      ```python
      import sqlite3, time, random
      
      def with_retry(conn, fn, attempts=5):
          """Retry a whole write transaction on SQLITE_BUSY with jittered backoff."""
          for attempt in range(attempts):
              try:
                  conn.execute("BEGIN IMMEDIATE")
                  result = fn(conn)
                  conn.execute("COMMIT")
                  return result
              except sqlite3.OperationalError as exc:
                  conn.execute("ROLLBACK")
                  if "locked" not in str(exc) and "busy" not in str(exc):
                      raise                      # not a contention error - do not retry
                  if attempt == attempts - 1:
                      raise
                  time.sleep((2 ** attempt) * 0.05 + random.random() * 0.05)
      ```
      
      Jitter matters: without it, N contending writers retry in lockstep and keep colliding.
      
      ---
      
      ## See also
      
      - [`hosts.md`](hosts.md) — the pragma baseline per driver, and which hosts expose it
      - [`operations.md`](operations.md) — backups that are safe under concurrent writers
      - [`schema-design.md`](schema-design.md) — `foreign_keys` and the constraints it enables
      - [`d1-edge.md`](d1-edge.md) — what a managed engine takes away from this chapter
      
    • d1-edge.md 27.1 KB
      # Cloudflare D1 and Edge SQLite
      
      D1 is SQLite, so everything in [`query-performance.md`](query-performance.md),
      [`schema-design.md`](schema-design.md), and [`feature-modules.md`](feature-modules.md)
      applies unchanged. This file covers what is **different**: how you observe cost, how you are
      billed, the platform features stock SQLite has no equivalent for (Sessions API read
      replication, Time Travel, `d1 insights`), and the limits that will bite.
      
      > **Two sourcing notes.** Measurements labelled *verified 2026-08-04* come from a live D1
      > database (`atdw-mirror`, region OC, colo SYD) and are **one database's numbers, not
      > constants**. Platform limits and feature descriptions come from the Cloudflare D1 docs as
      > of 2026-08-04 — that surface moves, so re-check
      > [platform/limits](https://developers.cloudflare.com/d1/platform/limits/) before designing
      > around a number.
      
      ## Contents
      
      - [Two metrics, not one](#two-metrics-not-one)
      - [The meta object](#the-meta-object)
      - [Measuring correctly](#measuring-correctly)
      - [wrangler d1 insights](#wrangler-d1-insights)
      - [Statement formatting rules](#statement-formatting-rules)
      - [Cold runs and variance](#cold-runs-and-variance)
      - [Platform limits](#platform-limits)
      - [The bound-parameter cap](#the-bound-parameter-cap)
      - [Introspection is blocked](#introspection-is-blocked)
      - [Batching and the invisible statement](#batching-and-the-invisible-statement)
      - [Sessions API and read replication](#sessions-api-and-read-replication)
      - [Time Travel](#time-travel)
      - [Automatic retries](#automatic-retries)
      - [Error catalogue](#error-catalogue)
      - [Import and export](#import-and-export)
      - [Optimising for rows read](#optimising-for-rows-read)
      - [Schema changes under a deploy gate](#schema-changes-under-a-deploy-gate)
      - [libSQL and Turso](#libsql-and-turso)
      
      ---
      
      ## Two metrics, not one
      
      D1 reports latency and rows-read per statement, and **they move independently**:
      
      | Metric | What it is | Why it matters |
      |---|---|---|
      | `meta.timings.sql_duration_ms` | Server-side execution, excluding network | User-facing latency |
      | `meta.rows_read` | Rows the engine **scanned** | **The billing unit** |
      | `meta.rows_written` | Rows written (`INSERT`/`UPDATE`/`DELETE`) | Also billed |
      
      Billing is per row scanned, regardless of row size: a 1 KB row and a 100 KB row each count
      as one. Indexes add a written row when the indexed column is written (one to the table, one
      to the index) — almost always repaid by the reduction in rows read.
      
      Measured examples from the same session (verified 2026-08-04):
      
      - A covering-index fix cut latency **~25x** (171.83 ms → 6.75 ms) while leaving rows read
        essentially **unchanged** (60,736 → 58,433). Latency win, **no billing win**.
      - A watermark fix (replacing an unindexed `MAX()` scan with an indexed lookup) collapsed rows
        read roughly **58,000x** — 58,432 → 1. Billing win *and* latency win.
      
      An "optimisation" that halves latency while leaving a 58k-row scan in place has not reduced
      your D1 bill at all. Always report the pair.
      
      ---
      
      ## The meta object
      
      Returned by `run()`, `all()`, and each result of `batch()`.
      
      | Field | Meaning |
      |---|---|
      | `timings.sql_duration_ms` | SQL execution by the database instance, **excluding network time** — the number to optimise against |
      | `duration` | Duration of the query execution, in milliseconds |
      | `rows_read` | Rows scanned — the billing unit |
      | `rows_written` | Rows written |
      | `changes` | Number of changes made |
      | `changed_db` | `true` if anything on the database changed — useful for asserting a statement really was read-only |
      | `last_row_id` | Last inserted row id (not applicable to `WITHOUT ROWID` tables) |
      | `size_after` | Database size after the query |
      | `served_by_region` | Region of the instance that executed the query |
      | `served_by_primary` | `true` only if the primary served it — the replica-routing tell |
      
      ```js
      const { results, meta } = await env.DB
        .prepare("SELECT id, name FROM product WHERE org = ?").bind("acme").all();
      
      console.log({
        ms: meta.timings.sql_duration_ms,
        scanned: meta.rows_read,
        efficiency: results.length / Math.max(meta.rows_read, 1),  // want close to 1.0
        region: meta.served_by_region,
        primary: meta.served_by_primary,
      });
      ```
      
      **Query efficiency** — rows returned ÷ rows read — is the single best one-number health
      metric for a D1 statement. A query returning 20 rows after scanning 58,000 has an efficiency
      of 0.0003 and is a missing index.
      
      ---
      
      ## Measuring correctly
      
      **Use `sql_duration_ms`. Never wall-clock a `wrangler` invocation** — `npx`/wrangler startup
      is roughly two seconds before any SQL executes, which drowns the signal entirely. A Worker
      with a D1 binding talks to the database directly and pays none of that startup, so CLI wall
      time is doubly misleading about production.
      
      ```bash
      wrangler d1 execute atdw-mirror --remote --json \
        --command "SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%acme%'" \
        | jq '.[0].meta | {ms: .timings.sql_duration_ms, rows_read, rows_written, served_by_primary}'
      ```
      
      A 12-run median loop, reporting the range as well:
      
      ```bash
      for i in $(seq 1 12); do
        wrangler d1 execute atdw-mirror --remote --json \
          --command "SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%acme%'" \
          | jq -r '.[0].meta.timings.sql_duration_ms'
      done | sort -n | awk '{a[NR]=$1} END {printf "median %.2f  min %.2f  max %.2f\n", a[int(NR/2)+1], a[1], a[NR]}'
      ```
      
      `EXPLAIN QUERY PLAN` works over the same path and is the right first move:
      
      ```bash
      wrangler d1 execute atdw-mirror --remote --json \
        --command "EXPLAIN QUERY PLAN SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%acme%'" \
        | jq -r '.[0].results[].detail'
      ```
      
      Pipe that into `scripts/eqp-triage.py` for severities and fix hints.
      
      **Note `--remote`.** Omit it and you hit a *local* copy, which will happily give you fast,
      meaningless numbers against different data.
      
      ---
      
      ## wrangler d1 insights
      
      The feature most often missed. `d1 insights` ranks your **actual production queries** by
      cost — it finds the expensive statement you didn't know to look for, which is precisely the
      class of problem the "invisible aggregate" below belongs to.
      
      ```bash
      # Slowest queries on average over the last day
      wrangler d1 insights atdw-mirror --sort-type=avg --sort-by=time --limit=10
      
      # Biggest total row-scanners over a week - the ones costing you money
      wrangler d1 insights atdw-mirror --sort-type=sum --sort-by=reads --limit=10 --timePeriod=7d
      
      # Most frequently executed - a cheap query run 10M times beats a slow one run twice
      wrangler d1 insights atdw-mirror --sort-type=sum --sort-by=count --limit=10
      
      # Machine-readable, for triage in a script
      wrangler d1 insights atdw-mirror --sort-by=reads --limit=20 --json | jq '.[]'
      ```
      
      | Flag | Values | Default |
      |---|---|---|
      | `--timePeriod` | e.g. `1d`, `7d` | `1d` |
      | `--sort-type` | `sum`, `avg` | `sum` |
      | `--sort-by` | `time`, `reads`, `writes`, `count` | `time` |
      | `--sort-direction` | `ASC`, `DESC` | `DESC` |
      | `--limit` | integer | — |
      | `--json` | flag | `false` |
      
      Reported per query: `avgRowsRead` / `totalRowsRead`, `avgRowsWritten` / `totalRowsWritten`,
      `avgDurationMs` / `totalDurationMs`, `numberOfTimesRun`, and **`queryEfficiency`** (rows
      returned ÷ rows read — target close to 1.0).
      
      **Triage order that works:** sort by `sum`/`reads` first (total cost), then by `avg`/`time`
      (worst single experience), then look for low `queryEfficiency` at high `numberOfTimesRun` —
      that combination is a missing index on a hot path.
      
      The command is marked experimental; if it changes, the same data is available through the
      GraphQL Analytics API (`d1AnalyticsAdaptiveGroups`, `d1QueriesAdaptiveGroups`,
      `d1StorageAdaptiveGroups`; fields include `readQueries`, `writeQueries`, `rowsRead`,
      `rowsWritten`, `queryBatchTimeMs` with percentiles such as `queryBatchTimeMsP90`, and
      `databaseSizeBytes`; 31-day retention).
      
      ---
      
      ## Statement formatting rules
      
      **Statements must be on ONE LINE.** A multi-line `--command` fails with:
      
      ```
      incomplete input: SQLITE_ERROR 7500
      ```
      
      This is a wrapper-parsing artefact, not a SQL error, and the message is actively misleading —
      it reads like unbalanced parentheses. Collapse to one line, or use a file.
      
      ```bash
      # Fails: multi-line --command
      wrangler d1 execute db --remote --command "SELECT a
      FROM t"
      
      # Works: one line
      wrangler d1 execute db --remote --command "SELECT a FROM t"
      
      # Works: multi-line via file
      wrangler d1 execute db --remote --file ./query.sql
      ```
      
      ---
      
      ## Cold runs and variance
      
      The **first run is not representative.** Observed on multi-thousand-row reads (verified
      2026-08-04): a routine 1.5–1.7x penalty above median on the first execution, and one cold run
      measured **2,495 ms against a 171 ms median** for the same statement — a 14x outlier.
      
      - Take a **median of 10+ runs** and report the range.
      - Discard, or at least label, the first run.
      - Never compare a single before-run to a single after-run — that comparison can invert the
        true result entirely.
      - Report honestly: "171.83 ms median, 168–2,495 ms across 12 runs", not "171 ms".
      
      ---
      
      ## Platform limits
      
      From the D1 docs, 2026-08-04. Re-check before designing around any of them.
      
      | Limit | Workers Paid | Workers Free |
      |---|---|---|
      | Databases per account | 50,000 | 10 |
      | Maximum database size | 10 GB | 500 MB |
      | Maximum storage per account | 1 TB | 5 GB |
      | Queries per Worker invocation | 1,000 | 50 |
      | Maximum SQL statement length | 100,000 bytes (100 KB) | same |
      | **Maximum bound parameters per query** | **100** | same |
      | Maximum SQL query duration | 30 seconds | same |
      | Maximum columns per table | 100 | same |
      | Maximum rows per table | Unlimited (within storage) | same |
      | Maximum string / BLOB / row size | 2,000,000 bytes (2 MB) | same |
      | Maximum arguments per SQL function | 32 | same |
      | **Maximum bytes in a `LIKE`/`GLOB` pattern** | **50 bytes** | same |
      | Maximum file import (`d1 execute --file`) | 5 GB | same |
      
      Individual query limits apply to **each statement inside a batch**, not to the batch as a
      whole.
      
      Two of these interact with material elsewhere in this skill:
      
      - **100 columns per table.** The worked example's 73-column table was already close. A wide
        table is exactly where covering indexes pay off most (see
        [`query-performance.md`](query-performance.md#covering-indexes)) — and past 100 columns you
        must split the table regardless.
      - **50 bytes in a `LIKE` pattern.** Long user-supplied search strings will be rejected — a
        further argument for FTS5 `MATCH` over `LIKE` for real search
        ([`feature-modules.md`](feature-modules.md#the-trigram-tokenizer)).
      
      Rows-read/written pricing (2026-08-04): Free 5M rows read + 100k written per day; Paid
      includes 25B rows read + 50M written per month, then $0.001/M read and $1.00/M written;
      storage 5 GB included, then $0.75/GB-month. No egress charges. **Read replicas cost nothing
      extra** — you pay the same `rows_read`/`rows_written`.
      
      ---
      
      ## The bound-parameter cap
      
      100 bound parameters per statement. Exceeding it:
      
      ```
      too many SQL variables … SQLITE_ERROR 7500
      ```
      
      This bites the moment you build `WHERE id IN (?, ?, ?, …)` from a list. Inline literals are
      **not** capped — and that is the trap, because switching to string-interpolated literals to
      dodge the cap is how SQL injection gets introduced.
      
      **Chunk instead:**
      
      ```js
      const CHUNK = 90;   // headroom under the 100-parameter cap
      const out = [];
      for (let i = 0; i < ids.length; i += CHUNK) {
        const slice = ids.slice(i, i + CHUNK);
        const placeholders = slice.map(() => "?").join(",");
        const { results } = await env.DB
          .prepare(`SELECT id, name FROM product WHERE id IN (${placeholders})`)
          .bind(...slice)
          .all();
        out.push(...results);
      }
      ```
      
      Note what is and isn't interpolated: the **placeholder string** is generated (safe — it is
      `?` characters), the **values** are always bound. Never build the value list by
      concatenation, whatever the cap says.
      
      **Better still, one parameter for any list length** using `json_each`:
      
      ```sql
      SELECT p.id, p.name FROM product p JOIN json_each(?) j ON j.value = p.id;
      ```
      
      ```js
      await env.DB.prepare("SELECT p.id, p.name FROM product p JOIN json_each(?) j ON j.value = p.id")
        .bind(JSON.stringify(ids)).all();
      ```
      
      Watch the 100 KB statement-length limit if you go the inline route for a very large list —
      and remember chunked reads each count separately toward the 1,000-queries-per-invocation cap.
      
      ---
      
      ## Introspection is blocked
      
      D1 refuses several introspection paths with `SQLITE_AUTH`. Verified refused 2026-08-04:
      
      | Attempted | Result |
      |---|---|
      | `SELECT sqlite_version()` | `SQLITE_AUTH` |
      | `SELECT * FROM pragma_module_list` | `SQLITE_AUTH` |
      
      **Consequence: FTS5 and trigram availability on D1 could not be confirmed read-only.**
      Confirming it requires `CREATE VIRTUAL TABLE`, which is a **write** — out of reach of a
      read-only investigation and of a session under a deploy gate.
      
      **This is genuinely unknown, not "probably fine".** If your design depends on FTS5 on D1,
      verify deliberately: create a throwaway virtual table in a **preview/dev** D1 database (never
      production) and observe. Record the result; do not infer it from stock SQLite behaviour.
      
      What *does* work for schema discovery:
      
      ```sql
      SELECT name, sql FROM sqlite_master WHERE type IN ('table','index');
      SELECT * FROM pragma_table_info('q_product');
      SELECT * FROM pragma_index_list('q_product');
      SELECT * FROM pragma_index_info('q_product_org');
      ```
      
      The `pragma_*` **table-valued functions** are the introspection route on D1 — the classic
      `PRAGMA table_info(x)` statement form is not generally available through the HTTP path.
      Connection-scoped pragmas (`journal_mode`, `busy_timeout`, `foreign_keys`, `synchronous`) are
      managed by the platform and are not yours to set.
      
      ---
      
      ## Batching and the invisible statement
      
      `batch()` sends multiple statements in one round trip, wrapped in an implicit transaction
      that stops at the first failure. Good for latency and atomicity — **dangerous for
      observability**: a statement inside someone else's batch adds no measurable round-trip cost,
      so it never appears in per-request timing, while still doing all its work and billing every
      row it reads.
      
      The measured case (verified 2026-08-04): a `MAX()` over an unindexed column, riding inside a
      batch another query was already sending, scanned **58,432 rows on every response across four
      separate tools** and cost **28.09 ms** — invisible to per-query measurement.
      
      ```js
      // Each of these is billed and timed separately, even though it is one round trip
      const [a, b] = await env.DB.batch([
        env.DB.prepare("SELECT id, name FROM product WHERE org = ?").bind(org),
        env.DB.prepare("SELECT MAX(updated_at) FROM product"),   // <- full scan, hiding here
      ]);
      ```
      
      **Audit rule for any D1 codebase:** enumerate every statement in every `batch()` and price
      each individually. A batch's cost is the *sum* of its statements' rows read. `d1 insights`
      sorted by `sum`/`reads` will surface these even when your own instrumentation cannot.
      
      Batching has a *correctness* trap as well as this observability one: the batch rolls back
      on a SQL error, but a conditional `UPDATE` matching 0 rows is **not** an error — see
      [`d1-production-patterns.md`](d1-production-patterns.md#batch-and-the-0-row-conditional-write)
      for the pre-check / post-verify / compensate shape.
      
      ---
      
      ## Sessions API and read replication
      
      D1 can serve reads from **read replicas** — read-only copies, one per supported D1 region,
      created and routed to automatically by Cloudflare **at no additional cost**. Writes always
      go to the primary; replicas forward them.
      
      Replication is **opt-in twice**: enable it on the database (dashboard → D1 → your database →
      Settings → Enable Read Replication, or the REST API with `read_replication.mode: auto`),
      **and** use the Sessions API in your Worker. Without `withSession()`, every query goes to the
      primary and you get no benefit.
      
      ```ts
      export default {
        async fetch(request, env) {
          // Continue a prior session's consistency guarantee, or start fresh
          const bookmark = request.headers.get("x-d1-bookmark") ?? "first-unconstrained";
          const session = env.DB.withSession(bookmark);
      
          const { results, meta } = await session
            .prepare("SELECT * FROM Customers WHERE CompanyName = ?")
            .bind("Bs Beverages")
            .all();
      
          const response = Response.json(results);
          // Hand the bookmark back so the NEXT request is at least as fresh as this one
          response.headers.set("x-d1-bookmark", session.getBookmark() ?? "");
          return response;
        },
      };
      ```
      
      | `withSession()` argument | Behaviour |
      |---|---|
      | `"first-unconstrained"` (default) | First query may go to any instance — lowest latency, may be slightly stale |
      | `"first-primary"` | First query goes to the primary — freshest data, higher first-query latency |
      | A bookmark string | Session starts at least as current as that bookmark |
      
      **The consistency model is sequential consistency *within a session*.** Queries in one
      session never see the database go backwards, and a read after a write in the same session
      sees that write. Across sessions you get nothing unless you carry the bookmark — which is why
      the header round-trip above is the whole pattern, not an optimisation.
      
      The classic bug replication introduces: write, redirect, read — and the read lands on a
      replica that hasn't caught up, so the user doesn't see their own change. Carrying the
      bookmark (or using `first-primary` on the read-after-write path) is the fix.
      
      `session.getBookmark()` returns `null` if no query ran in the session. Check
      `meta.served_by_primary` and `meta.served_by_region` to see where a query actually landed —
      that is how you verify replication is doing anything.
      
      **When replication does not help:** write-heavy workloads (all writes hit the primary
      anyway), single-region traffic, and workloads that require the absolute latest data on every
      read.
      
      For the production rollout shape — default every request to `first-primary` and let only an
      enumerated allowlist of display-only GETs touch a replica, so a misclassified route degrades
      to slower rather than staler — see
      [`d1-production-patterns.md`](d1-production-patterns.md#read-replication-opt-in-to-replica-never-opt-out).
      
      ---
      
      ## Time Travel
      
      D1's built-in point-in-time recovery. There is no stock-SQLite equivalent, and it is the
      reason a D1 migration is less frightening than a local one.
      
      | Property | Detail |
      |---|---|
      | Retention | **30 days** (Workers Paid), 7 days (Free) |
      | Granularity | Any timestamp, or a bookmark |
      | Restore is destructive | Overwrites the database **in place**; in-flight queries are cancelled |
      | Restoring keeps history | Older bookmarks remain valid, so you can restore again to a different point |
      | Bookmarks from timestamps | Deterministic — the same timestamp always yields the same bookmark |
      | Not yet supported | Cloning/forking a database to a new one via Time Travel |
      | Requires | Wrangler v3.4.0+, a production-version database |
      
      ```bash
      # Current bookmark - capture this BEFORE any risky migration
      wrangler d1 time-travel info atdw-mirror
      
      # Restore to a Unix timestamp, or an ISO-8601 date-time string
      wrangler d1 time-travel restore atdw-mirror --timestamp=1754280000
      wrangler d1 time-travel restore atdw-mirror --timestamp=2026-08-04T11:18:53.000+10:00
      
      # Restore to a specific bookmark
      wrangler d1 time-travel restore atdw-mirror --bookmark=<BOOKMARK_ID>
      ```
      
      **A restore is a production-state change** — maintainer-gated, exactly like a deploy. A
      working session records the bookmark and reports the command; it does not run it.
      
      Bookmarks are the same objects the Sessions API uses, which is what makes "restore to the
      state that request saw" possible: log `session.getBookmark()` alongside a request id and you
      can later restore to precisely that point.
      
      ---
      
      ## Automatic retries
      
      D1 detects read-only queries and retries them up to **two** times on retryable failures.
      
      - Only statements containing solely `SELECT`, `EXPLAIN`, or `WITH` are retried.
      - Anything containing a write keyword is never retried.
      - D1 checks for modifications after each execution and rolls back if a retry caused a write,
        so retries are side-effect-free even if detection is fooled.
      
      **Implication for measurement:** a `sql_duration_ms` outlier may be a retried query. One more
      reason to take a median rather than trusting a single sample.
      
      Your own code still needs retry logic for **writes** — those are never retried for you.
      
      ---
      
      ## Error catalogue
      
      | Message | Meaning | Action |
      |---|---|---|
      | `incomplete input: SQLITE_ERROR 7500` | Multi-line `--command` | One line, or `--file` |
      | `too many SQL variables … SQLITE_ERROR 7500` | >100 bound parameters | Chunk, or `json_each` |
      | `SQLITE_AUTH` | Blocked introspection (`sqlite_version()`, `pragma_module_list`) | Use `sqlite_master` / `pragma_*` functions |
      | `D1 DB is overloaded. Requests queued for too long.` | Too many requests, or queries too slow | Optimise queries, spread load, shard |
      | `D1 DB is overloaded. Too many requests queued.` | Queue too long | Same |
      | `D1 DB's isolate exceeded its memory limit and was reset.` | A query loaded too much into memory | Shard the query; add a `LIMIT` |
      | `D1 DB exceeded its CPU time limit and was reset.` | A very expensive scan, or a large import/export | Split into smaller statements |
      | `Exceeded maximum DB size.` | Past the storage limit | Delete data, or shard across databases |
      | `Your account has exceeded D1's maximum account storage limit` | Account-wide storage limit | Delete unused databases, or upgrade |
      | `No SQL statements detected.` | Empty/invalid input | Check the statement made it through |
      | `D1 DB reset because its code was updated.` / `Network connection lost.` | Transient platform events | Retry |
      
      The "overloaded" and "CPU time limit" errors are the ones an unindexed scan produces at
      scale. They are performance problems wearing an infrastructure costume — go to
      [`query-performance.md`](query-performance.md), not to a support ticket.
      
      ---
      
      ## Import and export
      
      ```bash
      # Export the whole database as SQL
      wrangler d1 export atdw-mirror --remote --output=./database.sql
      
      # Schema only / data only / one table
      wrangler d1 export atdw-mirror --remote --output=./schema.sql --no-data
      wrangler d1 export atdw-mirror --remote --output=./data.sql   --no-schema
      wrangler d1 export atdw-mirror --remote --output=./one.sql    --table=q_product
      
      # Import (this is a WRITE to production - maintainer-gated when --remote)
      wrangler d1 execute atdw-mirror --local  --file=./database.sql
      wrangler d1 execute atdw-mirror --remote --file=./database.sql
      ```
      
      | Constraint | Detail |
      |---|---|
      | Import file size | 5 GB max for `d1 execute --file` — split larger loads and import sequentially |
      | Statement length | 100 KB — split a huge `INSERT` into batches (e.g. 1,000 rows → four 250-row statements) |
      | Transactions | **Remove `BEGIN TRANSACTION` / `COMMIT`** from a dump before importing |
      | From local SQLite | `sqlite3 db.sqlite3 .dump > db.sql`, then strip the transaction statements |
      | CPU limits | A very large import can trip the isolate's CPU limit — smaller chunks are the fix |
      
      Export is also the honest way to get a local copy for plan experiments: export the schema,
      import it locally, seed representative row counts, and iterate on indexes there before
      proposing a migration.
      
      ---
      
      ## Optimising for rows read
      
      Because rows read is the billing unit, D1 rewards optimisations stock SQLite treats as a
      nice-to-have.
      
      | Pattern | Rows-read effect |
      |---|---|
      | Index an aggregated column (`MAX`/`MIN` watermark) | Collapses a full scan to ~1 row — the biggest single win available |
      | Add a selective `WHERE` a plain index can seek | Proportional reduction |
      | Covering index for an unseekable predicate | Usually **no** rows-read change — latency only |
      | Maintain a counter/summary row instead of `COUNT(*)` | O(n) scan → one row read |
      | `LIMIT` with a matching index | Stops the scan early |
      | Cache in KV or the Workers cache in front of D1 | Removes the read entirely |
      
      Watermark pattern — the highest-value D1 refactor, from the measured session:
      
      ```sql
      -- Before: full scan on every request
      SELECT MAX(updated_at) FROM q_product;             -- 28.09 ms, 58,432 rows read
      
      -- After: index the aggregated column
      CREATE INDEX q_product_updated ON q_product(updated_at);
      SELECT MAX(updated_at) FROM q_product;             -- 0.17 ms, 1 row read
      ```
      
      `MAX(col)` over an indexed column is a walk to the end of the B-tree. That is the whole fix.
      
      ---
      
      ## Schema changes under a deploy gate
      
      Creating an index on D1 is a **write to production**, applied through a migration and a
      deploy — and deploys are maintainer-gated. A working session should:
      
      1. Prove the payoff **read-only** using the technique in
         [`query-performance.md`](query-performance.md#the-read-only-proof-technique).
      2. Write the migration file and commit it.
      3. Capture a Time Travel bookmark so the maintainer has a named restore point.
      4. **Stop.** Report the exact command and what it would change.
      
      ```bash
      wrangler d1 time-travel info atdw-mirror              # record the restore point
      wrangler d1 migrations apply atdw-mirror --local      # safe: local copy
      wrangler d1 migrations apply atdw-mirror --remote     # MAINTAINER RUNS THIS
      ```
      
      An unverified-until-deploy conclusion is a legitimate deliverable: "this index is projected
      to cut the statement from 171 ms to ~7 ms based on a read-only proof; applying it needs a
      gated deploy" beats applying it to find out.
      
      And when the maintainer's `--remote` apply reports a timeout: the migration may have landed
      anyway — verify schema state read-only before re-running
      ([`d1-production-patterns.md`](d1-production-patterns.md#migration-apply-can-time-out-yet-still-land)).
      
      ---
      
      ## libSQL and Turso
      
      libSQL is a SQLite fork; Turso is its hosted service. Same planner, same SQL, different
      operational envelope.
      
      | Aspect | Note |
      |---|---|
      | Embedded replicas | Local read replica synced from the primary — reads local-fast, writes remote |
      | Replica staleness | A read right after a write may not see it; sync or use read-your-writes support before assuming consistency |
      | Connection model | HTTP/WebSocket to a server, embedded file, or embedded replica — pick deliberately; the performance profiles differ enormously |
      | Extensions | libSQL adds features beyond stock SQLite (e.g. native vector support in recent versions). Verify against **your** server version — this moves |
      | Pragmas | More of the pragma surface than D1, but a hosted primary still owns durability settings |
      | Billing | Also reads-oriented — the rows-read discipline transfers directly |
      
      The portability rule: **keep your SQL stock-SQLite unless you have a specific reason not
      to.** A schema that runs unmodified on the CLI, on D1, and on Turso is worth real money in
      optionality, and the vast majority of application SQL never needs a vendor extension.
      
      ---
      
      ## See also
      
      - [`query-performance.md`](query-performance.md) — the engine-level analysis this builds on
      - [`d1-production-patterns.md`](d1-production-patterns.md) — incident-derived procedures: timed-out migrations, `batch()` 0-row writes, the replication rollout
      - [`hosts.md`](hosts.md) — the D1 driver API alongside the other hosts
      - [`migration-patterns.md`](migration-patterns.md) — wrangler migrations and the deploy gate
      - [`feature-modules.md`](feature-modules.md) — why FTS5 on D1 is recorded as unknown
      - `cloudflare-ops` skill — Workers, bindings, wrangler configuration, deployment
      
    • d1-production-patterns.md 12.7 KB
      # D1 Production Patterns
      
      Three patterns learned running a multi-tenant production Worker on Cloudflare D1. Each is
      incident-shaped — **symptom → why → procedure** — and each closes a gap where D1's happy-path
      API reads as success while something else happened. [`d1-edge.md`](d1-edge.md) is the
      platform reference (billing, limits, meta, Sessions API mechanics); this file is what those
      mechanics do to you in production and the defensive shape that survives them.
      
      > **Sourcing note.** These patterns come from a live multi-tenant billing platform on one
      > Worker + one D1 database (2026-07/08): a migration-timeout incident, a conditional-write
      > race in the Xero push path, and a signed-off read-replication rollout (its ADR and the
      > ~100-line session module are distilled in pattern 3). The code shapes are generalised;
      > the failure modes are not hypothetical.
      
      ## Contents
      
      - [Migration apply can time out yet still land](#migration-apply-can-time-out-yet-still-land)
      - [batch() and the 0-row conditional write](#batch-and-the-0-row-conditional-write)
      - [Read replication: opt-in-to-replica, never opt-out](#read-replication-opt-in-to-replica-never-opt-out)
      
      ---
      
      ## Migration apply can time out yet still land
      
      **Symptom.** `wrangler d1 migrations apply <db> --remote` reports a timeout or network
      error. The natural reflex — re-run it — is the trap.
      
      **Why.** The apply is an HTTP round trip to a remote engine, and the error you saw is about
      the *response*, not the *work*. The migration can execute and be recorded server-side while
      the CLI's connection dies waiting — so the client-visible outcome ("it failed") and the
      database's actual state ("it applied") disagree. A blind re-run then re-executes SQL against
      a database that already has it:
      
      - `CREATE TABLE` / `CREATE INDEX` without `IF NOT EXISTS` → the re-run fails, which at
        least tells you the truth, confusingly.
      - Seed/backfill `INSERT`s → **duplicated data**, which tells you nothing until something
        downstream breaks.
      - `ALTER TABLE ... ADD COLUMN` → fails with "duplicate column name" (SQLite has no
        `IF NOT EXISTS` for column adds — this is why column-add migrations can never be made
        fully idempotent and *must* go through the verify step).
      
      **Procedure — verify state before re-applying, read-only:**
      
      ```bash
      # 1. What does wrangler think was applied? (D1 tracks applied migrations in its own table)
      wrangler d1 migrations list <db> --remote
      
      # 2. Does the schema object actually exist? Ask the database, not the CLI's last error.
      wrangler d1 execute <db> --remote --json \
        --command "SELECT name, sql FROM sqlite_master WHERE name = 'new_table_or_index'"
      
      # 3. For a column add, inspect the table shape (pragma_* function form — see d1-edge.md)
      wrangler d1 execute <db> --remote --json \
        --command "SELECT * FROM pragma_table_info('the_table')"
      ```
      
      Decision table:
      
      | Observed | Meaning | Action |
      |---|---|---|
      | Object exists, migrations list shows it applied | Landed; only the response was lost | Nothing to do — do **not** re-run |
      | Object exists, migrations list does NOT show it | Landed but bookkeeping is behind | Reconcile deliberately (the migration's SQL must not run twice) — never a blind re-apply |
      | Object missing | Genuinely did not land | Re-apply |
      
      Two habits make the incident boring instead of dangerous:
      
      - **Write migrations idempotent-safe where the syntax allows it** — `IF NOT EXISTS` on
        every `CREATE`, `INSERT OR IGNORE` for seeds — so an accidental double-apply is a no-op.
        Where it doesn't (column adds), the verify-first procedure above is the whole protection.
      - **Treat "timeout" as "state unknown", never as "failed".** The same discipline as any
        distributed write: an error after the request left the building tells you nothing about
        what the server did.
      
      ---
      
      ## batch() and the 0-row conditional write
      
      **Symptom.** A scoped write inside a `batch()` — `UPDATE ... WHERE id = ? AND
      tenant_id = ?` — "succeeds": no error, batch commits, caller returns 200. But the row
      belongs to another tenant (or was already claimed, or doesn't exist), so the statement
      matched **zero rows** and changed nothing. The caller reported success for a write that
      never happened.
      
      **Why.** `batch()` wraps its statements in a transaction and rolls back on a **SQL
      error** — and a conditional `UPDATE`/`DELETE` matching 0 rows is *not* an error. It is a
      successful statement with `meta.changes === 0`. This is correct SQL semantics on every
      engine, but D1's batch framing makes it easy to read "the batch committed" as "every
      statement did what I meant". The scoping predicate that makes multi-tenant writes safe
      (`AND tenant_id = ?`) is exactly the predicate that turns an authorization failure into a
      silent no-op.
      
      **Procedure — pre-check, post-verify, compensate.** The rule in one line: **0 rows
      affected on a scoped UPDATE/DELETE means 403/404/conflict, never success.**
      
      The single-statement shape — check `meta.changes` on every conditional write:
      
      ```ts
      const res = await db
        .prepare(`UPDATE clients SET name = ?, updated_at = ? WHERE id = ? AND tenant_id = ?`)
        .bind(name, now, clientId, tenantId)
        .run();
      if ((res.meta?.changes ?? 0) === 0) throw NotFound('client not found');
      // 0 here means: wrong tenant, or no such row. Either way, NOT success.
      ```
      
      The compare-and-set shape — a conditional claim where exactly one racer may win:
      
      ```ts
      // Claim is a CAS on the ownership column, never an assumption. Two racers issue the
      // same UPDATE; the WHERE ... IS NULL lets exactly one match. The loser's statement
      // "succeeds" with 0 changes — the explicit count check is what turns that into Conflict.
      const claim = await db
        .prepare(`UPDATE push_intents SET document_id = ?, updated_at = ?
                   WHERE id = ? AND tenant_id = ? AND document_id IS NULL`)
        .bind(documentId, now, intentId, tenantId)
        .run();
      if ((claim.meta?.changes ?? 0) !== 1) throw Conflict();
      ```
      
      The batch shape — conditional statements inside a `batch()` need a **post-verify count
      and a compensating undo**, because the batch cannot fail itself on your behalf:
      
      ```ts
      // lockStmts are conditional claims (`... WHERE owner_id IS NULL`): a racing writer
      // matches zero rows there and the batch still commits. The post-verify is the gate.
      await db.batch([docStmt, ...lineStmts, ...lockStmts]);
      
      const locked = await db
        .prepare(`SELECT COUNT(*) AS n FROM entries WHERE tenant_id = ? AND owner_id = ?`)
        .bind(tenantId, documentId)
        .first<{ n: number }>();
      if ((locked?.n ?? 0) !== expectedCount) {
        await db.batch(undoStmts);   // compensate: release partial claims, delete the doc
        throw Conflict();            // a concurrent writer claimed some rows first
      }
      ```
      
      `batch()` returns one result per statement, each with its own `meta` — so for batches
      where each statement's effect matters, walk the results and check `meta.changes`
      per-statement rather than trusting the commit. (Counting actual changes is also how you
      report an honest number for `INSERT OR IGNORE` batches: sum `meta.changes`, don't count
      statements.)
      
      When you *want* the batch to abort atomically on a precondition, invert the trick: make
      the guard statement **violate a constraint** on failure (e.g. an `INSERT` that collides
      with a `UNIQUE` index) — a real SQL error rolls the whole batch back. That is the one
      shape where "error aborts batch" works *for* you; a 0-row match never will.
      
      ---
      
      ## Read replication: opt-in-to-replica, never opt-out
      
      **Symptom class this prevents.** With D1 read replication enabled (see
      [`d1-edge.md`](d1-edge.md#sessions-api-and-read-replication) for the mechanics), the naive
      rollout routes *all* reads through replica-eligible sessions and then exempts the routes
      someone remembered are sensitive. Every route the exemption list misses is a **stale read
      feeding a write flow** — a balance check, a pre-push billing review, a lock check — and the
      failure only shows up as an occasional wrong decision under replica lag, which is the worst
      possible way to discover a route classification bug.
      
      **The pattern.** Invert the default so a classification mistake degrades to *slower*,
      never to *staler*:
      
      1. **Every request defaults to `first-primary`** — strongly consistent, identical to
         pre-replication behaviour.
      2. A request may serve from a replica **only** when it is a `GET` **and** its path is
         positively enumerated in a replica allowlist of display-only surfaces.
      3. A route missing from the allowlist — by oversight or by design — stays on the primary:
         no latency win, no correctness risk. **A misclassification cannot corrupt data.** The
         partition's safety is a property of the code shape, not of per-query vigilance.
      4. **Writes always hit the primary** regardless of session mode (D1 routes writes to the
         primary itself) — so the only thing the allowlist can get wrong is letting a
         *pre-write read* see stale data. Keep every entry display-only.
      5. **Carry the session bookmark in a cookie** so the caller's next request is bounded to
         be at least as fresh as their own last write (read-your-writes), even when served by
         a replica.
      
      The whole decision fits in ~100 lines, and isolating it in one module is part of the
      pattern — one place owns the safety decision, and the data layer just receives a session:
      
      ```ts
      /** Display-only GET routes allowed to serve from a nearby read replica.
       *  Two matching shapes, chosen deliberately per entry:
       *   - Trailing '/': whole-subtree prefix. Safe ONLY because the method gate below
       *     excludes every mutation under it anyway.
       *   - No trailing slash: exact path only. Use this when the same mount point also
       *     serves write-flow reads (e.g. '/api/time/dashboard' is listed, but the
       *     billing-review reads under '/api/time/billing' must never become eligible —
       *     a prefix would have silently swept them in).
       *  When unsure, leave the route off — the default (primary) is always safe, just slower. */
      const REPLICA_ALLOWLIST: readonly string[] = [
        '/api/analytics/',        // subtree: pure read-model surface
        '/api/dashboard',         // exact: sibling routes include write-flow reads
      ];
      
      function isReplicaEligible(method: string, path: string): boolean {
        if (method !== 'GET') return false;
        return REPLICA_ALLOWLIST.some((e) => (e.endsWith('/') ? path.startsWith(e) : path === e));
      }
      
      /** Argument for env.DB.withSession(...) for this request. */
      function pickSessionMode(method: string, path: string, bookmark: string | null): string {
        if (!isReplicaEligible(method, path)) return 'first-primary';   // the safe default
        // Replica-eligible: constrain to the caller's own last write if they have one.
        return bookmark ?? 'first-unconstrained';
      }
      
      // In the request middleware: one session per request, bookmark round-tripped in a cookie.
      const bookmark = getCookie(request, 'd1_bookmark');
      const session = env.DB.withSession(pickSessionMode(request.method, url.pathname, bookmark));
      // ... handlers run against `session` instead of `env.DB` ...
      const newBookmark = session.getBookmark();
      if (newBookmark) setCookie(response, 'd1_bookmark', newBookmark);
      ```
      
      Classification guidance from the production partition that shipped:
      
      | Surface | Classification | Why |
      |---|---|---|
      | Analytics, dashboards, index/list read-models | Replica-eligible | Seconds of lag is invisible on a display surface |
      | Money *display* (totals, statements) | Replica-eligible | Display-only; nothing decides on it |
      | Any read **inside a write flow** (balance/lock checks, review-before-push) | Primary-only | A stale read here feeds a mutation |
      | Read immediately after that user's own write | Covered by the bookmark | Read-your-writes without pinning the route to primary |
      
      Two operational notes:
      
      - **Local test environments have one D1 and no replicas**, so the *refactor* (threading a
        session through the data layer) is testable locally but the *staleness behaviour* is
        not — validate replication in a preview/staging environment, and verify routing in
        production via `meta.served_by_primary` / `meta.served_by_region`
        ([`d1-edge.md`](d1-edge.md#the-meta-object)).
      - Session mode only constrains the **first** query; within a session D1 guarantees
        sequential consistency. The allowlist + bookmark pattern is about *choosing* the right
        first-query constraint per request, cheaply and safely, for every route you have —
        including the ones nobody thought about.
      
      ---
      
      ## See also
      
      - [`d1-edge.md`](d1-edge.md) — the D1 platform reference: billing, limits, `meta`,
        Sessions API mechanics, Time Travel, error catalogue
      - [`migration-patterns.md`](migration-patterns.md) — wrangler migrations, numbering,
        the deploy gate these apply-verification steps slot into
      - [`concurrency-durability.md`](concurrency-durability.md) — the engine-level locking
        model behind the CAS/claim shapes
      - `cloudflare-ops` skill — Workers, bindings, wrangler configuration, deployment
      
    • feature-modules.md 15.1 KB
      # SQLite Feature Modules
      
      FTS5, JSON/JSONB, R-tree, window functions, upsert, and RETURNING. Engine-agnostic SQL —
      but **availability varies by build**, and that is the first thing to check. See
      [Checking availability](#checking-availability).
      
      ## Contents
      
      - [Checking availability](#checking-availability)
      - [FTS5 full-text search](#fts5-full-text-search)
      - [The trigram tokenizer](#the-trigram-tokenizer)
      - [External-content FTS tables](#external-content-fts-tables)
      - [JSON functions](#json-functions)
      - [JSONB](#jsonb)
      - [R-tree](#r-tree)
      - [Window functions](#window-functions)
      - [Upsert](#upsert)
      - [RETURNING](#returning)
      - [Other useful modules](#other-useful-modules)
      
      ---
      
      ## Checking availability
      
      FTS5, R-tree, and JSON are **compile-time options**. Most distributions include all three;
      some minimal or embedded builds don't, and managed engines may block the introspection you
      would use to find out.
      
      ```sql
      SELECT sqlite_version();                          -- version gate for syntax features
      SELECT * FROM pragma_compile_options;             -- look for ENABLE_FTS5, ENABLE_RTREE
      SELECT * FROM pragma_module_list;                 -- registered virtual-table modules
      ```
      
      | Feature | Minimum version | Notes |
      |---|---|---|
      | Window functions | 3.25 (2018) | Also `ALTER TABLE RENAME COLUMN` |
      | Upsert (`ON CONFLICT DO UPDATE`) | 3.24 | |
      | `RETURNING` | 3.35 (2021) | Also `ALTER TABLE DROP COLUMN` |
      | `STRICT` tables | 3.37 | |
      | `->` / `->>` JSON operators | 3.38 | JSON functions themselves are much older |
      | JSONB | 3.45 (2024) | Internal binary format |
      | FTS5 `trigram` tokenizer | 3.34 | |
      
      **On Cloudflare D1, `sqlite_version()` and `pragma_module_list` are both refused with
      `SQLITE_AUTH`** (verified 2026-08-04). Confirming FTS5 availability there requires
      `CREATE VIRTUAL TABLE`, which is a write — so it **could not be confirmed read-only**, and
      this file records it as genuinely unknown. Test it in a preview/dev D1 database if your
      design depends on it; do not assume from stock SQLite behaviour. See
      [`d1-edge.md`](d1-edge.md).
      
      ---
      
      ## FTS5 full-text search
      
      A virtual table that maintains an inverted index over text columns.
      
      ```sql
      CREATE VIRTUAL TABLE doc_fts USING fts5(title, body);
      
      INSERT INTO doc_fts (title, body) VALUES ('Indexing', 'How B-trees work in SQLite');
      
      -- Match syntax
      SELECT * FROM doc_fts WHERE doc_fts MATCH 'btree';
      SELECT * FROM doc_fts WHERE doc_fts MATCH '"exact phrase"';
      SELECT * FROM doc_fts WHERE doc_fts MATCH 'index*';             -- prefix
      SELECT * FROM doc_fts WHERE doc_fts MATCH 'sqlite NOT mysql';
      SELECT * FROM doc_fts WHERE doc_fts MATCH 'title: indexing';    -- column filter
      SELECT * FROM doc_fts WHERE doc_fts MATCH 'NEAR(btree sqlite, 5)';
      ```
      
      ### Ranking
      
      ```sql
      -- bm25(): lower (more negative) is better; ORDER BY rank uses it automatically
      SELECT title, rank FROM doc_fts WHERE doc_fts MATCH 'sqlite' ORDER BY rank LIMIT 10;
      
      -- Column weights: title matters 10x more than body
      SELECT title, bm25(doc_fts, 10.0, 1.0) AS score
      FROM doc_fts WHERE doc_fts MATCH 'sqlite'
      ORDER BY score LIMIT 10;
      
      -- Highlighted excerpt
      SELECT snippet(doc_fts, 1, '<b>', '</b>', '…', 20) FROM doc_fts WHERE doc_fts MATCH 'sqlite';
      SELECT highlight(doc_fts, 0, '[', ']')             FROM doc_fts WHERE doc_fts MATCH 'sqlite';
      ```
      
      ### Tokenizers
      
      ```sql
      CREATE VIRTUAL TABLE t USING fts5(body, tokenize = 'unicode61 remove_diacritics 2');
      CREATE VIRTUAL TABLE t USING fts5(body, tokenize = 'porter unicode61');   -- stemming
      CREATE VIRTUAL TABLE t USING fts5(body, tokenize = 'trigram');            -- substring
      ```
      
      | Tokenizer | Use for |
      |---|---|
      | `unicode61` (default) | General word search; `remove_diacritics 2` folds accents |
      | `porter` | English stemming — "running" matches "run" |
      | `ascii` | ASCII-only, fastest, no Unicode folding |
      | `trigram` | **Substring** search — the real fix for `LIKE '%x%'` |
      
      ### Maintenance
      
      ```sql
      INSERT INTO doc_fts(doc_fts) VALUES ('optimize');   -- merge index segments; do periodically
      INSERT INTO doc_fts(doc_fts) VALUES ('rebuild');    -- rebuild from content table
      PRAGMA integrity_check;                             -- also checks FTS structures
      ```
      
      ---
      
      ## The trigram tokenizer
      
      The answer to substring search. It indexes every 3-character sequence, so both `MATCH` and —
      uniquely — `LIKE '%…%'` become index-backed.
      
      ```sql
      CREATE VIRTUAL TABLE org_fts USING fts5(name, tokenize = 'trigram');
      INSERT INTO org_fts(name) SELECT DISTINCT org FROM q_product;
      
      SELECT * FROM org_fts WHERE org_fts MATCH 'acme';       -- substring, index-backed
      SELECT * FROM org_fts WHERE name LIKE '%acme%';         -- ALSO index-backed on a trigram table
      ```
      
      | Property | Detail |
      |---|---|
      | Minimum search length | 3 characters — shorter patterns fall back to a scan |
      | Case handling | Case-insensitive by default (`case_sensitive 1` to change) |
      | Index size | Large — roughly one entry per character position |
      | Write cost | Higher than `unicode61`; not for high-churn columns |
      
      **Decision rule.** For a leading-wildcard predicate:
      
      - Occasional query, wide table → covering index (see
        [`query-performance.md`](query-performance.md#covering-indexes)) — cheap, no new object.
      - Frequent query, or the scan is genuinely too big → trigram FTS5 — eliminates the scan,
        costs index size and write throughput.
      
      The covering index makes the scan cheap; trigram makes the scan disappear.
      
      ---
      
      ## External-content FTS tables
      
      By default FTS5 stores its own copy of the text. An **external-content** table indexes rows
      that live in an ordinary table, halving storage.
      
      ```sql
      CREATE TABLE doc (id INTEGER PRIMARY KEY, title TEXT, body TEXT) STRICT;
      
      CREATE VIRTUAL TABLE doc_fts USING fts5(
          title, body,
          content = 'doc',
          content_rowid = 'id'
      );
      
      -- You must maintain the index yourself, via triggers
      CREATE TRIGGER doc_ai AFTER INSERT ON doc BEGIN
          INSERT INTO doc_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
      END;
      CREATE TRIGGER doc_ad AFTER DELETE ON doc BEGIN
          INSERT INTO doc_fts(doc_fts, rowid, title, body) VALUES ('delete', old.id, old.title, old.body);
      END;
      CREATE TRIGGER doc_au AFTER UPDATE ON doc BEGIN
          INSERT INTO doc_fts(doc_fts, rowid, title, body) VALUES ('delete', old.id, old.title, old.body);
          INSERT INTO doc_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
      END;
      ```
      
      **The delete trigger's odd shape is mandatory**: FTS5 needs the *old values* to remove the
      right index entries, and it cannot read them from the content table (they're already gone).
      Omitting the old values corrupts the index silently — searches start returning deleted rows.
      
      `contentless` tables (`content=''`) store no text at all: smallest, but `snippet()`/
      `highlight()` and updates are unavailable. Use for pure "which rowids match" lookups.
      
      ---
      
      ## JSON functions
      
      ```sql
      CREATE TABLE event (id INTEGER PRIMARY KEY, payload TEXT NOT NULL) STRICT;
      
      -- Extract
      SELECT json_extract(payload, '$.user.id')  FROM event;   -- SQL value
      SELECT payload -> '$.user'                 FROM event;   -- JSON representation
      SELECT payload ->> '$.user.id'             FROM event;   -- SQL value (3.38+, preferred)
      
      -- Modify (returns a new document; does not mutate in place)
      UPDATE event SET payload = json_set(payload, '$.status', 'done')    WHERE id = ?;
      UPDATE event SET payload = json_remove(payload, '$.tmp')            WHERE id = ?;
      UPDATE event SET payload = json_patch(payload, '{"a":1,"b":null}')  WHERE id = ?;  -- RFC 7386
      
      -- Build
      SELECT json_object('id', id, 'kind', payload ->> '$.kind') FROM event;
      SELECT json_group_array(json_object('id', id))             FROM event;
      
      -- Inspect
      SELECT json_valid(payload), json_type(payload, '$.tags'), json_array_length(payload, '$.tags')
      FROM event;
      ```
      
      ### Expanding arrays and objects
      
      `json_each` and `json_tree` are table-valued functions — the workhorses of JSON querying.
      
      ```sql
      -- One row per array element
      SELECT e.id, t.value AS tag
      FROM event e, json_each(e.payload, '$.tags') t
      WHERE t.value = 'urgent';
      
      -- Turn a bound JSON array into a joinable set (one parameter, any length —
      -- the standard way past a host's bound-parameter cap)
      SELECT p.* FROM product p JOIN json_each(?) j ON j.value = p.id;
      
      -- Recursive walk of the whole document
      SELECT fullkey, value FROM event, json_tree(event.payload) WHERE atom IS NOT NULL;
      ```
      
      ### Indexing JSON
      
      `json_extract` on a plain column is **never** indexable. Use a generated column or an
      expression index:
      
      ```sql
      ALTER TABLE event ADD COLUMN kind TEXT
          GENERATED ALWAYS AS (payload ->> '$.kind') VIRTUAL;
      CREATE INDEX event_kind ON event(kind);
      
      -- or, without changing the table shape
      CREATE INDEX event_kind_expr ON event(json_extract(payload, '$.kind'));
      ```
      
      The query must use the **same expression** as the index, syntactically. An index on
      `json_extract(payload,'$.kind')` is not used by a query written with `payload ->> '$.kind'`.
      
      ---
      
      ## JSONB
      
      SQLite 3.45+ adds a binary JSON representation. Every `json_*` function has a `jsonb_*`
      counterpart that returns the binary form.
      
      ```sql
      CREATE TABLE doc (id INTEGER PRIMARY KEY, body BLOB) STRICT;
      INSERT INTO doc (body) VALUES (jsonb('{"a":1,"b":[2,3]}'));
      
      SELECT body ->> '$.a' FROM doc;        -- operators work directly on JSONB
      SELECT json(body) FROM doc;            -- back to text for display/export
      ```
      
      | | JSON (TEXT) | JSONB (BLOB) |
      |---|---|---|
      | Parse cost per read | Full re-parse | None — already parsed |
      | Storage | Slightly smaller for simple docs | Usually smaller for nested docs |
      | Human-readable in a CLI dump | Yes | No — wrap in `json()` |
      | Portability | Universal | SQLite-internal format; **not** PostgreSQL's JSONB |
      
      Use JSONB when documents are read and traversed frequently. Keep TEXT when the column is
      mostly passed through to an application that parses it anyway, or when tooling needs to read
      the file directly. The format is a SQLite implementation detail — do not send it over a wire
      or store it expecting another system to read it.
      
      ---
      
      ## R-tree
      
      A virtual table for bounding-box and interval overlap queries. Compile-time module
      (`ENABLE_RTREE`), enabled in most builds.
      
      ```sql
      CREATE VIRTUAL TABLE place_idx USING rtree(
          id,                  -- INTEGER primary key, joins to the real table
          min_lon, max_lon,
          min_lat, max_lat
      );
      
      INSERT INTO place_idx VALUES (1, 151.20, 151.22, -33.87, -33.85);
      
      -- Bounding-box query: fast, index-backed
      SELECT p.name FROM place p JOIN place_idx i ON p.id = i.id
      WHERE i.min_lon <= 151.25 AND i.max_lon >= 151.15
        AND i.min_lat <= -33.80 AND i.max_lat >= -33.90;
      ```
      
      R-tree gives you the **coarse filter**; apply exact geometry or distance maths afterwards on
      the small result set. It also works for one-dimensional intervals (time ranges, version
      ranges) by using a single min/max pair.
      
      For real geospatial work (projections, true distance, polygon operations) you need
      SpatiaLite, a separate loadable extension.
      
      ---
      
      ## Window functions
      
      SQLite 3.25+, with essentially PostgreSQL-compatible syntax.
      
      ```sql
      -- Running total
      SELECT id, amount, sum(amount) OVER (ORDER BY created_at) AS running
      FROM txn;
      
      -- Rank within a group
      SELECT org, name, price,
             row_number() OVER (PARTITION BY org ORDER BY price DESC) AS rn,
             rank()       OVER (PARTITION BY org ORDER BY price DESC) AS rnk
      FROM product;
      
      -- Compare to the previous row (gap detection)
      SELECT id, created_at,
             lag(created_at) OVER (ORDER BY created_at) AS prev,
             julianday(created_at) - julianday(lag(created_at) OVER (ORDER BY created_at)) AS gap_days
      FROM event;
      
      -- Top-N per group: filter on the window result via a CTE
      WITH ranked AS (
          SELECT *, row_number() OVER (PARTITION BY org ORDER BY price DESC) AS rn FROM product
      )
      SELECT * FROM ranked WHERE rn <= 3;
      ```
      
      Available: `row_number`, `rank`, `dense_rank`, `percent_rank`, `cume_dist`, `ntile`, `lag`,
      `lead`, `first_value`, `last_value`, `nth_value`, plus every aggregate used as a window
      function. Frame specifications (`ROWS BETWEEN … `, `RANGE BETWEEN …`, `GROUPS`) are
      supported.
      
      **Performance:** an `OVER (ORDER BY x)` clause needs the rows in `x` order — an index on `x`
      avoids a temp B-tree, which will show up in `EXPLAIN QUERY PLAN` if it's missing.
      
      ---
      
      ## Upsert
      
      ```sql
      -- Insert or update
      INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?)
      ON CONFLICT(key) DO UPDATE SET
          value = excluded.value,
          expires_at = excluded.expires_at;
      
      -- Conditional update (only overwrite if newer)
      INSERT INTO cache (key, value, updated_at) VALUES (?, ?, ?)
      ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
      WHERE excluded.updated_at > cache.updated_at;
      
      -- Insert or ignore
      INSERT INTO seen (hash) VALUES (?) ON CONFLICT DO NOTHING;
      
      -- Counter increment
      INSERT INTO counter (name, n) VALUES (?, 1)
      ON CONFLICT(name) DO UPDATE SET n = n + 1;
      ```
      
      `excluded.*` refers to the row that *would* have been inserted. The conflict target
      (`ON CONFLICT(key)`) must match a `UNIQUE` constraint or index.
      
      The older `INSERT OR REPLACE` is **not** the same thing: it deletes and re-inserts, so it
      fires `ON DELETE CASCADE`, drops columns you didn't supply back to their defaults, and
      allocates a new rowid. Prefer `ON CONFLICT DO UPDATE` unless you specifically want the
      delete semantics.
      
      ---
      
      ## RETURNING
      
      SQLite 3.35+. Read back the rows a write touched, in one statement.
      
      ```sql
      INSERT INTO product (sku, price) VALUES (?, ?) RETURNING id, created_at;
      UPDATE product SET price = price * 1.1 WHERE org = ? RETURNING id, price;
      DELETE FROM session WHERE expires_at < datetime('now') RETURNING token;
      ```
      
      The highest-value use is an **atomic claim**, which without `RETURNING` needs a
      select-then-update race:
      
      ```sql
      UPDATE job_queue
      SET status = 'running', started_at = datetime('now')
      WHERE id = (
          SELECT id FROM job_queue WHERE status = 'pending'
          ORDER BY priority DESC, created_at LIMIT 1
      )
      RETURNING *;
      ```
      
      Note the row order of a `RETURNING` result is undefined, and the rows are produced *before*
      triggers and foreign-key actions complete — don't rely on either.
      
      ---
      
      ## Other useful modules
      
      | Module | Purpose |
      |---|---|
      | `json_each` / `json_tree` | Table-valued JSON expansion (above) |
      | `generate_series(a,b,step)` | Row generator — calendars, gap-filling, test data |
      | `dbstat` | Per-table/index page usage — the honest answer to "what is taking up space" |
      | `pragma_*` functions | `pragma_table_info('t')`, `pragma_index_list('t')` as queryable tables |
      | `carray` | Bind a C array as a table (available in some builds/CLI) |
      | `sqlite_dbpage` | Raw page access — recovery tooling only |
      
      ```sql
      -- Gap-fill a daily report with generate_series
      SELECT d.value AS day, coalesce(count(e.id), 0) AS n
      FROM generate_series(
              (SELECT min(cast(strftime('%s', created_at) AS INTEGER)) FROM event),
              (SELECT max(cast(strftime('%s', created_at) AS INTEGER)) FROM event),
              86400) d
      LEFT JOIN event e ON date(e.created_at) = date(d.value, 'unixepoch')
      GROUP BY 1 ORDER BY 1;
      ```
      
      ---
      
      ## See also
      
      - [`query-performance.md`](query-performance.md) — when trigram FTS beats a covering index
      - [`schema-design.md`](schema-design.md) — generated columns for indexable JSON fields
      - [`schema-patterns.md`](schema-patterns.md) — the FTS-backed document table recipe
      - [`d1-edge.md`](d1-edge.md) — why FTS5 availability on D1 is unconfirmed
      
    • hosts.md 12.8 KB
      # SQLite Hosts
      
      One engine, many drivers. The SQL, the planner, and the pragmas are identical everywhere —
      this file covers the **driver surface** and the traps that differ per host.
      
      ## Contents
      
      - [The portable connection baseline](#the-portable-connection-baseline)
      - [sqlite3 CLI](#sqlite3-cli)
      - [Python: sqlite3](#python-sqlite3)
      - [Python: aiosqlite](#python-aiosqlite)
      - [node:sqlite](#nodesqlite)
      - [better-sqlite3](#better-sqlite3)
      - [bun:sqlite](#bunsqlite)
      - [Cloudflare D1](#cloudflare-d1)
      - [libSQL / Turso](#libsql--turso)
      - [Host comparison](#host-comparison)
      
      ---
      
      ## The portable connection baseline
      
      Every host that gives you a real connection should apply the same four pragmas on **every
      connection** (only `journal_mode` is persistent — the rest are per-connection and reset each
      time). Rationale in [`concurrency-durability.md`](concurrency-durability.md).
      
      ```sql
      PRAGMA journal_mode = WAL;      -- once per database (persistent)
      PRAGMA busy_timeout = 5000;     -- every connection
      PRAGMA foreign_keys = ON;       -- every connection
      PRAGMA synchronous = NORMAL;    -- every connection
      ```
      
      The single most common bug across all hosts below is setting these once at startup and
      missing the connections a pool or framework creates later.
      
      ---
      
      ## sqlite3 CLI
      
      ```bash
      sqlite3 app.db                     # interactive
      sqlite3 app.db 'SELECT 1;'         # one-shot
      sqlite3 -readonly app.db 'SELECT 1;'
      ```
      
      | Dot command | Purpose |
      |---|---|
      | `.tables` / `.schema t` / `.indexes t` | Structure |
      | `.timer on` | **Engine-reported timing** — the only honest CLI measurement |
      | `.stats on` | VM steps, sorts, full-scan steps per statement |
      | `.mode box\|json\|csv\|markdown` | Output format (`box` for reading, `json` for piping) |
      | `.headers on` | Column names |
      | `.once file` / `.output file` | Redirect the next / all results |
      | `.import --csv data.csv t` | Bulk load |
      | `.dump` / `.read f.sql` | Text backup / run a script |
      | `.expert` | Suggests indexes for a statement (build-dependent) |
      | `.eqp on` | Auto-print the query plan for every statement |
      
      ```bash
      # Export
      sqlite3 app.db -header -csv 'SELECT * FROM product;' > product.csv
      sqlite3 app.db -json 'SELECT * FROM product LIMIT 5;' | jq '.[0]'
      
      # Pragmas in a one-shot invocation (they apply to that connection only)
      sqlite3 app.db 'PRAGMA foreign_keys=ON; DELETE FROM author WHERE id=1;'
      ```
      
      **Trap:** the CLI does not enable foreign keys for you. A manual `DELETE` from the CLI can
      leave orphans in a database whose application always sets the pragma.
      
      ---
      
      ## Python: sqlite3
      
      Standard library. The main traps are transaction handling and thread affinity.
      
      ```python
      import sqlite3
      
      def connect(path: str) -> sqlite3.Connection:
          conn = sqlite3.connect(path, timeout=5.0, isolation_level=None)
          conn.row_factory = sqlite3.Row          # dict-like access by column name
          conn.execute("PRAGMA journal_mode = WAL")
          conn.execute("PRAGMA busy_timeout = 5000")
          conn.execute("PRAGMA foreign_keys = ON")
          conn.execute("PRAGMA synchronous = NORMAL")
          return conn
      ```
      
      | Trap | Detail |
      |---|---|
      | Implicit transactions | By default the module opens a transaction before DML and commits on `commit()`. `isolation_level=None` turns this off so **you** write `BEGIN IMMEDIATE` explicitly — strongly preferred (see [the upgrade deadlock](concurrency-durability.md#transaction-modes-and-the-upgrade-deadlock)) |
      | DDL and autocommit | Older Pythons implicitly committed before DDL; explicit control avoids version-dependent surprises |
      | `check_same_thread=False` | Lets a connection cross threads, but does **not** make it thread-safe — you must serialise access yourself. One connection per thread is the safe pattern |
      | `timeout=` | This is `busy_timeout` in **seconds**, set at connect time |
      | `executemany` | Use for bulk inserts; wrap in one explicit transaction for the real win |
      | `detect_types` | Legacy converters; prefer explicit conversion in your own code |
      | Python 3.12+ | Warns on deprecated default adapters for `date`/`datetime` — store ISO text yourself |
      
      ```python
      # Explicit transaction with retry-friendly semantics
      conn.execute("BEGIN IMMEDIATE")
      try:
          conn.executemany("INSERT INTO event (kind, payload) VALUES (?, ?)", rows)
          conn.execute("COMMIT")
      except Exception:
          conn.execute("ROLLBACK")
          raise
      ```
      
      ```python
      # Read the plan from Python — no external binary needed
      for row in conn.execute("EXPLAIN QUERY PLAN SELECT * FROM event WHERE kind = ?", ("login",)):
          print(row["detail"])
      ```
      
      `scripts/eqp-triage.py` in this skill uses exactly this path, which is why it needs no
      `sqlite3` binary on PATH.
      
      ---
      
      ## Python: aiosqlite
      
      A thread-pool wrapper around `sqlite3` with an async API. It does **not** make SQLite
      concurrent — there is still one writer, and each connection still occupies a worker thread.
      
      ```python
      import aiosqlite
      
      async def connect(path: str) -> aiosqlite.Connection:
          conn = await aiosqlite.connect(path, isolation_level=None)
          conn.row_factory = aiosqlite.Row
          await conn.execute("PRAGMA busy_timeout = 5000")
          await conn.execute("PRAGMA foreign_keys = ON")
          return conn
      ```
      
      | Consideration | Guidance |
      |---|---|
      | When it helps | Keeps an async event loop unblocked during disk I/O |
      | When it doesn't | CPU-bound queries; write-heavy workloads (still serialised) |
      | Pooling | A small pool of read connections + **one** dedicated write connection is the pattern that works |
      | Long transactions | Especially harmful here — an awaited call inside a transaction can hold a lock across arbitrary scheduling delays |
      
      **Deep dive:** [`async-patterns.md`](async-patterns.md) — async CRUD, batching, pooling.
      
      ---
      
      ## node:sqlite
      
      Built into modern Node, synchronous, zero dependencies.
      
      ```js
      import { DatabaseSync } from "node:sqlite";
      
      const db = new DatabaseSync("app.db");
      db.exec("PRAGMA journal_mode = WAL");
      db.exec("PRAGMA busy_timeout = 5000");
      db.exec("PRAGMA foreign_keys = ON");
      
      const insert = db.prepare("INSERT INTO event (kind, payload) VALUES (?, ?)");
      insert.run("login", JSON.stringify({ user: 1 }));
      
      const rows = db.prepare("SELECT * FROM event WHERE kind = ?").all("login");
      const one  = db.prepare("SELECT * FROM event WHERE id = ?").get(1);
      ```
      
      | Note | Detail |
      |---|---|
      | Synchronous by design | Blocks the event loop — fine for fast statements, bad for long scans |
      | API stability | Newer than the alternatives; check your Node version's docs before relying on a specific method |
      | No native async | For long-running work use a worker thread, not a promise wrapper |
      | Named parameters | Supported (`@name`/`:name`), style varies by version — verify against your Node |
      
      Use it when you want no native build step and no dependency. Use better-sqlite3 when you
      want the most mature Node API and the broadest feature surface.
      
      ---
      
      ## better-sqlite3
      
      The mature Node option. Synchronous, native addon, fastest of the Node choices.
      
      ```js
      import Database from "better-sqlite3";
      
      const db = new Database("app.db");
      db.pragma("journal_mode = WAL");
      db.pragma("busy_timeout = 5000");
      db.pragma("foreign_keys = ON");
      
      // Prepared statements are the unit of reuse - prepare ONCE, at module scope
      const findByKind = db.prepare("SELECT * FROM event WHERE kind = ?");
      const rows = findByKind.all("login");
      
      // Transactions: the wrapper handles BEGIN/COMMIT/ROLLBACK
      const insertMany = db.transaction((events) => {
        for (const e of events) insertOne.run(e.kind, e.payload);
      });
      insertMany(events);          // one transaction, one fsync
      ```
      
      | Feature | Note |
      |---|---|
      | `.transaction(fn)` | Wraps in `BEGIN`/`COMMIT`; use `.immediate(...)` for write transactions |
      | Statement reuse | Re-preparing in a loop is the #1 performance mistake with this driver |
      | `.iterate()` | Streams rows without materialising the whole result |
      | `.pluck()` / `.raw()` | Single-column / array-row modes; avoid object allocation in hot loops |
      | Native build | Needs a prebuilt binary or a toolchain — the cost of admission |
      | WASM alternatives | `sql.js`, `wa-sqlite` for browsers/edge — different performance envelope entirely |
      
      Synchronous is a **feature** here: it eliminates a class of race conditions, and SQLite reads
      from page cache are fast enough that the event-loop cost is usually negligible. Measure
      before assuming you need async.
      
      ---
      
      ## bun:sqlite
      
      Built into Bun. API is close to better-sqlite3 but **not identical** — porting code between
      them needs review, not just a find-and-replace on the import.
      
      ```js
      import { Database } from "bun:sqlite";
      
      const db = new Database("app.db");
      db.run("PRAGMA journal_mode = WAL");
      db.run("PRAGMA busy_timeout = 5000");
      db.run("PRAGMA foreign_keys = ON");
      
      const q = db.query("SELECT * FROM event WHERE kind = ?");
      const rows = q.all("login");
      const one  = q.get("login");
      
      const tx = db.transaction((rows) => { for (const r of rows) ins.run(r.kind, r.payload); });
      tx(rows);
      ```
      
      Differences worth checking when porting: `query()` caches prepared statements where
      better-sqlite3 expects you to hold the statement yourself; `.run()`/`.exec()` semantics
      differ; class-mapping (`.as(Class)`) is Bun-specific.
      
      ---
      
      ## Cloudflare D1
      
      Managed, accessed over HTTP/RPC. No file, no `PRAGMA` surface, billed on rows read.
      
      ```js
      export default {
        async fetch(request, env) {
          const { results, meta } = await env.DB
            .prepare("SELECT id, name FROM product WHERE org = ?")
            .bind("acme")
            .all();
          // meta.rows_read and meta.timings.sql_duration_ms are the numbers that matter
          return Response.json({ results, cost: meta.rows_read });
        },
      };
      ```
      
      | API | Use |
      |---|---|
      | `.all()` | All rows plus `meta` |
      | `.first()` | First row, or a single column with `.first("col")` |
      | `.run()` | Writes; returns `meta` only |
      | `.raw()` | Arrays instead of objects — cheaper for wide results |
      | `env.DB.batch([...])` | Multiple statements, one round trip, implicit transaction |
      
      | Constraint | Detail |
      |---|---|
      | Bound parameters | Capped at **100 per statement** — chunk, don't inline literals |
      | Connection pragmas | Not available; the platform owns journal mode, durability, timeouts |
      | Introspection | `sqlite_version()` and `pragma_module_list` refused with `SQLITE_AUTH` |
      | Interactive transactions | Not supported — use `batch()` |
      | Billing | Rows read, not time |
      
      **Deep dive:** [`d1-edge.md`](d1-edge.md).
      
      ---
      
      ## libSQL / Turso
      
      A SQLite fork plus a hosted service. Three connection modes with very different profiles:
      
      ```js
      import { createClient } from "@libsql/client";
      
      // 1. Remote server
      const remote = createClient({ url: "libsql://db.turso.io", authToken: TOKEN });
      
      // 2. Local file (plain SQLite semantics)
      const local = createClient({ url: "file:local.db" });
      
      // 3. Embedded replica: local reads, remote writes, background sync
      const replica = createClient({
        url: "file:replica.db",
        syncUrl: "libsql://db.turso.io",
        authToken: TOKEN,
      });
      await replica.sync();     // pull latest before a read that must be fresh
      ```
      
      | Consideration | Note |
      |---|---|
      | Embedded replica staleness | A read right after a write may not see it — call `sync()` or use the client's read-your-writes support |
      | Mode choice | Remote = simple, network-latency per query. Replica = fast reads, sync complexity. Pick deliberately |
      | Extensions | libSQL adds features beyond stock SQLite (e.g. native vector types in recent versions) — verify against **your** server version; this moves |
      | Portability | Keep SQL stock-SQLite unless you have a concrete reason not to |
      | Billing | Reads-oriented, like D1 — the rows-read discipline transfers |
      
      ---
      
      ## Host comparison
      
      | | Real file | `PRAGMA` control | Sync/async | Transactions | Billed on reads |
      |---|---|---|---|---|---|
      | `sqlite3` CLI | Yes | Full | Sync | Full | No |
      | Python `sqlite3` | Yes | Full | Sync | Full | No |
      | Python `aiosqlite` | Yes | Full | Async (thread-backed) | Full | No |
      | `node:sqlite` | Yes | Full | Sync | Full | No |
      | better-sqlite3 | Yes | Full | Sync | Full | No |
      | `bun:sqlite` | Yes | Full | Sync | Full | No |
      | Cloudflare D1 | No | None | Async | `batch()` only | **Yes** |
      | libSQL / Turso | Depends on mode | Partial | Async | Full (server mode) | **Yes** |
      
      ### Choosing
      
      | Situation | Host |
      |---|---|
      | Ad-hoc investigation, migrations, exports | `sqlite3` CLI |
      | Python service, sync | stdlib `sqlite3` |
      | Python service, async framework | `aiosqlite` (one write connection + a read pool) |
      | Node, no native build allowed | `node:sqlite` |
      | Node, maximum maturity and speed | better-sqlite3 |
      | Bun runtime | `bun:sqlite` |
      | Cloudflare Workers | D1 |
      | Multi-region reads, embedded replicas | libSQL / Turso |
      | Many concurrent writers, large dataset | **Not SQLite** — see `postgres-ops` |
      
      ---
      
      ## See also
      
      - [`concurrency-durability.md`](concurrency-durability.md) — why the pragma baseline is what it is
      - [`d1-edge.md`](d1-edge.md) — the managed-engine chapter in full
      - [`async-patterns.md`](async-patterns.md) — Python async depth
      - [`testing.md`](testing.md) — per-host test database setup
      
    • migration-patterns.md 13.8 KB
      # SQLite Migrations
      
      Changing a schema that already holds data. Engine-agnostic SQL, with runner examples per
      host. For designing a schema in the first place see [`schema-design.md`](schema-design.md).
      
      ## Contents
      
      - [What ALTER TABLE can and cannot do](#what-alter-table-can-and-cannot-do)
      - [The 12-step recreate procedure](#the-12-step-recreate-procedure)
      - [Versioned migrations with user_version](#versioned-migrations-with-user_version)
      - [A named-migration runner](#a-named-migration-runner)
      - [Runners per host](#runners-per-host)
      - [Migrating large tables](#migrating-large-tables)
      - [Rollback strategy](#rollback-strategy)
      - [Migration review checklist](#migration-review-checklist)
      
      ---
      
      ## What ALTER TABLE can and cannot do
      
      | Operation | Supported | Since |
      |---|---|---|
      | `ADD COLUMN` | Yes | Always |
      | `RENAME TO` (table) | Yes | Always |
      | `RENAME COLUMN` | Yes | 3.25 |
      | `DROP COLUMN` | Yes, **with conditions** | 3.35 |
      | Change a column type | **No** | — |
      | Add/remove `NOT NULL`, `CHECK`, `DEFAULT` | **No** | — |
      | Add/remove a foreign key | **No** | — |
      | Reorder columns | **No** | — |
      | Add a `PRIMARY KEY` / `UNIQUE` | **No** (`CREATE UNIQUE INDEX` is the workaround) | — |
      
      ```sql
      ALTER TABLE product ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
      ALTER TABLE product RENAME COLUMN sku TO product_code;
      ALTER TABLE product DROP COLUMN legacy_flag;
      ```
      
      **`ADD COLUMN` constraints:** a `NOT NULL` column must have a non-null `DEFAULT` (existing
      rows need a value), the default cannot be from the `CURRENT_TIME` family, and it cannot be
      `PRIMARY KEY` or `UNIQUE`. Adding a column is O(1) — SQLite records it in the schema and
      materialises the default on read.
      
      **`DROP COLUMN` refuses** when the column is a primary key, has a `UNIQUE` constraint, or is
      referenced by an index, view, trigger, `CHECK` constraint, or generated column. Drop the
      dependent object first, or use the recreate procedure. Unlike `ADD`, it rewrites every row.
      
      Everything else needs the recreate procedure below.
      
      ---
      
      ## The 12-step recreate procedure
      
      The official sequence for any change `ALTER TABLE` cannot express. **Order matters** — each
      step prevents a specific failure.
      
      ```sql
      -- 1. If foreign keys are enabled, note that and turn them OFF.
      --    Must be OUTSIDE a transaction: PRAGMA foreign_keys is a no-op inside one.
      PRAGMA foreign_keys = OFF;
      
      -- 2. Start a transaction.
      BEGIN IMMEDIATE;
      
      -- 3. Record every index, trigger and view attached to the table - you will recreate them:
      --    SELECT type, name, sql FROM sqlite_master WHERE tbl_name = 'product';
      
      -- 4. Create the new table under a temporary name, with the desired shape.
      CREATE TABLE product_new (
          id         INTEGER PRIMARY KEY,
          org        TEXT NOT NULL,
          price      INTEGER NOT NULL,                   -- changed: REAL -> INTEGER (minor units)
          status     TEXT NOT NULL DEFAULT 'active'
                         CHECK (status IN ('active','archived')),  -- added constraint
          created_at TEXT NOT NULL
          -- dropped: legacy_flag
      ) STRICT;
      
      -- 5. Copy the data, transforming as needed.
      INSERT INTO product_new (id, org, price, status, created_at)
      SELECT id, org, CAST(round(price * 100) AS INTEGER),
             coalesce(status, 'active'), created_at
      FROM product;
      
      -- 6. Drop the old table.
      DROP TABLE product;
      
      -- 7. Rename the new table into place.
      ALTER TABLE product_new RENAME TO product;
      
      -- 8. Recreate the indexes and triggers recorded in step 3.
      CREATE INDEX product_org ON product(org);
      
      -- 9. Recreate any VIEW that referenced the old table shape.
      
      -- 10. If foreign keys were on, verify nothing broke - while you can still ROLLBACK.
      PRAGMA foreign_key_check;
      
      -- 11. Commit.
      COMMIT;
      
      -- 12. Restore the foreign-key setting (outside the transaction).
      PRAGMA foreign_keys = ON;
      ```
      
      ### Why each guard is there
      
      | Step | Guards against |
      |---|---|
      | FK off during the rebuild (1, 12) | `DROP TABLE` firing `ON DELETE CASCADE` and **deleting child rows** — the destructive failure this procedure exists to prevent |
      | FK toggled outside the transaction | `PRAGMA foreign_keys` is silently ignored inside a transaction |
      | Recording indexes/triggers first (3) | `DROP TABLE` takes them with it; they are gone before you notice |
      | `foreign_key_check` before `COMMIT` (10) | Catching orphans while rollback is still possible |
      | `BEGIN IMMEDIATE` (2) | A mid-migration upgrade deadlock — see [`concurrency-durability.md`](concurrency-durability.md) |
      
      **Legacy ordering to avoid:** the shorter "rename old, create new, copy, drop old" sequence
      (`ALTER TABLE t RENAME TO t_old` first) interacts badly with `legacy_alter_table` settings —
      references from other objects get rewritten to point at `t_old`. Use the order above.
      
      ---
      
      ## Versioned migrations with user_version
      
      SQLite reserves a 32-bit integer in the file header for exactly this. It costs no table and
      cannot drift from the file it describes.
      
      ```sql
      PRAGMA user_version;        -- read (0 on a fresh database)
      PRAGMA user_version = 3;    -- write (cannot be parameterised - interpolate an integer)
      ```
      
      ```sql
      -- migrations/001_initial.sql
      BEGIN IMMEDIATE;
      CREATE TABLE product (
          id  INTEGER PRIMARY KEY,
          org TEXT NOT NULL,
          sku TEXT NOT NULL UNIQUE
      ) STRICT;
      CREATE INDEX product_org ON product(org);
      PRAGMA user_version = 1;
      COMMIT;
      ```
      
      ```bash
      sqlite3 app.db < migrations/001_initial.sql
      sqlite3 app.db 'PRAGMA user_version;'    # -> 1
      ```
      
      | Approach | Pros | Cons |
      |---|---|---|
      | `PRAGMA user_version` | No extra table; atomic with the schema; trivially readable | Integer only — no names, timestamps, or audit trail |
      | A `schema_migrations` table | Names, applied-at timestamps, per-migration audit | An extra table; created by migration zero |
      
      Use `user_version` for embedded/single-app databases; use a table when several people or
      services need to see what ran and when.
      
      ---
      
      ## A named-migration runner
      
      ```sql
      CREATE TABLE IF NOT EXISTS schema_migrations (
          name       TEXT PRIMARY KEY,
          applied_at TEXT NOT NULL DEFAULT (datetime('now')),
          checksum   TEXT
      ) STRICT;
      ```
      
      Recording a **checksum** catches the nastiest migration bug: someone editing a migration
      that has already run somewhere, so environments silently diverge. Compare on startup and
      refuse to proceed on a mismatch.
      
      ```python
      import hashlib, pathlib, sqlite3
      
      def migrate(conn: sqlite3.Connection, directory: str) -> list[str]:
          """Apply pending .sql migrations in filename order. Returns names applied."""
          conn.execute("""
              CREATE TABLE IF NOT EXISTS schema_migrations (
                  name TEXT PRIMARY KEY,
                  applied_at TEXT NOT NULL DEFAULT (datetime('now')),
                  checksum TEXT
              ) STRICT
          """)
          applied = {r[0]: r[1] for r in
                     conn.execute("SELECT name, checksum FROM schema_migrations")}
      
          done = []
          for path in sorted(pathlib.Path(directory).glob("*.sql")):
              sql = path.read_text(encoding="utf-8")
              checksum = hashlib.sha256(sql.encode()).hexdigest()
      
              if path.name in applied:
                  if applied[path.name] != checksum:
                      raise RuntimeError(
                          f"{path.name} changed after it was applied "
                          f"(recorded {applied[path.name][:12]}, now {checksum[:12]}). "
                          "Add a new migration instead of editing an applied one.")
                  continue
      
              # Each file supplies its own BEGIN/COMMIT so a failure rolls that file back cleanly.
              conn.executescript(sql)
              conn.execute("INSERT INTO schema_migrations (name, checksum) VALUES (?, ?)",
                           (path.name, checksum))
              done.append(path.name)
          return done
      ```
      
      **Never edit an applied migration.** Add a new one. The checksum turns that convention into
      an enforced invariant.
      
      ---
      
      ## Runners per host
      
      ```js
      // better-sqlite3 / node:sqlite - synchronous, so the loop reads naturally
      import Database from "better-sqlite3";
      import { readFileSync, readdirSync } from "node:fs";
      
      const db = new Database("app.db");
      db.pragma("foreign_keys = ON");
      db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
                 name TEXT PRIMARY KEY,
                 applied_at TEXT NOT NULL DEFAULT (datetime('now'))) STRICT`);
      
      const applied = new Set(db.prepare("SELECT name FROM schema_migrations").pluck().all());
      const record  = db.prepare("INSERT INTO schema_migrations (name) VALUES (?)");
      
      for (const file of readdirSync("migrations").filter(f => f.endsWith(".sql")).sort()) {
        if (applied.has(file)) continue;
        db.exec(readFileSync(`migrations/${file}`, "utf8"));   // file supplies BEGIN/COMMIT
        record.run(file);
      }
      ```
      
      ```bash
      # Cloudflare D1 - migrations are a first-class wrangler feature
      wrangler d1 migrations create atdw-mirror add_covering_index
      wrangler d1 migrations list   atdw-mirror --remote
      wrangler d1 migrations apply  atdw-mirror --local     # test locally first
      wrangler d1 migrations apply  atdw-mirror --remote    # MAINTAINER-GATED: this is a deploy
      ```
      
      D1 tracks applied migrations in its own table and applies files in name order. Three
      differences from a local database: statements are subject to platform limits (100 KB per
      statement, 100 bound parameters — see [`d1-edge.md`](d1-edge.md)); a remote apply is a
      **production deploy**, so a working session writes the migration file and stops; and
      [Time Travel](d1-edge.md#time-travel) gives you a 30-day undo that local SQLite does not,
      so take a bookmark before applying.
      
      One more remote-apply trap: `--remote` can **report a timeout while the migration actually
      landed**, and blindly re-running is the mistake — verify the schema state read-only first
      ([`d1-production-patterns.md`](d1-production-patterns.md#migration-apply-can-time-out-yet-still-land)).
      
      ```python
      # aiosqlite - same logic, awaited. Run migrations at startup, before serving.
      async def migrate(conn, directory: str) -> None:
          await conn.execute("""CREATE TABLE IF NOT EXISTS schema_migrations (
                                  name TEXT PRIMARY KEY,
                                  applied_at TEXT NOT NULL DEFAULT (datetime('now'))) STRICT""")
          cur = await conn.execute("SELECT name FROM schema_migrations")
          applied = {r[0] for r in await cur.fetchall()}
          for path in sorted(pathlib.Path(directory).glob("*.sql")):
              if path.name in applied:
                  continue
              await conn.executescript(path.read_text(encoding="utf-8"))
              await conn.execute("INSERT INTO schema_migrations (name) VALUES (?)", (path.name,))
              await conn.commit()
      ```
      
      ---
      
      ## Migrating large tables
      
      The recreate procedure holds a write lock throughout and needs room for a second copy of the
      table. On a large table that is real downtime.
      
      | Technique | Detail |
      |---|---|
      | Prefer `ADD COLUMN` | O(1) and lock-free — model changes as additive whenever possible |
      | Backfill in batches | New nullable column → backfill in ~10k-row chunks in separate transactions → add the constraint later via a recreate |
      | Expand/contract | Add the new column, dual-write from the app, backfill, switch reads, drop the old column in a later release |
      | Measure first | `SELECT count(*)`, and confirm free disk ≥ 2x the table size |
      | Schedule it | A recreate on a multi-GB table is minutes, not seconds |
      
      ```sql
      -- Batched backfill: bounded transactions, resumable, no long lock
      UPDATE product SET price_cents = CAST(round(price * 100) AS INTEGER)
      WHERE price_cents IS NULL
        AND id IN (SELECT id FROM product WHERE price_cents IS NULL LIMIT 10000);
      -- repeat until 0 rows changed
      ```
      
      On D1 this pattern is mandatory rather than optional: a statement that scans too much is
      killed by the 30-second query-duration limit or the isolate's CPU/memory limits.
      
      ---
      
      ## Rollback strategy
      
      Down-migrations are frequently more dangerous than the change they undo — a `DROP COLUMN`
      rollback destroys everything written since the migration ran.
      
      | Situation | Preferred response |
      |---|---|
      | Additive change (new column/table/index) | Roll **forward** — old code ignores the addition |
      | Destructive change | **Restore from backup** (or D1 Time Travel); snapshot immediately before |
      | Constraint tightened, data now violates it | Roll forward with a repair migration |
      | Genuine need to reverse | Write an explicit down-migration and test it against a copy of production |
      
      ```bash
      # The rollback plan that always works: snapshot first
      sqlite3 app.db "VACUUM INTO '/backup/pre-migration-$(date +%F-%H%M).db'"
      sqlite3 app.db < migrations/007_recreate_product.sql
      
      # On D1, capture a restore point instead
      wrangler d1 time-travel info atdw-mirror     # record the bookmark BEFORE applying
      ```
      
      Design migrations to be **backwards-compatible for one release**: deploy the schema change
      first, the code that depends on it second. Then a code rollback never needs a schema
      rollback.
      
      ---
      
      ## Migration review checklist
      
      - [ ] Wrapped in `BEGIN IMMEDIATE` … `COMMIT`
      - [ ] `PRAGMA foreign_keys = OFF` around a table recreate — **outside** the transaction
      - [ ] Every index, trigger, and view on a recreated table is recreated afterwards
      - [ ] `PRAGMA foreign_key_check` before `COMMIT`
      - [ ] Version recorded (`user_version` or a `schema_migrations` row) in the same transaction
      - [ ] Tested against a **copy of production data**, not just an empty schema
      - [ ] Backup (or D1 bookmark) captured immediately before a destructive change
      - [ ] Free disk ≥ 2x the table size for a recreate
      - [ ] `ANALYZE` after a change that alters data distribution or adds an index
      - [ ] Query plans for affected statements re-checked afterwards
      - [ ] No edits to an already-applied migration file
      - [ ] For D1: the remote apply is left to the maintainer, not run from a working session
      
      ---
      
      ## See also
      
      - [`schema-design.md`](schema-design.md) — designing the shape you are migrating to
      - [`schema-patterns.md`](schema-patterns.md) — ready-made table designs
      - [`testing.md`](testing.md) — idempotency, data-preservation, and integrity tests
      - [`operations.md`](operations.md) — backups before destructive changes
      - [`d1-edge.md`](d1-edge.md) — wrangler migrations, Time Travel, and the deploy gate
      
    • operations.md 11.1 KB
      # SQLite Operations
      
      Integrity, corruption, backups, VACUUM, and size/page tuning. Applies to any host that owns
      a real database file. Managed engines (D1, Turso primaries) handle most of this for you —
      see [`d1-edge.md`](d1-edge.md).
      
      ## Contents
      
      - [Integrity checks](#integrity-checks)
      - [What actually corrupts a SQLite database](#what-actually-corrupts-a-sqlite-database)
      - [Recovering a corrupted database](#recovering-a-corrupted-database)
      - [Backups](#backups)
      - [VACUUM vs VACUUM INTO](#vacuum-vs-vacuum-into)
      - [Where the space went](#where-the-space-went)
      - [Page size, cache, and mmap](#page-size-cache-and-mmap)
      - [Bulk-load tuning](#bulk-load-tuning)
      - [Routine maintenance](#routine-maintenance)
      
      ---
      
      ## Integrity checks
      
      ```sql
      PRAGMA quick_check;        -- structural checks only; fast
      PRAGMA integrity_check;    -- full: pages, indexes, constraints. Slow on large files
      PRAGMA integrity_check(10);-- stop after 10 errors
      PRAGMA foreign_key_check;  -- orphaned rows (independent of the foreign_keys pragma)
      ```
      
      Both return the single row `ok` when clean. `quick_check` skips the index-content
      verification, so it misses a corrupt index whose pages are structurally valid — run the full
      check when you actually suspect damage, and `quick_check` as a routine heartbeat.
      
      `integrity_check` reads every page. On a multi-GB database that is minutes of I/O and it
      holds a read transaction throughout, which (in WAL) pins the checkpoint — schedule it, don't
      run it casually against a busy production database.
      
      ---
      
      ## What actually corrupts a SQLite database
      
      SQLite is extremely hard to corrupt through the API. Nearly every real case is external:
      
      | Cause | Detail |
      |---|---|
      | **Networked filesystems** | NFS/SMB/CIFS advisory locking is unreliable. The single most common cause |
      | **Copying a live database** | `cp`/`rsync` of a file with an active writer produces a torn copy |
      | **Deleting `-wal` / `-shm` by hand** | Removing them while a connection is open discards committed data |
      | **`PRAGMA synchronous = OFF`** | Power loss mid-write can leave the file inconsistent |
      | Two processes with different locking assumptions | e.g. a WSL process and a Windows process on the same file |
      | Hardware / filesystem failure | Bad sectors, a lying fsync in a virtualised disk stack |
      | Killing a process with `SIGKILL` mid-write | Safe at `synchronous=NORMAL` or higher — SQLite recovers. Only unsafe with `synchronous=OFF` |
      
      Application crashes and normal process kills are **not** on the corruption list — SQLite's
      journal/WAL recovery handles them. Note the file's own defence: `PRAGMA integrity_check`
      verifies structure, not semantics, so it will not detect application-level data errors.
      
      ---
      
      ## Recovering a corrupted database
      
      Work on a **copy**. Never attempt recovery on the only artefact you have.
      
      ```bash
      cp app.db app.db.broken          # after stopping every writer
      
      # 1. Confirm and characterise
      sqlite3 app.db.broken 'PRAGMA integrity_check;'
      
      # 2. The .recover command — reconstructs from whatever pages are readable.
      #    Strictly better than .dump for damaged files: it walks the b-trees directly
      #    and salvages orphaned pages instead of aborting at the first bad read.
      sqlite3 app.db.broken '.recover' > recovered.sql
      sqlite3 app_new.db < recovered.sql
      sqlite3 app_new.db 'PRAGMA integrity_check;'
      
      # 3. If .recover is unavailable, .dump gets what it can
      sqlite3 app.db.broken '.dump' > dump.sql
      ```
      
      | Symptom | Likely meaning |
      |---|---|
      | `database disk image is malformed` | Real page-level corruption |
      | `file is not a database` | Wrong file, truncated header, or an encrypted database opened without its key |
      | `database is locked` on every attempt | Not corruption — a stale lock or another process |
      | Missing rows, intact structure | Application bug or an interrupted write, not corruption |
      
      After recovery, `.recover` output usually needs indexes and triggers re-created and should
      be diffed against the schema you expect. Compare row counts per table against your last
      known-good backup before declaring success.
      
      ---
      
      ## Backups
      
      | Method | Consistent under writers | Output | Best for |
      |---|---|---|---|
      | `VACUUM INTO 'f.db'` | **Yes** | Compact database file | The default answer |
      | Backup API (driver-level) | **Yes** | Database file | Programmatic/incremental backups |
      | `.dump` | Yes (single read txn) | SQL text | Portability, archival, cross-version moves |
      | Continuous WAL replication (Litestream-style) | Yes | Object storage | Point-in-time recovery |
      | `cp` / `rsync` | **No** | Corrupt copy | Never, unless all writers are stopped |
      
      ```bash
      # Online, consistent, defragmented — no downtime
      sqlite3 app.db "VACUUM INTO '/backup/app-$(date +%F).db'"
      
      # Portable text backup
      sqlite3 app.db '.dump' | gzip > /backup/app.sql.gz
      
      # Verify the backup before trusting it. An unverified backup is a rumour.
      sqlite3 /backup/app-2026-08-04.db 'PRAGMA integrity_check;'
      sqlite3 /backup/app-2026-08-04.db 'SELECT count(*) FROM product;'
      ```
      
      ```python
      # Backup API: consistent, incremental, works while the source is being written
      import sqlite3
      src = sqlite3.connect("app.db")
      dst = sqlite3.connect("/backup/app.db")
      with dst:
          src.backup(dst, pages=1000, sleep=0.05)   # yields between page batches
      dst.close(); src.close()
      ```
      
      **Restore drills matter more than backup scripts.** Schedule a periodic restore-and-verify;
      a backup nobody has restored is an untested assumption.
      
      ---
      
      ## VACUUM vs VACUUM INTO
      
      ```sql
      VACUUM;                          -- rebuild this database in place
      VACUUM INTO 'copy.db';           -- write a fresh, compact copy elsewhere
      ```
      
      | | `VACUUM` | `VACUUM INTO` |
      |---|---|---|
      | Locks the database | **Yes**, exclusive, for the whole operation | No — a read transaction only |
      | Disk needed | Up to **2x** the database size, plus temp | Size of the output |
      | Result | Same file, defragmented, free pages released | New compact file; original untouched |
      | Safe on a live system | No | Yes |
      
      **`VACUUM` is not a performance tool.** It defragments and reclaims free pages; it does not
      fix a missing index, and running it "to speed things up" is a common misdiagnosis. Reach for
      it when the file has genuinely bloated after large deletions.
      
      `auto_vacuum` handles reclamation incrementally instead:
      
      ```sql
      PRAGMA auto_vacuum;               -- 0 NONE (default) | 1 FULL | 2 INCREMENTAL
      PRAGMA auto_vacuum = INCREMENTAL; -- must be set before tables exist, or followed by VACUUM
      PRAGMA incremental_vacuum(1000);  -- release up to 1000 free pages, cheaply
      ```
      
      `INCREMENTAL` + a periodic `incremental_vacuum` is the low-impact choice for a
      delete-heavy database. `FULL` reclaims on every commit and costs write throughput.
      
      ---
      
      ## Where the space went
      
      ```sql
      -- Overall geometry
      PRAGMA page_count;    -- pages in the file
      PRAGMA page_size;     -- bytes per page  (file size ≈ page_count * page_size)
      PRAGMA freelist_count;-- unused pages — a large number means VACUUM would reclaim
      
      -- Per-object breakdown (needs the dbstat virtual table; present in most builds)
      SELECT name, SUM(pgsize) AS bytes, SUM(pgsize)/1024/1024 AS mb
      FROM dbstat GROUP BY name ORDER BY bytes DESC LIMIT 20;
      
      -- Rows per table
      SELECT name, (SELECT count(*) FROM sqlite_master) AS _ FROM sqlite_master WHERE type='table';
      ```
      
      `dbstat` distinguishes tables from indexes by name, which is how you discover that an index
      you added is larger than the table it indexes — a common outcome with wide covering indexes
      and trigram FTS. The CLI's `.dbinfo` gives the header summary in one shot.
      
      ---
      
      ## Page size, cache, and mmap
      
      ```sql
      PRAGMA page_size;              -- default 4096; only changeable before the first table, or via VACUUM
      PRAGMA cache_size = -64000;    -- NEGATIVE means KiB → 64 MB. Positive means a page count
      PRAGMA mmap_size = 268435456;  -- 256 MB memory-mapped I/O
      PRAGMA temp_store = MEMORY;    -- keep temp B-trees in RAM
      ```
      
      | Setting | Guidance |
      |---|---|
      | `page_size` | 4096 suits most workloads. 8192/16384 can help large sequential scans and large rows. Changing it requires `PRAGMA page_size = N; VACUUM;` |
      | `cache_size` | The highest-leverage knob. Always express as negative KiB — a positive value is a *page count* and silently means something different if you later change `page_size` |
      | `mmap_size` | Can cut read syscalls substantially. Avoid on network filesystems; a corrupt page becomes a segfault rather than an error |
      | `temp_store` | `MEMORY` avoids disk for sorts and temp B-trees — worth setting when EQP shows temp B-trees you can't design away |
      
      Measure rather than cargo-cult: on a database that fits in the OS page cache, none of these
      will move the needle, and index design will move it 25x.
      
      ---
      
      ## Bulk-load tuning
      
      For an import into a database nobody else is using, temporarily trading durability for speed
      is legitimate — **on a database you can rebuild**.
      
      ```sql
      PRAGMA journal_mode = OFF;      -- no rollback journal (UNSAFE: crash = corrupt)
      PRAGMA synchronous = OFF;       -- no fsync            (UNSAFE)
      PRAGMA temp_store = MEMORY;
      PRAGMA cache_size = -256000;    -- 256 MB
      
      BEGIN;
      -- ... millions of INSERTs, one transaction ...
      COMMIT;
      
      -- Restore safe settings, then build indexes AFTER the data is in
      PRAGMA journal_mode = WAL;
      PRAGMA synchronous = NORMAL;
      CREATE INDEX ...;
      ANALYZE;
      ```
      
      | Technique | Effect |
      |---|---|
      | One transaction around the whole load | The single biggest win — one fsync instead of N |
      | Create indexes **after** loading | Bulk index build beats N incremental updates |
      | `.import --csv` in the CLI | Fastest path for CSV; skips per-row round trips |
      | Prepared statement + `executemany`/batch | Avoids re-parsing the statement per row |
      | `ANALYZE` after loading | The planner has no statistics for freshly loaded data |
      
      Never leave `journal_mode = OFF` or `synchronous = OFF` set on a production database.
      
      ---
      
      ## Routine maintenance
      
      | Cadence | Task |
      |---|---|
      | On connection close (long-lived apps) | `PRAGMA optimize` — cheap, targeted `ANALYZE` |
      | Daily | `VACUUM INTO` backup + `integrity_check` on the **backup** (not the live file) |
      | Weekly | `PRAGMA quick_check` on the live database |
      | After bulk changes | `ANALYZE`; FTS5 `'optimize'`; re-read plans for affected statements |
      | After large deletions | `incremental_vacuum`, or a scheduled `VACUUM` during a maintenance window |
      | Monthly | Restore drill: restore the backup somewhere and verify row counts |
      | On schema change | Re-run `EXPLAIN QUERY PLAN` on the statements the change was meant to fix |
      
      ```bash
      #!/usr/bin/env bash
      # Nightly: consistent backup, then verify the BACKUP rather than locking production.
      set -uo pipefail
      DB=/var/lib/app/app.db
      OUT=/backup/app-$(date +%F).db
      sqlite3 "$DB" "VACUUM INTO '$OUT'"          || exit 1
      result=$(sqlite3 "$OUT" 'PRAGMA integrity_check;')
      [ "$result" = "ok" ] || { echo "backup failed integrity_check: $result" >&2; exit 1; }
      find /backup -name 'app-*.db' -mtime +14 -delete
      ```
      
      ---
      
      ## See also
      
      - [`concurrency-durability.md`](concurrency-durability.md) — WAL checkpointing and `synchronous`
      - [`query-performance.md`](query-performance.md) — `ANALYZE`, `PRAGMA optimize`, index sizing
      - [`migration-patterns.md`](migration-patterns.md) — schema change procedure
      - [`testing.md`](testing.md) — verifying a restore in a test harness
      
    • query-performance.md 25.1 KB
      # SQLite Query Performance
      
      Engine-agnostic. Everything here applies to any SQLite 3.x host — CLI, Python, Node, Bun,
      Cloudflare D1, libSQL/Turso — because the query planner is the same code everywhere. Host
      differences are about *how you observe* the cost, not what the cost is; see
      [`d1-edge.md`](d1-edge.md) and [`hosts.md`](hosts.md) for those.
      
      ## Contents
      
      - [The measurement contract](#the-measurement-contract)
      - [Reading EXPLAIN QUERY PLAN](#reading-explain-query-plan)
      - [Covering indexes](#covering-indexes)
      - [Predicates that cannot be seeked](#predicates-that-cannot-be-seeked)
      - [LIKE and GLOB optimisation rules](#like-and-glob-optimisation-rules)
      - [Index design and column order](#index-design-and-column-order)
      - [Partial and expression indexes](#partial-and-expression-indexes)
      - [ANALYZE and sqlite_stat1](#analyze-and-sqlite_stat1)
      - [Query planner defeats](#query-planner-defeats)
      - [Joins and subqueries](#joins-and-subqueries)
      - [Pagination](#pagination)
      - [The read-only proof technique](#the-read-only-proof-technique)
      - [A worked optimisation, end to end](#a-worked-optimisation-end-to-end)
      - [Triage checklist](#triage-checklist)
      
      ---
      
      ## The measurement contract
      
      Before changing anything, be able to state four numbers for the statement you are about to
      optimise: **median latency, range across runs, rows read, and plan shape.** If you can't,
      you are guessing.
      
      | Rule | Why | How |
      |---|---|---|
      | Measure the **statement**, not the request/tool call | A statement piggy-backing on a batch someone else was already sending adds no round trip, so it never appears in per-call timing — while still scanning the table on every request | Decompose multi-part statements; time each part alone |
      | Use engine-reported duration | Process/driver startup dominates a CLI stopwatch (a `npx`-shaped launch is ~2 s before any SQL runs) | `.timer on` (CLI), `sql_duration_ms` (D1), driver-level instrumentation |
      | Median of 10+, report the range | First runs are cold: a 1.5–1.7x first-run penalty on multi-thousand-row reads is routine, and outliers an order of magnitude above the median happen | Loop the statement, sort, take the middle |
      | Report latency **and** rows scanned | They move independently: an optimisation can cut latency ~25x with rows-read unchanged, and a different one can collapse rows-read ~58,000x | Pair `sql_duration_ms`-style timing with a rows-read counter |
      
      **Why rows-read matters even when latency doesn't change.** Rows read is a proxy for work
      done and, on managed engines, is literally the billing unit. Two optimisations with
      identical latency wins can have completely different cost profiles. Always report both, and
      never let one stand in for the other.
      
      ### Getting the numbers per host
      
      ```bash
      # sqlite3 CLI — engine timing plus a scan counter
      sqlite3 app.db '.timer on' 'SELECT ...;'
      sqlite3 app.db '.stats on' 'SELECT ...;'   # includes fullscan steps / sort counts
      ```
      
      ```python
      # Python — sqlite3 exposes VM steps, a good proxy for work done
      import sqlite3, time, statistics
      conn = sqlite3.connect("app.db")
      times = []
      for _ in range(12):
          t0 = time.perf_counter()
          conn.execute(SQL).fetchall()
          times.append((time.perf_counter() - t0) * 1000)
      print(f"median {statistics.median(times):.2f} ms  range {min(times):.2f}-{max(times):.2f}")
      ```
      
      For Cloudflare D1's `meta.timings.sql_duration_ms` / `meta.rows_read`, see
      [`d1-edge.md`](d1-edge.md) — those are server-side and are the only trustworthy numbers there.
      
      ---
      
      ## Reading EXPLAIN QUERY PLAN
      
      EQP is read-only, instant, and safe on production. Run it first, every time.
      
      ```sql
      EXPLAIN QUERY PLAN SELECT ...;
      ```
      
      | Line | What the engine is doing | Read as |
      |---|---|---|
      | `SEARCH t USING INDEX ix (col=?)` | Seek into a B-tree, then fetch each matching row from the table | Good |
      | `SEARCH t USING COVERING INDEX ix (col=?)` | Seek, and answer entirely from the index — the table is never touched | Best |
      | `SEARCH t USING INTEGER PRIMARY KEY (rowid=?)` | Direct rowid lookup | Best |
      | `SCAN t USING COVERING INDEX ix` | Every entry of a narrow index is read; the table is never touched | Acceptable, often the right answer for unseekable predicates |
      | `SCAN t USING INDEX ix` | Every index entry read **and** a table row fetched per hit — usually strictly worse than a plain scan | Suspicious |
      | `SCAN t` | Full table scan: every row, all columns' pages | Fix unless the table is tiny |
      | `USE TEMP B-TREE FOR ORDER BY` | No index supplies the requested order; rows are buffered and sorted | Cost signal |
      | `USE TEMP B-TREE FOR GROUP BY` | Same for grouping | Cost signal |
      | `USE TEMP B-TREE FOR DISTINCT` | Same for de-duplication | Cost signal |
      | `CORRELATED SCALAR SUBQUERY` | Subquery re-runs once per outer row | Usually dominates |
      | `MULTI-INDEX OR` | An `OR` split into multiple index lookups then merged | Fine; better than the scan it replaced |
      | `BLOOM FILTER ON t` | Join pre-filter (3.38+) | Informational |
      | `MATERIALIZE subquery` | A subquery/CTE result is written to a temp table then read | Watch for repeated materialisation |
      
      ### The three questions to ask of any plan
      
      1. **Is there a `SCAN` on a large table?** If yes, is it covering? A non-covering scan on a
         wide table is the single most common cause of slow SQLite.
      2. **Is there a temp B-tree?** That is a sort or grouping the schema could have supplied.
         Note it, but do not fix it first — an index added for the scan often removes it for free.
      3. **Does the plan change after `ANALYZE`?** If yes, your production behaviour depends on
         whether stats exist. See [ANALYZE and sqlite_stat1](#analyze-and-sqlite_stat1).
      
      ### EXPLAIN vs EXPLAIN QUERY PLAN
      
      `EXPLAIN` (without `QUERY PLAN`) dumps the VDBE bytecode — hundreds of opcodes. It is
      occasionally useful for confirming *which* index a statement opened, or spotting a hidden
      `OpenEphemeral` (a temp table), but EQP answers 95% of questions. Reach for `EXPLAIN` only
      after EQP has left you genuinely puzzled.
      
      ---
      
      ## Covering indexes
      
      An index **covers** a statement when every column the statement needs — in the `WHERE`, the
      `SELECT` list, the `ORDER BY`, the `GROUP BY` — is present in the index itself. The engine
      then answers from the index and never reads the table.
      
      This is the highest-value optimisation in SQLite specifically because SQLite stores rows
      contiguously: a 73-column row costs the same page reads whether you asked for one column or
      all of them. A covering index turns "read 58k wide rows" into "read 58k narrow entries".
      
      ```sql
      -- Statement: filter on org, project product_id
      SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%acme%';
      
      -- Covering index — FILTERED column first, PROJECTED column second
      CREATE INDEX q_product_org_product ON q_product(org, product_id);
      ```
      
      **Column order is load-bearing**, even when the leading column can't be seeked:
      
      - The leading column is what the planner matches against the `WHERE` clause when deciding
        the index is relevant at all.
      - Trailing columns exist to make the index *cover*, not to be searched.
      - Reversing them (`(product_id, org)`) gives you an index the planner is far less likely to
        choose for this statement.
      
      ### When a covering index is the right answer
      
      | Situation | Covering index? |
      |---|---|
      | Predicate is unseekable (`LIKE '%x%'`, function on column) but the table is wide | **Yes** — this is the classic win |
      | Predicate is seekable and selective | Usually unnecessary — a plain index already avoids most row reads |
      | Statement projects many columns | No — the index would be as wide as the table |
      | Table is narrow (a few small columns) | No — a table scan already reads narrow pages |
      
      ### Verify it worked
      
      ```sql
      EXPLAIN QUERY PLAN SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%acme%';
      -- want: SCAN q_product USING COVERING INDEX q_product_org_product
      -- not:  SCAN q_product USING INDEX q_product_org
      ```
      
      The word **COVERING** is the whole test. If it is missing, some column the statement needs
      isn't in the index — commonly because someone wrote `SELECT *`.
      
      ---
      
      ## Predicates that cannot be seeked
      
      A B-tree can only seek when the predicate constrains a **prefix of the indexed value**.
      Anything that transforms the column first destroys that property.
      
      | Predicate | Seekable? | Remedy |
      |---|---|---|
      | `col = ?` / `col IN (…)` / `col > ?` / `BETWEEN` | Yes | Plain index |
      | `col LIKE 'abc%'` | Yes, conditionally (see below) | Plain index |
      | `col LIKE '%abc%'` or `'%abc'` | **No** | Covering index, or FTS5 trigram |
      | `lower(col) = ?`, `date(col) = ?`, `substr(col,1,3) = ?` | **No** | Expression index on the same expression |
      | `col + 0 = ?`, `CAST(col AS TEXT) = ?` | **No** | Fix the type, or expression index |
      | `col LIKE ?` (parameterised pattern) | **No** at prepare time | The planner can't see the pattern; treat as unseekable |
      | `a = ? OR b = ?` | Sometimes (`MULTI-INDEX OR`) | Index both columns; or rewrite as `UNION` |
      | `col != ?`, `NOT IN` | Effectively no | Rethink the predicate |
      | `col IS NULL` | Yes | Plain index (NULLs are indexed in SQLite) |
      | `json_extract(doc,'$.k') = ?` | **No** | Expression index or generated column + index |
      
      **Type mismatch is a silent killer.** Because of type affinity, comparing a TEXT column to
      an integer parameter can prevent index use *and* silently return nothing. `WHERE id = '42'`
      against an `INTEGER` column and `WHERE code = 42` against a `TEXT` column both behave
      surprisingly — see [`schema-design.md`](schema-design.md).
      
      ---
      
      ## LIKE and GLOB optimisation rules
      
      SQLite converts `LIKE` into a range constraint (`col >= 'abc' AND col < 'abd'`) only when
      **all** of these hold:
      
      1. The pattern is a **string literal or a bound parameter whose value is known**, and it
         does not start with a wildcard (`%` or `_`).
      2. The column has **TEXT affinity** and the default **`BINARY`** collation — unless
         `PRAGMA case_sensitive_like = ON`, in which case `BINARY` is required, or the column
         uses `COLLATE NOCASE`, which enables the optimisation for case-insensitive `LIKE`.
      3. The `ESCAPE` clause is not used.
      4. The right-hand side is not a column reference.
      
      `GLOB` follows the same rule with `*`/`?` wildcards and is always case-sensitive, so it
      optimises under `BINARY` collation without the `case_sensitive_like` dance.
      
      ```sql
      -- Optimisable: anchored prefix
      SELECT * FROM city WHERE name LIKE 'Syd%';         -- SEARCH ... USING INDEX
      
      -- Not optimisable: leading wildcard
      SELECT * FROM city WHERE name LIKE '%ney';         -- SCAN
      
      -- Case-insensitive, still optimisable if the column is COLLATE NOCASE
      CREATE TABLE city (name TEXT COLLATE NOCASE);
      CREATE INDEX city_name ON city(name);
      SELECT * FROM city WHERE name LIKE 'syd%';          -- SEARCH ... USING INDEX
      ```
      
      **For genuine substring search, stop fighting `LIKE`.** FTS5 with the `trigram` tokenizer
      indexes 3-character sequences and makes `LIKE '%abc%'` and `MATCH` both fast — see
      [`feature-modules.md`](feature-modules.md). The covering-index trick makes an unseekable
      scan cheaper; trigram FTS makes it disappear.
      
      ---
      
      ## Index design and column order
      
      ### The ordering rule
      
      For a composite index `(a, b, c)`, the planner can use a prefix — `(a)`, `(a, b)`,
      `(a, b, c)` — never a suffix. Order columns:
      
      1. **Equality predicates first** (`WHERE a = ?`)
      2. **Then one range or sort column** (`WHERE b > ?` or `ORDER BY b`)
      3. **Then columns needed only for coverage** (projected, never filtered)
      
      ```sql
      -- Statement: WHERE org = ? AND created_at > ? ORDER BY created_at, projecting title
      CREATE INDEX ev_org_created ON events(org, created_at, title);
      --                                    ^equality  ^range/sort   ^coverage only
      ```
      
      A range column stops the usefulness of everything after it for *seeking* — but those
      trailing columns still count for *coverage*, which is exactly why coverage columns go last.
      
      ### Sizing and maintenance
      
      | Consideration | Guidance |
      |---|---|
      | Write cost | Every index is updated on every INSERT/UPDATE/DELETE touching its columns |
      | Redundant indexes | `(a)` is redundant if `(a, b)` exists — drop the shorter one |
      | Index size | Check with `dbstat` (if compiled in) or by summing column widths; a covering index over wide TEXT columns can rival the table |
      | Naming | `<table>_<col1>_<col2>` — readable in EQP output, which is where you will see it |
      | `UNIQUE` | Enforces a constraint *and* provides an index — don't add both |
      
      ### DESC and ORDER BY
      
      SQLite can scan an index backwards, so `CREATE INDEX ix ON t(a)` serves both
      `ORDER BY a` and `ORDER BY a DESC`. A `DESC` index is only needed for **mixed** orders:
      
      ```sql
      -- Needs an explicitly mixed index; a plain (a, b) index cannot supply this order
      SELECT * FROM t ORDER BY a ASC, b DESC;
      CREATE INDEX t_a_bdesc ON t(a ASC, b DESC);
      ```
      
      ---
      
      ## Partial and expression indexes
      
      ### Partial indexes
      
      Index only the rows you actually query. Smaller index, cheaper writes for the excluded rows.
      
      ```sql
      -- Only pending jobs are ever fetched by this predicate
      CREATE INDEX job_pending ON job_queue(priority DESC, created_at)
          WHERE status = 'pending';
      
      -- Only non-NULL values matter
      CREATE INDEX product_sku ON product(sku) WHERE sku IS NOT NULL;
      ```
      
      **The catch:** the planner uses a partial index only when it can *prove* the statement's
      `WHERE` clause implies the index's `WHERE` clause. `WHERE status = 'pending'` matches;
      `WHERE status = :status` does **not**, because the value isn't known at prepare time. Write
      the literal, or keep a full index.
      
      ### Expression indexes
      
      When you can't stop the code calling a function on the column, index the function.
      
      ```sql
      CREATE INDEX user_email_lower ON users(lower(email));
      SELECT * FROM users WHERE lower(email) = lower(?);   -- now seekable
      
      CREATE INDEX ev_kind ON events(json_extract(payload, '$.kind'));
      SELECT * FROM events WHERE json_extract(payload, '$.kind') = 'login';
      ```
      
      The expression in the query must match the indexed expression **syntactically**. Only
      deterministic functions are allowed.
      
      **Generated column alternative** — often clearer, and indexable the same way:
      
      ```sql
      ALTER TABLE events ADD COLUMN kind TEXT
          GENERATED ALWAYS AS (json_extract(payload, '$.kind')) VIRTUAL;
      CREATE INDEX ev_kind ON events(kind);
      ```
      
      `VIRTUAL` costs nothing on disk and is computed on read; `STORED` costs disk and is
      computed on write. For an indexed generated column, `VIRTUAL` is usually right — the index
      already materialises the value.
      
      ---
      
      ## ANALYZE and sqlite_stat1
      
      `ANALYZE` samples indexes and writes row-distribution statistics into the `sqlite_stat1`
      table. Without it, the planner uses fixed guesses (roughly: "an index lookup returns ~10
      rows"), which is fine for simple statements and wrong for skewed data.
      
      ```sql
      ANALYZE;              -- whole database
      ANALYZE events;       -- one table and its indexes
      SELECT * FROM sqlite_stat1;   -- empty means it never ran
      PRAGMA optimize;      -- run periodically / before closing: ANALYZEs only what changed
      ```
      
      `PRAGMA optimize` is the maintenance answer for a long-lived application: cheap, targeted,
      and safe to call on connection close.
      
      ### Verify the plan with and without statistics
      
      **Many managed engines never run `ANALYZE`.** If your index only wins once stats exist, it
      may not win in production. Test both states explicitly rather than assuming:
      
      ```sql
      ANALYZE;
      EXPLAIN QUERY PLAN SELECT ...;         -- plan A
      
      DELETE FROM sqlite_stat1;
      ANALYZE sqlite_master;                 -- forces the planner to reload now-empty stats
      EXPLAIN QUERY PLAN SELECT ...;         -- plan B — same as A?
      ```
      
      If A and B agree, the index is chosen regardless of statistics and you are safe. If they
      disagree, either arrange for `ANALYZE` to run in production, or design an index the planner
      picks without stats. In the worked example below, the covering index was chosen in both
      states — a result that was **verified, not assumed**.
      
      `sqlite_stat1` is an ordinary table: you can copy it between databases to reproduce a
      production plan locally, which is the cheapest way to debug "fast on my machine".
      
      ---
      
      ## Query planner defeats
      
      | Anti-pattern | Effect | Fix |
      |---|---|---|
      | `SELECT *` | Prevents covering-index plans; reads every column's page | Project explicitly |
      | Function on an indexed column | Index unusable | Expression index, or move the function to the parameter side |
      | Type mismatch (TEXT column vs integer param) | Index unusable; may silently return nothing | Fix affinity, cast the parameter, use `STRICT` |
      | `OR` across different columns | May force a scan | `MULTI-INDEX OR` needs an index per branch; else rewrite as `UNION ALL` |
      | `NOT IN (subquery)` with NULLs | Returns no rows at all | `NOT EXISTS` |
      | Correlated scalar subquery in the `SELECT` list | Re-executed per row | Rewrite as a `JOIN` or a windowed aggregate |
      | `LIMIT` without `ORDER BY` | Non-deterministic rows | Always pair them |
      | `OFFSET` pagination on a large table | Scans and discards | Keyset pagination (below) |
      | `ORDER BY random()` | Full sort of the table | Sample by rowid range |
      | Aggregates over an unindexed column | Full scan per call | Index the column, or maintain a watermark row |
      | Too many indexes | Slows every write, bloats the file | Audit; drop redundant prefixes |
      
      ### The invisible aggregate
      
      The subtlest cost in this list. An aggregate like `MAX(updated_at)` over an unindexed
      column scans the table — but if it ships inside a statement batch the application was
      already sending, it costs **no extra round trip** and never surfaces in per-request timing.
      
      Decomposition is how you find it:
      
      ```sql
      -- The full statement as shipped
      SELECT count(*) AS n, MAX(updated_at) AS watermark FROM q_product;
      
      -- Time each half separately:
      SELECT count(*) FROM q_product;                    -- cheap? then the MAX owns the cost
      SELECT MAX(updated_at) FROM q_product;             -- the real cost centre
      ```
      
      In the measured example: the full statement 27.17 ms / 58,434 rows; the `MAX` alone
      28.09 ms / 58,432 rows; the statement **without** the `MAX` 0.17 ms / 2 rows; the same
      `MAX` over an indexed column 0.17 ms / 1 row. One index on the aggregated column removed a
      58,000-row scan from every response across four separate tools.
      
      **Rule:** any `MIN`/`MAX`/`COUNT DISTINCT` over an unindexed column, executed per request,
      is a full scan hiding in plain sight. Index the column — `MAX(col)` over an indexed column
      is an O(log n) walk to the end of the B-tree.
      
      ---
      
      ## Joins and subqueries
      
      SQLite uses nested-loop joins exclusively. Performance therefore hinges on the **inner**
      table having an index on the join column.
      
      ```sql
      EXPLAIN QUERY PLAN
      SELECT o.id, u.email FROM orders o JOIN users u ON u.id = o.user_id;
      -- want: SCAN o  +  SEARCH u USING INTEGER PRIMARY KEY (rowid=?)
      -- bad:  SCAN o  +  SCAN u                      -> O(n*m)
      ```
      
      | Symptom | Fix |
      |---|---|
      | `SCAN` on the inner table | Index the join column on the inner table |
      | Join order looks wrong | Usually the planner is right; if genuinely wrong, `ANALYZE` first, `CROSS JOIN` to force order only as a last resort |
      | CTE materialised repeatedly | `WITH x AS NOT MATERIALIZED (...)` (3.35+) to inline, or `AS MATERIALIZED` to force one evaluation |
      | `IN (subquery)` slow | Often better as a `JOIN`; check whether the subquery is correlated |
      
      ---
      
      ## Pagination
      
      ```sql
      -- Bad: OFFSET must generate and discard 10,000 rows
      SELECT * FROM events ORDER BY id LIMIT 20 OFFSET 10000;
      
      -- Good: keyset pagination — seeks straight to the page
      SELECT * FROM events WHERE id > :last_id ORDER BY id LIMIT 20;
      
      -- Composite sort key
      SELECT * FROM events
      WHERE (created_at, id) > (:last_created, :last_id)
      ORDER BY created_at, id LIMIT 20;
      ```
      
      Keyset pagination needs an index on exactly the sort key. It also gives stable results when
      rows are inserted between page fetches, which `OFFSET` does not.
      
      ---
      
      ## The read-only proof technique
      
      **Problem:** you believe an index will help, but creating it means writing to production
      schema — which in a properly gated setup you cannot do from a working session, and which is
      irreversible enough that you would like evidence first.
      
      **Technique:** run the **identical statement shape** against a column that an existing index
      already covers. Same table, same row count, same predicate shape, same projection width —
      only the column identity changes. The measured difference is your projected payoff, obtained
      with zero writes.
      
      ```sql
      -- Control: the real, unindexed statement
      SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%acme%';
      -- measured: 171.83 ms, 60,736 rows read
      
      -- Proof: same shape over a column already covered by an index
      SELECT DISTINCT org FROM q_product WHERE org LIKE '%acme%';
      -- measured:   6.75 ms, 58,433 rows read
      ```
      
      **How to keep the proof honest:**
      
      | Requirement | Why |
      |---|---|
      | Same table and same predicate shape | Row count and scan pattern must match |
      | Comparable column width | Proving with a 4-byte int and shipping a 200-char TEXT column overstates the win |
      | Same projection count | `DISTINCT one_col` vs `DISTINCT three_cols` are different index widths |
      | Confirm the control's plan is what you think | Run EQP on both; the proof shot should show `COVERING` |
      | Report rows-read for both | If rows-read is unchanged, the win is row *width* — say so |
      
      **What this technique cannot prove:** whether the planner will *choose* your new index (see
      [ANALYZE](#analyze-and-sqlite_stat1)), or what the index costs on write. Pair it with the
      stats check and a write-volume sanity check before shipping.
      
      Generalises beyond indexes: any time you want to measure "what if this data were shaped
      differently" on a production system you may not write to, look for an existing object that
      already has the shape you're proposing and measure against that.
      
      ---
      
      ## A worked optimisation, end to end
      
      > **These are numbers from one database, not constants.** Measured 2026-08-04 against a
      > live Cloudflare D1 (`atdw-mirror`, region OC, colo SYD), 12 runs each, reporting the
      > median of server-side `sql_duration_ms`. Table: 73 columns, 58k rows. Your database will
      > produce different magnitudes; the *shape* of the reasoning is what transfers.
      
      **1. Symptom.** A lookup filtering on an organisation name took ~170 ms and got slower as
      the table grew.
      
      **2. Plan.**
      
      ```
      SCAN q_product USING INDEX q_product_org
      ```
      
      A `SCAN` — but *with* an index. That combination is the tell: the index was consulted and
      bought nothing, because the predicate was `LIKE '%…%'` and could not be seeked.
      
      **3. Diagnosis.** Leading-wildcard `LIKE` is unseekable by construction. The existing index
      did not cover the projected column, so every one of the 58k index entries triggered a fetch
      of a 73-column row. The cost was **row width**, not row count.
      
      **4. Hypothesis.** A covering index `(org, product_id)` keeps the scan but confines it to
      narrow index entries.
      
      **5. Read-only proof.** The same statement shape over an already-covered column: **6.75 ms /
      58,433 rows** vs the control's **171.83 ms / 60,736 rows**. ~25x, with rows read essentially
      unchanged — confirming the win is width, not selectivity.
      
      **6. Stats check.** Plan compared with `sqlite_stat1` populated and after deleting it. The
      covering index was chosen in both states — verified, not assumed.
      
      **7. Second-order effect.** With the covering index in place, SQLite **dropped the
      `GROUP BY` temp B-tree on its own**. A hand-rewrite to avoid the grouping measured 5.99 ms
      vs 5.85 ms — noise. *Re-read the plan after adding an index before hand-optimising anything
      else; the index may have already fixed it.*
      
      **8. The one that was invisible.** A separate `MAX()` over an unindexed column rode inside a
      batch the application was already sending: 28.09 ms and 58,432 rows scanned per response
      across four tools, never once appearing in per-query timing. Decomposition found it; an
      index on the aggregated column reduced it to 0.17 ms / 1 row.
      
      **Transferable lessons:** `SCAN … USING INDEX` means the index is not earning its keep ·
      prove before you write · check the plan with and without stats · re-read the plan after
      each change · measure statements, not calls.
      
      ---
      
      ## Triage checklist
      
      Work top to bottom; stop when the numbers are acceptable.
      
      1. **Capture the baseline** — median of 10+, range, rows read, engine-reported timing.
      2. **`EXPLAIN QUERY PLAN`** — classify every line against the table above.
      3. **Any `SCAN` on a large table?** Determine whether the predicate is seekable at all.
         - Seekable → add or fix the index.
         - Unseekable → make the scan covering, or move to FTS5 trigram.
      4. **`SCAN … USING INDEX`** (non-covering)? The index is not earning its place — either
         extend it to cover, or drop it.
      5. **Decompose multi-part statements** — time each part; hunt for invisible aggregates.
      6. **Check `SELECT *`** — it silently defeats covering plans.
      7. **Prove read-only** before writing schema (see above).
      8. **Check the plan with and without `sqlite_stat1`.**
      9. **Apply the change; re-read the plan.** Temp B-trees often vanish for free.
      10. **Re-measure both metrics** — latency and rows read — and report the deltas separately.
      
      ---
      
      ## See also
      
      - [`d1-edge.md`](d1-edge.md) — measuring on Cloudflare D1, rows-read billing, caps
      - [`schema-design.md`](schema-design.md) — affinity and type mismatches that defeat indexes
      - [`feature-modules.md`](feature-modules.md) — FTS5 trigram as the real fix for substring search
      - [`operations.md`](operations.md) — `PRAGMA optimize`, page sizing, size analysis
      - `perf-ops` skill — the surrounding before/after profiling workflow
      
    • schema-design.md 13 KB
      # SQLite Schema Design
      
      Engine-agnostic. Type affinity, STRICT tables, foreign-key enforcement, generated columns,
      and `WITHOUT ROWID` behave identically on every host. For ready-made table recipes see
      [`schema-patterns.md`](schema-patterns.md); for changing an existing schema see
      [`migration-patterns.md`](migration-patterns.md).
      
      ## Contents
      
      - [Type affinity: the thing that surprises everyone](#type-affinity-the-thing-that-surprises-everyone)
      - [STRICT tables](#strict-tables)
      - [Foreign keys are OFF by default](#foreign-keys-are-off-by-default)
      - [Primary keys and rowid](#primary-keys-and-rowid)
      - [WITHOUT ROWID](#without-rowid)
      - [Generated columns](#generated-columns)
      - [Constraints and defaults](#constraints-and-defaults)
      - [Storing dates, booleans, and JSON](#storing-dates-booleans-and-json)
      - [Collation](#collation)
      - [Schema review checklist](#schema-review-checklist)
      
      ---
      
      ## Type affinity: the thing that surprises everyone
      
      SQLite is **dynamically typed**. A column's declared type is not a constraint — it is an
      *affinity*, a preference for how values are converted when they can be converted losslessly.
      A `TEXT` column will store the integer `42` if you insert `42`.
      
      | Declared type contains | Affinity | Behaviour |
      |---|---|---|
      | `INT` | INTEGER | Text that looks like an integer is converted |
      | `CHAR`, `CLOB`, `TEXT` | TEXT | Numbers are converted to text |
      | `BLOB`, or no type at all | BLOB (none) | Everything stored as given |
      | `REAL`, `FLOA`, `DOUB` | REAL | Integers stored as floats |
      | anything else (e.g. `NUMERIC`, `DATETIME`, `BOOLEAN`) | NUMERIC | Converts to INTEGER/REAL when lossless, else TEXT |
      
      ```sql
      CREATE TABLE t (a INTEGER, b TEXT, c BLOB);
      INSERT INTO t VALUES ('42', 42, 42);
      SELECT typeof(a), typeof(b), typeof(c) FROM t;
      -- integer | text | integer     <- a and b converted; c kept as given
      ```
      
      ### Why this is a performance bug, not just a tidiness bug
      
      A type mismatch between a column and a bound parameter can **prevent index use** and
      **silently return no rows**, because comparison across storage classes follows fixed rules
      (NULL < INTEGER/REAL < TEXT < BLOB) rather than converting.
      
      ```sql
      CREATE TABLE code (id TEXT PRIMARY KEY);
      INSERT INTO code VALUES ('00123');
      SELECT * FROM code WHERE id = 123;    -- 0 rows: integer 123 never equals text '00123'
      ```
      
      Debug with `typeof()`, which is the fastest way to find a column that has been storing two
      storage classes for years:
      
      ```sql
      SELECT typeof(id), count(*) FROM code GROUP BY 1;
      ```
      
      ---
      
      ## STRICT tables
      
      SQLite 3.37+ (2021). Adding `STRICT` after the closing parenthesis makes declared types
      **enforced**.
      
      ```sql
      CREATE TABLE product (
          id     INTEGER PRIMARY KEY,
          sku    TEXT NOT NULL,
          price  REAL NOT NULL,
          active INTEGER NOT NULL DEFAULT 1     -- SQLite has no BOOLEAN; use INTEGER 0/1
      ) STRICT;
      
      INSERT INTO product (sku, price) VALUES (42, 'free');
      -- Error: cannot store INTEGER value in TEXT column product.sku
      ```
      
      Rules for STRICT tables:
      
      - Every column must declare one of exactly six types: `INT`, `INTEGER`, `REAL`, `TEXT`,
        `BLOB`, `ANY`.
      - `ANY` stores anything without conversion — the escape hatch, and unlike a non-STRICT
        column with no type, it preserves the original storage class exactly.
      - `NOT NULL` is enforced as always; STRICT does not change nullability.
      - The `PRIMARY KEY` of a STRICT table is implicitly `NOT NULL` (fixing a long-standing
        legacy quirk where a non-INTEGER primary key could be NULL).
      
      **Use STRICT for all new tables** unless you have a specific reason for dynamic typing. It
      costs nothing at runtime and converts a class of silent data bugs into loud errors. The one
      migration consideration: existing rows with mixed storage classes will block the table
      recreation, which is a feature — it tells you the data was already wrong.
      
      ---
      
      ## Foreign keys are OFF by default
      
      ```sql
      PRAGMA foreign_keys = ON;    -- per CONNECTION, every connection, not persistent
      ```
      
      This is the classic silent data-integrity bug in SQLite applications. `REFERENCES` clauses
      parse fine, are stored in the schema, appear in `.schema` output, and **do nothing** unless
      the pragma is on for the connection performing the write. Applications routinely ship for
      years with orphaned rows accumulating.
      
      ```sql
      PRAGMA foreign_keys = ON;
      
      CREATE TABLE author (id INTEGER PRIMARY KEY, name TEXT NOT NULL) STRICT;
      CREATE TABLE book (
          id        INTEGER PRIMARY KEY,
          author_id INTEGER NOT NULL REFERENCES author(id) ON DELETE CASCADE,
          title     TEXT NOT NULL
      ) STRICT;
      
      -- Index the child column: SQLite indexes the PARENT key automatically, never the child
      CREATE INDEX book_author ON book(author_id);
      ```
      
      | Action | Options |
      |---|---|
      | `ON DELETE` | `NO ACTION` (default), `RESTRICT`, `CASCADE`, `SET NULL`, `SET DEFAULT` |
      | `ON UPDATE` | Same set |
      | Deferred checking | `DEFERRABLE INITIALLY DEFERRED` — checked at COMMIT, needed for circular references |
      
      **Two operational notes:**
      
      - `PRAGMA foreign_keys` is a **no-op inside a transaction** — set it before `BEGIN`.
      - Find pre-existing damage with `PRAGMA foreign_key_check;` before turning enforcement on.
        It lists every violating row so you can repair rather than discover at runtime.
      
      ```sql
      PRAGMA foreign_key_check;                 -- whole database
      PRAGMA foreign_key_check(book);           -- one table
      ```
      
      **Always index the child column.** SQLite requires an index on the parent side (the
      `PRIMARY KEY`/`UNIQUE` it references) but creates nothing on the child side — so a
      `ON DELETE CASCADE` on an unindexed child column triggers a full table scan per parent
      delete.
      
      ---
      
      ## Primary keys and rowid
      
      Every ordinary table has a hidden 64-bit `rowid`. `INTEGER PRIMARY KEY` is special: it
      **becomes** the rowid rather than creating a separate index, which makes it the fastest
      possible key.
      
      ```sql
      CREATE TABLE a (id INTEGER PRIMARY KEY);           -- id IS the rowid. Fast, no extra index.
      CREATE TABLE b (id INT PRIMARY KEY);               -- NOT the rowid (INT != INTEGER) - separate index
      CREATE TABLE c (id TEXT PRIMARY KEY);              -- separate unique index + rowid
      ```
      
      `INTEGER PRIMARY KEY AUTOINCREMENT` adds a `sqlite_sequence` table and guarantees ids are
      never reused. It is **slower** and rarely needed: without it, SQLite reuses the ids of
      deleted rows only when the max row is deleted. Use `AUTOINCREMENT` only when id reuse would
      be a correctness or security problem (e.g. externally published ids).
      
      | Key choice | Trade-off |
      |---|---|
      | `INTEGER PRIMARY KEY` | Fastest; rowid alias; ids may be reused |
      | `INTEGER PRIMARY KEY AUTOINCREMENT` | Never reuses; extra table and write cost |
      | `TEXT PRIMARY KEY` (UUID) | Portable and mergeable; larger index, random insert order hurts write locality |
      | ULID / UUIDv7 in TEXT | Retains sortability, so insert locality is good — usually the right choice if you need a distributed id |
      
      ---
      
      ## WITHOUT ROWID
      
      Stores rows directly in a B-tree keyed by the primary key, eliminating the extra rowid
      indirection.
      
      ```sql
      CREATE TABLE kv (
          key   TEXT PRIMARY KEY,
          value TEXT NOT NULL
      ) WITHOUT ROWID, STRICT;
      ```
      
      | Use it when | Avoid it when |
      |---|---|
      | Primary key is a non-integer (TEXT/BLOB) that you always look up by | Primary key is `INTEGER` (already optimal) |
      | Rows are small (a few hundred bytes) | Rows are large — big rows overflow badly here |
      | Key-value or association tables | You need `AUTOINCREMENT` (incompatible) |
      | Lookups are almost always by the full primary key | You rely on `rowid` / `last_insert_rowid()` |
      
      Requires an explicit `PRIMARY KEY`. The win is typically both space and lookup speed for
      small keyed rows; the loss is worse behaviour for wide rows and no rowid semantics.
      
      ---
      
      ## Generated columns
      
      SQLite 3.31+. Compute a column from other columns in the same row — and, crucially, **index
      it**. This is the clean way to make a JSON field or a normalised form queryable.
      
      ```sql
      CREATE TABLE event (
          id      INTEGER PRIMARY KEY,
          payload TEXT NOT NULL,
          kind    TEXT GENERATED ALWAYS AS (json_extract(payload, '$.kind')) VIRTUAL,
          email_l TEXT GENERATED ALWAYS AS (lower(json_extract(payload, '$.email'))) VIRTUAL
      ) STRICT;
      
      CREATE INDEX event_kind ON event(kind);
      ```
      
      | Kind | Storage | Computed | Choose when |
      |---|---|---|---|
      | `VIRTUAL` (default) | None | On read | Indexed columns, or cheap expressions — usually right |
      | `STORED` | On disk | On write | Expensive expressions read far more often than written |
      
      Constraints: the expression must be deterministic and reference only columns of the same
      row. Generated columns can be added by `ALTER TABLE ADD COLUMN` (`VIRTUAL` only) but not
      dropped or altered without the full recreate dance.
      
      ---
      
      ## Constraints and defaults
      
      ```sql
      CREATE TABLE account (
          id        INTEGER PRIMARY KEY,
          email     TEXT NOT NULL UNIQUE,
          status    TEXT NOT NULL DEFAULT 'active'
                        CHECK (status IN ('active','suspended','closed')),
          balance   REAL NOT NULL DEFAULT 0 CHECK (balance >= 0),
          created_at TEXT NOT NULL DEFAULT (datetime('now'))
      ) STRICT;
      ```
      
      | Constraint | Note |
      |---|---|
      | `NOT NULL` | Cheapest correctness win available; use liberally |
      | `UNIQUE` | Creates an index — don't also create one manually |
      | `CHECK` | Evaluated on insert/update; expression must be deterministic |
      | `DEFAULT (expr)` | Parenthesised expression allowed (e.g. `datetime('now')`); function defaults need the parentheses |
      | Partial `UNIQUE` | `CREATE UNIQUE INDEX ix ON t(a) WHERE b IS NULL` — the only way to express a conditional uniqueness rule |
      
      `CHECK` constraints are enforced by the engine on every host, including managed ones, which
      makes them more reliable than application-layer validation.
      
      ---
      
      ## Storing dates, booleans, and JSON
      
      SQLite has no dedicated DATE, TIME, or BOOLEAN storage class. Pick one convention and put
      it in the schema comment, because mixed conventions in one database are a recurring bug.
      
      | Data | Recommended | Why |
      |---|---|---|
      | Timestamp | `TEXT` ISO-8601 UTC: `'2026-08-04T10:23:45Z'` | Sorts lexicographically, human-readable, works with `datetime()` |
      | Timestamp (compact) | `INTEGER` Unix epoch seconds | Smaller, arithmetic-friendly, not human-readable |
      | Date only | `TEXT` `'2026-08-04'` | Same sorting property |
      | Boolean | `INTEGER` 0/1 | `TRUE`/`FALSE` keywords exist (3.23+) and store as 1/0 |
      | Money | `INTEGER` minor units (cents) | Avoids float rounding; `REAL` money is a bug factory |
      | JSON document | `TEXT` (or JSONB, 3.45+) | See [`feature-modules.md`](feature-modules.md) |
      | Binary | `BLOB` | Keep large blobs out of hot tables — they widen every row read |
      
      **Never mix conventions across tables.** A database with epoch integers in one table and ISO
      strings in another guarantees a comparison bug eventually.
      
      Storing large BLOBs inline is a specific performance trap: because SQLite stores rows
      contiguously, a 2 MB blob in a row makes *every* scan of that table pay for it, even when
      the blob column isn't selected — unless a covering index avoids the table entirely. Store
      large binaries out of line (filesystem, object storage) and keep a reference.
      
      ---
      
      ## Collation
      
      | Collation | Behaviour |
      |---|---|
      | `BINARY` (default) | Byte comparison; case-sensitive |
      | `NOCASE` | ASCII case-insensitive only — **does not handle non-ASCII** |
      | `RTRIM` | Ignores trailing spaces |
      
      ```sql
      CREATE TABLE users (email TEXT COLLATE NOCASE);
      CREATE INDEX users_email ON users(email);       -- index inherits the column collation
      SELECT * FROM users WHERE email = 'Alice@Example.COM';   -- matches, and uses the index
      ```
      
      The index must have the **same collation as the comparison** or it cannot be used. If you
      write `WHERE email COLLATE NOCASE = ?` against a `BINARY` column and index, the index is
      skipped — declare the collation on the column instead.
      
      For real Unicode case-folding you need the ICU extension, which is not compiled in by
      default and is unavailable on most managed hosts. A portable alternative is storing a
      normalised (`lower()`ed) generated column and indexing that.
      
      ---
      
      ## Schema review checklist
      
      - [ ] New tables declared `STRICT`
      - [ ] `PRAGMA foreign_keys = ON` set in the connection factory (every host, every path)
      - [ ] Every foreign-key **child** column has its own index
      - [ ] `PRAGMA foreign_key_check` clean
      - [ ] `NOT NULL` on everything that logically cannot be null
      - [ ] `CHECK` constraints for enumerations instead of free-text status columns
      - [ ] One timestamp convention across the whole database, documented
      - [ ] Money as integer minor units, never `REAL`
      - [ ] Large BLOBs stored out of line
      - [ ] `INTEGER PRIMARY KEY` unless a distributed id is genuinely needed
      - [ ] `AUTOINCREMENT` only where id reuse would be a real problem
      - [ ] Collation declared on the column, not in the query
      - [ ] JSON fields that are queried have a generated column + index
      
      ---
      
      ## See also
      
      - [`schema-patterns.md`](schema-patterns.md) — ready-made designs (state, cache, queue, log)
      - [`migration-patterns.md`](migration-patterns.md) — changing a schema safely
      - [`query-performance.md`](query-performance.md) — how affinity mismatches defeat indexes
      - [`feature-modules.md`](feature-modules.md) — JSON, FTS5, and other module-backed columns
      
    • schema-patterns.md 6.4 KB
      # SQLite Schema Patterns
      
      Ready-made table designs for state, caching, logging, queues, sessions, and search.
      Engine-agnostic SQL — these run on any host, including D1 and libSQL.
      
      **Before using any of these**, read the three rules they assume
      ([`schema-design.md`](schema-design.md) has the depth):
      
      - Add **`STRICT`** to every `CREATE TABLE` below unless you need dynamic typing — the
        recipes omit it only to stay readable on pre-3.37 engines.
      - `PRAGMA foreign_keys = ON` must be set on **every connection**, or `REFERENCES` does
        nothing.
      - Timestamps here use ISO-8601 text (`datetime('now')`), which sorts correctly and compares
        as text. Pick one convention per database and stick to it.
      
      Related: [`schema-design.md`](schema-design.md) (affinity, STRICT, generated columns),
      [`migration-patterns.md`](migration-patterns.md) (changing these later),
      [`feature-modules.md`](feature-modules.md) (FTS5 depth),
      [`query-performance.md`](query-performance.md) (whether the indexes below actually get used).
      
      ## State/Config Storage
      
      Key-value store with automatic timestamps:
      
      ```sql
      CREATE TABLE IF NOT EXISTS app_state (
          key TEXT PRIMARY KEY,
          value TEXT NOT NULL,
          updated_at TEXT DEFAULT (datetime('now'))
      );
      
      -- Upsert pattern (insert or update)
      INSERT INTO app_state (key, value) VALUES ('last_sync', '2024-01-15')
      ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now');
      
      -- Get value
      SELECT value FROM app_state WHERE key = 'last_sync';
      
      -- Get all state
      SELECT * FROM app_state ORDER BY updated_at DESC;
      ```
      
      ## Cache Table
      
      Time-based cache with expiry cleanup:
      
      ```sql
      CREATE TABLE IF NOT EXISTS cache (
          key TEXT PRIMARY KEY,
          value TEXT NOT NULL,
          expires_at TEXT NOT NULL,
          created_at TEXT DEFAULT (datetime('now'))
      );
      
      -- Create index for expiry cleanup
      CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at);
      
      -- Insert with 1 hour TTL
      INSERT INTO cache (key, value, expires_at)
      VALUES ('user:123', '{"name": "Alice"}', datetime('now', '+1 hour'))
      ON CONFLICT(key) DO UPDATE SET
          value = excluded.value,
          expires_at = excluded.expires_at;
      
      -- Get non-expired value
      SELECT value FROM cache
      WHERE key = 'user:123' AND expires_at > datetime('now');
      
      -- Cleanup expired entries
      DELETE FROM cache WHERE expires_at < datetime('now');
      ```
      
      ## Event/Log Table
      
      Append-only event log with type indexing:
      
      ```sql
      CREATE TABLE IF NOT EXISTS events (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          event_type TEXT NOT NULL,
          payload TEXT,  -- JSON
          created_at TEXT DEFAULT (datetime('now'))
      );
      
      -- Index for type + date queries
      CREATE INDEX IF NOT EXISTS idx_events_type_date ON events(event_type, created_at);
      
      -- Insert event
      INSERT INTO events (event_type, payload)
      VALUES ('user_login', '{"user_id": 123, "ip": "10.0.0.1"}');
      
      -- Get recent events by type
      SELECT * FROM events
      WHERE event_type = 'user_login'
      AND created_at > datetime('now', '-1 day')
      ORDER BY created_at DESC;
      
      -- Count by type
      SELECT event_type, COUNT(*) as count
      FROM events
      GROUP BY event_type;
      ```
      
      ## Deduplication Table
      
      Track seen items to avoid reprocessing:
      
      ```sql
      CREATE TABLE IF NOT EXISTS seen_items (
          hash TEXT PRIMARY KEY,
          source TEXT NOT NULL,
          first_seen TEXT DEFAULT (datetime('now'))
      );
      
      -- Check if seen
      SELECT 1 FROM seen_items WHERE hash = ? LIMIT 1;
      
      -- Mark as seen
      INSERT OR IGNORE INTO seen_items (hash, source) VALUES (?, ?);
      
      -- Get sources for hash
      SELECT source, first_seen FROM seen_items WHERE hash = ?;
      
      -- Cleanup old entries
      DELETE FROM seen_items WHERE first_seen < datetime('now', '-30 days');
      ```
      
      ## Queue Table
      
      Simple job queue with status tracking:
      
      ```sql
      CREATE TABLE IF NOT EXISTS job_queue (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          job_type TEXT NOT NULL,
          payload TEXT NOT NULL,  -- JSON
          status TEXT DEFAULT 'pending',  -- pending, processing, completed, failed
          priority INTEGER DEFAULT 0,
          created_at TEXT DEFAULT (datetime('now')),
          started_at TEXT,
          completed_at TEXT,
          error TEXT
      );
      
      -- Index for fetching next job
      CREATE INDEX IF NOT EXISTS idx_jobs_status_priority
      ON job_queue(status, priority DESC, created_at);
      
      -- Claim next job (atomic with transaction)
      UPDATE job_queue
      SET status = 'processing', started_at = datetime('now')
      WHERE id = (
          SELECT id FROM job_queue
          WHERE status = 'pending'
          ORDER BY priority DESC, created_at
          LIMIT 1
      )
      RETURNING *;
      
      -- Complete job
      UPDATE job_queue
      SET status = 'completed', completed_at = datetime('now')
      WHERE id = ?;
      
      -- Fail job
      UPDATE job_queue
      SET status = 'failed', completed_at = datetime('now'), error = ?
      WHERE id = ?;
      ```
      
      ## Session Table
      
      User sessions with expiry:
      
      ```sql
      CREATE TABLE IF NOT EXISTS sessions (
          token TEXT PRIMARY KEY,
          user_id INTEGER NOT NULL,
          data TEXT,  -- JSON
          created_at TEXT DEFAULT (datetime('now')),
          expires_at TEXT NOT NULL
      );
      
      CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
      CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
      
      -- Create session (1 week expiry)
      INSERT INTO sessions (token, user_id, data, expires_at)
      VALUES (?, ?, '{}', datetime('now', '+7 days'));
      
      -- Get valid session
      SELECT * FROM sessions
      WHERE token = ? AND expires_at > datetime('now');
      
      -- Extend session
      UPDATE sessions
      SET expires_at = datetime('now', '+7 days')
      WHERE token = ?;
      
      -- Delete session
      DELETE FROM sessions WHERE token = ?;
      
      -- Cleanup expired
      DELETE FROM sessions WHERE expires_at < datetime('now');
      ```
      
      ## Full-Text Search Table
      
      Using SQLite FTS5:
      
      ```sql
      -- Create FTS table
      CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
          title,
          content,
          content='documents',
          content_rowid='id'
      );
      
      -- Trigger to keep FTS in sync
      CREATE TRIGGER documents_ai AFTER INSERT ON documents BEGIN
          INSERT INTO documents_fts(rowid, title, content)
          VALUES (new.id, new.title, new.content);
      END;
      
      CREATE TRIGGER documents_ad AFTER DELETE ON documents BEGIN
          INSERT INTO documents_fts(documents_fts, rowid, title, content)
          VALUES('delete', old.id, old.title, old.content);
      END;
      
      CREATE TRIGGER documents_au AFTER UPDATE ON documents BEGIN
          INSERT INTO documents_fts(documents_fts, rowid, title, content)
          VALUES('delete', old.id, old.title, old.content);
          INSERT INTO documents_fts(rowid, title, content)
          VALUES (new.id, new.title, new.content);
      END;
      
      -- Search
      SELECT * FROM documents_fts WHERE documents_fts MATCH 'search query';
      
      -- Ranked search
      SELECT *, rank FROM documents_fts
      WHERE documents_fts MATCH 'query'
      ORDER BY rank;
      ```
      
    • testing.md 10.6 KB
      # Testing Against SQLite
      
      SQLite is unusually pleasant to test against: a whole database is one file (or none at all),
      so isolation is cheap and setup is fast. The traps are the ways a test database quietly
      stops resembling production.
      
      ## Contents
      
      - [In-memory vs file databases](#in-memory-vs-file-databases)
      - [Keeping the test database honest](#keeping-the-test-database-honest)
      - [Fixture strategies](#fixture-strategies)
      - [Deterministic seeding](#deterministic-seeding)
      - [Testing migrations](#testing-migrations)
      - [Testing concurrency](#testing-concurrency)
      - [Testing query plans](#testing-query-plans)
      - [Testing against D1](#testing-against-d1)
      
      ---
      
      ## In-memory vs file databases
      
      ```python
      sqlite3.connect(":memory:")                                     # private to this connection
      sqlite3.connect("file:test?mode=memory&cache=shared", uri=True) # shared across connections
      sqlite3.connect("/tmp/test-xyz.db")                             # real file
      ```
      
      | | `:memory:` | Shared-cache memory | Temp file |
      |---|---|---|---|
      | Speed | Fastest | Fast | Fast enough (OS page cache) |
      | Multiple connections see it | **No** | Yes | Yes |
      | Supports WAL | **No** (WAL needs a real file) | No | **Yes** |
      | Survives the process | No | No | Yes — inspectable after a failure |
      | Matches production behaviour | Least | Middling | **Most** |
      
      **Recommendation: temp files, not `:memory:`.** The speed difference is negligible against
      the OS page cache, and a file test can exercise WAL, real locking, multiple connections, and
      `busy_timeout` — precisely the behaviours where SQLite bugs live. A file also survives a
      failing test, so you can open it and look.
      
      ```python
      import tempfile, pathlib, sqlite3, pytest
      
      @pytest.fixture
      def db_path(tmp_path: pathlib.Path) -> str:
          return str(tmp_path / "test.db")     # pytest deletes tmp_path automatically
      ```
      
      Reserve `:memory:` for pure-SQL unit tests where a single connection is genuinely the whole
      story.
      
      ---
      
      ## Keeping the test database honest
      
      The recurring failure is a test database configured differently from production, so tests
      pass on behaviour production doesn't have. **Use the same connection factory in tests as in
      production** — do not hand-roll a second one.
      
      ```python
      # app/db.py — one factory, used by prod and tests alike
      def connect(path: str) -> sqlite3.Connection:
          conn = sqlite3.connect(path, isolation_level=None)
          conn.row_factory = sqlite3.Row
          conn.execute("PRAGMA journal_mode = WAL")
          conn.execute("PRAGMA busy_timeout = 5000")
          conn.execute("PRAGMA foreign_keys = ON")
          return conn
      ```
      
      | Divergence | Consequence |
      |---|---|
      | `foreign_keys` on in prod, off in tests (or vice versa) | FK violations either pass tests and fail live, or the reverse |
      | WAL in prod, rollback in tests | Locking behaviour differs; concurrency bugs invisible |
      | STRICT tables in prod, loose in tests | Type errors slip through |
      | Tiny test dataset | Every plan is a scan and every scan is fast — **no performance signal at all** |
      | Schema built by a fixture instead of by migrations | Tests validate a schema that never exists in production |
      
      **Build the test schema by running your real migrations.** That way the migration path is
      tested on every run, and the tested schema is by construction the one production will have.
      
      ---
      
      ## Fixture strategies
      
      | Strategy | Speed | Isolation | Use when |
      |---|---|---|---|
      | Fresh database per test | Slowest | Perfect | Small suites; anything touching schema |
      | Template copy | Fast | Perfect | Expensive seed data — build once, `shutil.copy` per test |
      | Transaction rollback per test | Fastest | Good | Read-heavy tests that don't need their own DDL |
      | Truncate between tests | Fast | Good | Stable schema, changing data |
      
      ```python
      # Template pattern: seed once per session, copy per test - fast AND fully isolated
      import shutil, pytest
      
      @pytest.fixture(scope="session")
      def template_db(tmp_path_factory):
          path = tmp_path_factory.mktemp("tpl") / "template.db"
          conn = connect(str(path))
          run_migrations(conn)
          seed(conn)
          conn.close()
          return str(path)
      
      @pytest.fixture
      def db(template_db, tmp_path):
          path = tmp_path / "test.db"
          shutil.copy(template_db, path)     # copying a CLOSED database is safe
          conn = connect(str(path))
          yield conn
          conn.close()
      ```
      
      Copying a closed database file is safe — the prohibition on `cp` applies to databases with
      active writers (see [`operations.md`](operations.md)). Close the template before copying, or
      build it with `VACUUM INTO`.
      
      ```python
      # Rollback pattern: fastest, but the test cannot commit or run its own DDL
      @pytest.fixture
      def db(shared_conn):
          shared_conn.execute("BEGIN")
          yield shared_conn
          shared_conn.execute("ROLLBACK")
      ```
      
      ---
      
      ## Deterministic seeding
      
      Flaky test data is a self-inflicted wound. Three rules:
      
      1. **Seed the RNG explicitly.** `random.Random(1234)`, never the global module state.
      2. **Never use SQL `random()` or `datetime('now')` in fixtures.** Both make the fixture
         non-reproducible and time-dependent — the classic source of a suite that fails at
         midnight or on a leap day.
      3. **Fix the clock.** Pass timestamps in as data; don't let the database generate them.
      
      ```python
      import random
      
      def seed(conn, n: int = 1000, seed_value: int = 1234) -> None:
          rng = random.Random(seed_value)          # local RNG - global state is not test-safe
          base = "2026-01-01T00:00:00Z"            # fixed epoch, not datetime('now')
          rows = [
              (f"org-{rng.randrange(50)}", f"sku-{i:06d}",
               round(rng.uniform(1, 500), 2),
               f"2026-01-{1 + (i % 28):02d}T00:00:00Z")
              for i in range(n)
          ]
          conn.execute("BEGIN IMMEDIATE")
          conn.executemany(
              "INSERT INTO product (org, sku, price, created_at) VALUES (?,?,?,?)", rows)
          conn.execute("COMMIT")
      ```
      
      **Seed enough rows to produce a performance signal.** A hundred rows makes every plan fast
      and every index pointless; if you intend to assert anything about scans or plans, seed tens
      of thousands. Generate them — don't commit a large fixture file.
      
      ---
      
      ## Testing migrations
      
      Migrations are the code most likely to destroy data and least likely to be tested. Assert
      three things:
      
      ```python
      def test_migrations_are_idempotent(db_path):
          conn = connect(db_path)
          run_migrations(conn)
          before = schema_snapshot(conn)
          run_migrations(conn)                       # second run must be a no-op
          assert schema_snapshot(conn) == before
      
      def test_migration_preserves_data(db_path):
          conn = connect(db_path)
          run_migrations(conn, target=3)
          conn.execute("INSERT INTO product (org, sku, price) VALUES ('acme','x',1.0)")
          run_migrations(conn, target=4)             # the migration under test
          row = conn.execute("SELECT org, sku FROM product").fetchone()
          assert (row["org"], row["sku"]) == ("acme", "x")
      
      def test_schema_is_valid_after_migration(db_path):
          conn = connect(db_path)
          run_migrations(conn)
          assert conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok"
          assert conn.execute("PRAGMA foreign_key_check").fetchall() == []
      
      
      def schema_snapshot(conn) -> list:
          return conn.execute(
              "SELECT type, name, sql FROM sqlite_master ORDER BY type, name").fetchall()
      ```
      
      The `foreign_key_check` assertion is the one that catches the 12-step recreate dance going
      wrong — a rebuilt table that dropped its references still looks fine until something reads
      across it. See [`migration-patterns.md`](migration-patterns.md).
      
      ---
      
      ## Testing concurrency
      
      Concurrency bugs need a **file** database and real connections.
      
      ```python
      import threading, sqlite3
      
      def test_concurrent_writers_do_not_error(db_path):
          """Two writers with busy_timeout should serialise, not raise."""
          errors = []
      
          def writer(tag):
              conn = connect(db_path)                # separate CONNECTION, not a shared one
              try:
                  for i in range(100):
                      conn.execute("BEGIN IMMEDIATE")
                      conn.execute("INSERT INTO event (kind) VALUES (?)", (tag,))
                      conn.execute("COMMIT")
              except sqlite3.OperationalError as exc:
                  errors.append(exc)
              finally:
                  conn.close()
      
          threads = [threading.Thread(target=writer, args=(f"t{i}",)) for i in range(4)]
          for t in threads: t.start()
          for t in threads: t.join()
          assert not errors, f"contention errors: {errors}"
      ```
      
      To test that your retry logic works, do the opposite: set `busy_timeout = 0`, force a
      conflict, and assert the retry wrapper recovers.
      
      ---
      
      ## Testing query plans
      
      Plans can regress silently — an added column turns a covering index non-covering, and
      nothing fails except latency. A plan assertion is a cheap regression guard for the small
      number of statements that genuinely matter.
      
      ```python
      def plan(conn, sql: str, params=()) -> str:
          return "\n".join(r["detail"]
                           for r in conn.execute("EXPLAIN QUERY PLAN " + sql, params))
      
      def test_org_lookup_uses_covering_index(db):
          detail = plan(db, "SELECT DISTINCT product_id FROM q_product WHERE org LIKE ?", ("%acme%",))
          assert "COVERING INDEX" in detail, detail      # the word COVERING is the whole test
          assert "USE TEMP B-TREE" not in detail, detail
      ```
      
      Keep these to the handful of statements you have actually optimised. Asserting plans across
      a whole codebase produces a brittle suite that fails on every legitimate schema change.
      
      `scripts/eqp-triage.py --db <file> --sql "<statement>"` exits `10` when it finds a problem,
      which makes it usable directly as a shell-level assertion in CI.
      
      ---
      
      ## Testing against D1
      
      | Approach | Fidelity | Note |
      |---|---|---|
      | Local SQLite with the same schema | Good for logic | No rows-read metric, no parameter cap, no `SQLITE_AUTH` restrictions |
      | `wrangler d1 execute` **without** `--remote` | Good | Local D1 copy — same wrangler surface, no network |
      | Miniflare / `wrangler dev` | Good | Exercises the Workers binding API too |
      | A preview/dev D1 database | Highest | The only place to verify platform behaviour (parameter caps, FTS5 availability) |
      
      **Never point tests at the production database.** For the platform-specific behaviours that
      only appear remotely — the 100-parameter cap, `SQLITE_AUTH` refusals, real `rows_read` — use
      a dedicated preview database and treat those as integration tests, run deliberately rather
      than on every commit.
      
      ---
      
      ## See also
      
      - [`hosts.md`](hosts.md) — the connection factory to share between prod and tests
      - [`migration-patterns.md`](migration-patterns.md) — what the migration tests are guarding
      - [`query-performance.md`](query-performance.md) — reading the plans you assert on
      - [`operations.md`](operations.md) — restore drills as a scheduled test
      
  • scripts
    • .gitkeep 0 B · in bundle
    • eqp-triage.py 13.1 KB
      #!/usr/bin/env python3
      """Triage a SQLite EXPLAIN QUERY PLAN: classify each plan line and suggest a fix.
      
      Usage:   eqp-triage.py [--db FILE --sql SQL | --plan-file FILE | -] [OPTIONS]
      Input:   argv (--db + --sql, or --plan-file), or a plan on stdin. Accepts raw
               sqlite3 CLI text, `sqlite3 -json`, or `wrangler d1 execute --json` output.
      Output:  stdout - findings, one per line: SEVERITY<TAB>CATEGORY<TAB>DETAIL<TAB>FIX
               (or the claude-mods.sqlite-ops.eqp/v1 envelope under --json)
      Stderr:  headers, progress, warnings, errors
      Exit:    0 clean, 2 usage, 3 file-not-found, 4 invalid-input/SQL-error,
               5 missing-dep, 10 findings at or above the reporting threshold
      
      Examples:
        eqp-triage.py --db app.db --sql "SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%acme%'"
        sqlite3 app.db 'EXPLAIN QUERY PLAN SELECT * FROM t WHERE a=1;' | eqp-triage.py
        wrangler d1 execute mydb --remote --json --command "EXPLAIN QUERY PLAN SELECT ..." | eqp-triage.py
        eqp-triage.py --db app.db --sql "SELECT ..." --json | jq '.data[]'
        eqp-triage.py --plan-file plan.txt --strict     # also fail on low-severity findings
      """
      
      import argparse
      import json
      import os
      import re
      import sys
      
      SCHEMA = "claude-mods.sqlite-ops.eqp/v1"
      
      EXIT_OK = 0
      EXIT_USAGE = 2
      EXIT_NOT_FOUND = 3
      EXIT_VALIDATION = 4
      EXIT_MISSING_DEP = 5
      EXIT_FINDINGS = 10
      
      SEVERITY_ORDER = {"info": 0, "low": 1, "medium": 2, "high": 3}
      
      # Rules are evaluated in order; the FIRST match wins, so the most specific
      # patterns must come first. In particular COVERING INDEX must be tested before
      # the bare "SCAN ... USING INDEX" rule, because a covering scan is acceptable
      # while a non-covering one usually means the index is not earning its place.
      RULES = [
          (
              re.compile(r"\bSEARCH\b.*\bUSING COVERING INDEX\b", re.I),
              "info", "covering-seek",
              "Seek answered entirely from the index - the table is never read. Best case.",
          ),
          (
              re.compile(r"\bSEARCH\b.*\bUSING INTEGER PRIMARY KEY\b", re.I),
              "info", "rowid-seek",
              "Direct rowid lookup. Best case.",
          ),
          (
              re.compile(r"\bSEARCH\b.*\bUSING (?:INDEX|AUTOMATIC)\b", re.I),
              "info", "index-seek",
              "B-tree seek. Fine. Consider covering the projected columns if the row is wide.",
          ),
          (
              re.compile(r"\bSCAN\b.*\bUSING COVERING INDEX\b", re.I),
              "low", "covering-scan",
              "Full pass over narrow index entries, table never read. Often the right answer "
              "for an unseekable predicate; reduces latency but usually NOT rows-read.",
          ),
          (
              re.compile(r"\bSCAN\b.*\bUSING (?:INDEX|AUTOMATIC (?:COVERING )?INDEX)\b", re.I),
              "high", "noncovering-scan",
              "Every index entry read AND a table row fetched per hit - the index buys little. "
              "Extend it to cover the projected columns (filtered column first, projected "
              "second), or drop it.",
          ),
          (
              re.compile(r"\bCORRELATED\b", re.I),
              "high", "correlated-subquery",
              "Subquery re-executed once per outer row. Rewrite as a JOIN or a windowed "
              "aggregate.",
          ),
          (
              re.compile(r"\bSCAN\b", re.I),
              "high", "table-scan",
              "Full table scan. Add an index matching the WHERE/JOIN, or - if the predicate "
              "cannot be seeked (leading-wildcard LIKE, function on the column) - make the scan "
              "covering or move to FTS5 trigram.",
          ),
          (
              re.compile(r"\bUSE TEMP B-TREE FOR (?:RIGHT PART OF )?ORDER BY\b", re.I),
              "medium", "temp-btree-order",
              "Sorting because no index supplies the order. A composite index ending in the "
              "sort column removes it. Re-check AFTER any index change - it often clears itself.",
          ),
          (
              re.compile(r"\bUSE TEMP B-TREE FOR GROUP BY\b", re.I),
              "medium", "temp-btree-group",
              "Grouping without an index supplying the order. Re-check AFTER any index change - "
              "adding a covering index frequently removes this on its own.",
          ),
          (
              re.compile(r"\bUSE TEMP B-TREE FOR DISTINCT\b", re.I),
              "medium", "temp-btree-distinct",
              "De-duplicating in a temp B-tree. An index covering the DISTINCT columns removes it.",
          ),
          (
              re.compile(r"\bUSE TEMP B-TREE\b", re.I),
              "medium", "temp-btree",
              "A temporary B-tree is being built. Check which clause needs it and whether an "
              "index can supply that order.",
          ),
      ]
      
      # Informational plan lines that are never findings on their own.
      BENIGN = re.compile(
          r"^\s*(QUERY PLAN|MULTI-INDEX OR|INDEX \d+|BLOOM FILTER|MATERIALIZE|CO-ROUTINE|"
          r"LIST SUBQUERY|SCALAR SUBQUERY|USING (?:ROWID SEARCH|INDEX FOR)|RIGHT-JOIN|"
          r"MERGE|LEFT-JOIN|COMPOUND QUERY|UNION|EXCEPT|INTERSECT|RECURSIVE)",
          re.I,
      )
      
      # Strip sqlite3's tree drawing and the legacy "0|0|0|" column prefix.
      TREE_PREFIX = re.compile(r"^[\s|`+\-]*")
      LEGACY_PREFIX = re.compile(r"^\d+\|\d+\|\d+\|")
      
      # Vocabulary a genuine EQP line uses. Text input is filtered against this so
      # that arbitrary text (a stray log, the wrong command's output) is reported as
      # invalid input rather than silently triaged as "clean" - a false all-clear is
      # the worst possible outcome for a tool whose job is finding problems.
      PLAN_VOCAB = re.compile(
          r"\b(SCAN|SEARCH|USE TEMP B-TREE|CO-ROUTINE|SUBQUERY|MATERIALIZE|"
          r"MULTI-INDEX OR|BLOOM FILTER|COMPOUND QUERY|UNION|EXCEPT|INTERSECT|"
          r"RECURSIVE|MERGE|LEFT-JOIN|RIGHT-JOIN|USING (?:INDEX|COVERING|ROWID|"
          r"INTEGER PRIMARY KEY)|CORRELATED)\b",
          re.I,
      )
      
      
      def warn(message):
          """Human-facing output goes to stderr; stdout stays a clean data stream."""
          print(message, file=sys.stderr)
      
      
      def collect_details(node, out):
          """Recursively pull every 'detail' string out of decoded JSON.
      
          Handles both `sqlite3 -json` ([{detail: ...}]) and wrangler's
          [{results: [{detail: ...}], meta: {...}}] shape without special-casing either.
          """
          if isinstance(node, dict):
              detail = node.get("detail")
              if isinstance(detail, str):
                  out.append(detail)
              for value in node.values():
                  collect_details(value, out)
          elif isinstance(node, list):
              for value in node:
                  collect_details(value, out)
      
      
      def parse_plan(text):
          """Return a list of plan detail strings from JSON or raw sqlite3 CLI text."""
          stripped = text.strip()
          if not stripped:
              return []
      
          if stripped[0] in "[{":
              try:
                  details = []
                  collect_details(json.loads(stripped), details)
                  if details:
                      return details
              except (ValueError, RecursionError):
                  pass  # not JSON after all - fall through to text parsing
      
          lines = []
          for raw in stripped.splitlines():
              line = LEGACY_PREFIX.sub("", raw.strip())
              line = TREE_PREFIX.sub("", line).strip()
              if not line or line.upper() == "QUERY PLAN":
                  continue
              if not PLAN_VOCAB.search(line):
                  continue  # not a plan line - see PLAN_VOCAB
              lines.append(line)
          return lines
      
      
      def classify(detail):
          """Return (severity, category, fix) for one plan line, or None if benign."""
          for pattern, severity, category, fix in RULES:
              if pattern.search(detail):
                  return severity, category, fix
          if BENIGN.search(detail):
              return None
          return None
      
      
      def run_plan(db_path, sql):
          """Run EXPLAIN QUERY PLAN against a database using Python's bundled sqlite3."""
          try:
              import sqlite3
          except ImportError:  # pragma: no cover - stdlib module absent is a broken build
              warn("error: Python's sqlite3 module is unavailable in this interpreter")
              sys.exit(EXIT_MISSING_DEP)
      
          if not os.path.isfile(db_path):
              warn("error: database not found: %s" % db_path)
              sys.exit(EXIT_NOT_FOUND)
      
          # Read-only URI: this script must never be able to modify the database it
          # is asked to analyse, even if handed a statement with side effects.
          uri = "file:%s?mode=ro" % db_path.replace("?", "%3f").replace("#", "%23")
          try:
              conn = sqlite3.connect(uri, uri=True)
          except sqlite3.Error as exc:
              warn("error: cannot open database: %s" % exc)
              sys.exit(EXIT_VALIDATION)
      
          try:
              rows = conn.execute("EXPLAIN QUERY PLAN " + sql).fetchall()
          except sqlite3.Error as exc:
              warn("error: %s" % exc)
              sys.exit(EXIT_VALIDATION)
          finally:
              conn.close()
      
          # EQP rows are (id, parent, notused, detail); detail is always last.
          return [str(row[-1]) for row in rows]
      
      
      def main(argv=None):
          parser = argparse.ArgumentParser(
              prog="eqp-triage.py",
              description="Triage a SQLite EXPLAIN QUERY PLAN and suggest fixes.",
              epilog=(
                  "EXAMPLES:\n"
                  "  eqp-triage.py --db app.db --sql \"SELECT * FROM t WHERE a LIKE '%x%'\"\n"
                  "  sqlite3 app.db 'EXPLAIN QUERY PLAN SELECT * FROM t;' | eqp-triage.py\n"
                  "  wrangler d1 execute db --remote --json --command \"EXPLAIN QUERY PLAN "
                  "SELECT ...\" | eqp-triage.py\n"
                  "  eqp-triage.py --db app.db --sql 'SELECT ...' --json | jq '.data[]'\n"
              ),
              formatter_class=argparse.RawDescriptionHelpFormatter,
          )
          parser.add_argument("stdin_marker", nargs="?", default=None,
                              help="'-' to read the plan from stdin (the default when piped)")
          parser.add_argument("--db", help="SQLite database file to run the plan against")
          parser.add_argument("--sql", help="Statement to explain (requires --db)")
          parser.add_argument("--plan-file", help="File containing captured plan output")
          parser.add_argument("--json", action="store_true",
                              help="Emit the claude-mods.sqlite-ops.eqp/v1 envelope on stdout")
          parser.add_argument("--strict", action="store_true",
                              help="Exit 10 on low-severity findings too (default: medium+)")
          parser.add_argument("--quiet", action="store_true",
                              help="Suppress stderr headers; findings still go to stdout")
      
          args, extra = parser.parse_known_args(argv)
          if extra:
              parser.print_usage(sys.stderr)
              warn("error: unrecognised arguments: %s" % " ".join(extra))
              return EXIT_USAGE
          if args.stdin_marker not in (None, "-"):
              parser.print_usage(sys.stderr)
              warn("error: unexpected positional argument: %s" % args.stdin_marker)
              return EXIT_USAGE
          if args.sql and not args.db:
              warn("error: --sql requires --db")
              return EXIT_USAGE
          if args.db and not args.sql:
              warn("error: --db requires --sql")
              return EXIT_USAGE
          if args.db and args.plan_file:
              warn("error: --db/--sql and --plan-file are mutually exclusive")
              return EXIT_USAGE
      
          # --- acquire the plan ---
          source = None
          if args.db:
              details = run_plan(args.db, args.sql)
              source = args.db
          elif args.plan_file:
              if not os.path.isfile(args.plan_file):
                  warn("error: plan file not found: %s" % args.plan_file)
                  return EXIT_NOT_FOUND
              with open(args.plan_file, "r", encoding="utf-8", errors="replace") as handle:
                  details = parse_plan(handle.read())
              source = args.plan_file
          else:
              if sys.stdin is None or sys.stdin.isatty():
                  parser.print_usage(sys.stderr)
                  warn("error: no input - pass --db/--sql, --plan-file, or pipe a plan on stdin")
                  return EXIT_USAGE
              details = parse_plan(sys.stdin.read())
              source = "stdin"
      
          if not details:
              warn("error: no EXPLAIN QUERY PLAN lines found in input from %s" % source)
              return EXIT_VALIDATION
      
          # --- classify ---
          findings = []
          for detail in details:
              verdict = classify(detail)
              if verdict is None:
                  continue
              severity, category, fix = verdict
              findings.append({
                  "severity": severity,
                  "category": category,
                  "detail": detail,
                  "fix": fix,
              })
      
          findings.sort(key=lambda f: -SEVERITY_ORDER[f["severity"]])
          threshold = SEVERITY_ORDER["low" if args.strict else "medium"]
          actionable = [f for f in findings if SEVERITY_ORDER[f["severity"]] >= threshold]
      
          # --- report ---
          if args.json:
              print(json.dumps({
                  "data": findings,
                  "meta": {
                      "count": len(findings),
                      "actionable": len(actionable),
                      "plan_lines": len(details),
                      "source": source,
                      "threshold": "low" if args.strict else "medium",
                      "schema": SCHEMA,
                  },
              }, indent=2))
          else:
              if not args.quiet:
                  warn("eqp-triage  %d plan line(s) from %s" % (len(details), source))
              for finding in findings:
                  print("%s\t%s\t%s\t%s" % (
                      finding["severity"].upper(), finding["category"],
                      finding["detail"], finding["fix"]))
              if not args.quiet:
                  if actionable:
                      warn("  %d actionable finding(s) at or above %s severity"
                           % (len(actionable), "low" if args.strict else "medium"))
                  else:
                      warn("  no actionable findings")
      
          return EXIT_FINDINGS if actionable else EXIT_OK
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
  • tests
    • run.sh 14.7 KB
      #!/usr/bin/env bash
      # Self-test for sqlite-ops: frontmatter contract, reference wiring, script behaviour.
      #
      # Fully offline and self-contained - the only external need is a working Python
      # (stdlib sqlite3), which every supported platform has. Fixtures are synthesized
      # in a temp dir, so no binary fixtures live in the repo.
      #
      # Usage:   bash tests/run.sh
      # Exit:    0 all pass, 1 one or more failures
      
      set -uo pipefail
      
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      SKILL="$(dirname "$HERE")"
      S="$SKILL/scripts"
      R="$SKILL/references"
      MD="$SKILL/SKILL.md"
      
      # Windows Store python3 is a stub that exits non-zero - probe for one that runs.
      PYTHON=""
      for c in python python3 py; do
        if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then PYTHON="$c"; break; fi
      done
      if [[ -z "$PYTHON" ]]; then
        echo "  SKIP  no working python found - sqlite-ops suite not run" >&2
        exit 0
      fi
      
      SB="$(mktemp -d)"; trap 'rm -rf "$SB"' EXIT
      PASS=0; FAIL=0
      ok() { PASS=$((PASS+1)); printf '  PASS  %s\n' "$1"; }
      no() { FAIL=$((FAIL+1)); printf '  FAIL  %s\n' "$1"; }
      expect_exit() { [[ "$2" == "$3" ]] && ok "$1 (exit $3)" || no "$1 (want $2 got $3)"; }
      expect_has()  { case "$3" in *"$2"*) ok "$1";; *) no "$1 (missing '$2')";; esac; }
      
      # Case-insensitive literal file search WITHOUT grep. GNU grep 3.0 (the build
      # shipped with Git Bash on Windows) ABORTS with SIGABRT on `-i` combined with
      # `-F` - it exits 134, which reads as "no match" and silently fails every
      # assertion. Pure-bash matching sidesteps the bug and is portable.
      file_has() { # $1=label $2=needle $3=file
        local hay needle
        hay="$(tr '[:upper:]' '[:lower:]' < "$3")"
        needle="$(printf '%s' "$2" | tr '[:upper:]' '[:lower:]')"
        case "$hay" in *"$needle"*) ok "$1";; *) no "$1 (missing '$2')";; esac
      }
      
      echo "=== sqlite-ops self-test ==="
      
      # ── frontmatter contract ────────────────────────────────────────────────────
      # CONTRACT: these assertions police this skill's OWN frontmatter. The skill was
      # de-Pythonised on 2026-08-04 precisely because a Python-pinned description and
      # compatibility line suppressed it in TypeScript/Worker contexts. If you edit the
      # frontmatter, keep it engine-agnostic or these fail on purpose.
      echo "-- frontmatter --"
      fm="$(sed -n '2,/^---$/p' "$MD")"
      
      expect_has "name is sqlite-ops"            "name: sqlite-ops" "$fm"
      expect_has "license MIT"                   "license: MIT"     "$fm"
      expect_has "metadata.author claude-mods"   "author: claude-mods" "$fm"
      
      # related-skills must be a comma-separated STRING under metadata, never an array
      # (naming-conventions.md + SKILL-SUBAGENT-REFERENCE.md). doc-drift.sh also checks
      # that every skill named here exists on disk.
      expect_has "related-skills present"        "related-skills:" "$fm"
      case "$fm" in
        *"related-skills: \""*) ok "related-skills is a quoted string, not an array";;
        *"related-skills: ["*)  no "related-skills is a YAML array (must be a comma-separated string)";;
        *) no "related-skills not in the expected string form";;
      esac
      for peer in sql-ops perf-ops cloudflare-ops postgres-ops; do
        expect_has "related-skills names $peer" "$peer" "$fm"
      done
      
      # The de-Pythonisation guard: description and compatibility must NOT scope the
      # skill to Python. A Python-only description hides this skill from Worker/TS work.
      case "$fm" in
        *"in Python projects"*) no "description still scopes the skill to Python projects";;
        *)                      ok "description is not scoped to Python projects";;
      esac
      case "$fm" in
        *"compatibility: \"Requires Python"*) no "compatibility still pins the skill to Python";;
        *)                                    ok "compatibility does not pin the skill to Python";;
      esac
      expect_has "compatibility states engine-agnostic guidance" "engine-agnostic" "$fm"
      
      # Description budget: tests/validate.sh HARD-FAILS over 700 chars for
      # description + when_to_use combined. Catch it here rather than at the repo gate.
      budget="$("$PYTHON" - "$MD" <<'PY'
      import sys, pathlib
      lines = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8-sig").splitlines()
      marks = [i for i, l in enumerate(lines) if l.strip() == "---"]
      body = "\n".join(lines[marks[0] + 1:marks[1]])
      try:
          import yaml
          fm = yaml.safe_load(body) or {}
          total = len(str(fm.get("description") or "")) + len(str(fm.get("when_to_use") or ""))
      except ImportError:
          # Fallback: measure the raw single-line values without a YAML parser.
          total = 0
          for line in body.splitlines():
              for key in ("description:", "when_to_use:"):
                  if line.startswith(key):
                      total += len(line[len(key):].strip().strip('"').strip("'"))
      print(total)
      PY
      )"
      if [[ -n "$budget" && "$budget" -le 700 ]]; then
        ok "description budget ${budget}/700 chars"
      else
        no "description budget ${budget}/700 chars (validate.sh hard-fails over 700)"
      fi
      
      # ── trigger keywords ────────────────────────────────────────────────────────
      # These are the terms that must route a performance/D1 question here. They were
      # absent before 2026-08-04, which is why a live D1 investigation never loaded
      # this skill. Removing one silently un-routes that class of question.
      echo "-- description triggers --"
      desc_line="$(grep -m1 '^description:' "$MD")"
      for trigger in "EXPLAIN QUERY PLAN" "covering index" "rows_read" "sql_duration_ms" \
                     "D1" "wrangler d1" "node:sqlite" "better-sqlite3" "fts5" "trigram" \
                     "sqlite_stat1" "ANALYZE" "SQLITE_BUSY" "WAL" "STRICT tables"; do
        case "$desc_line" in
          *"$trigger"*) ok "trigger: $trigger";;
          *)            no "trigger MISSING from description: $trigger";;
        esac
      done
      
      # ── references exist and are cited ──────────────────────────────────────────
      echo "-- references --"
      for ref in query-performance d1-edge d1-production-patterns concurrency-durability \
                 schema-design schema-patterns migration-patterns feature-modules hosts \
                 async-patterns operations testing; do
        if [[ -f "$R/$ref.md" ]]; then ok "reference exists: $ref.md"; else no "reference MISSING: $ref.md"; fi
        # SKILL-RESOURCE-PROTOCOL: an uncited reference is dead weight the router never finds.
        file_has "SKILL.md cites $ref.md" "references/$ref.md" "$MD"
      done
      
      # ── measured facts that must not silently vanish ────────────────────────────
      # These numbers come from a live D1 investigation (2026-08-04) and are the
      # evidence behind the skill's core lesson. They are labelled as one database's
      # worked example, NOT as constants - but if an edit drops them, the reasoning
      # loses its grounding, so they are pinned here.
      echo "-- worked-example facts --"
      file_has "covering-index before figure (171.83 ms)" "171.83" "$R/query-performance.md"
      file_has "covering-index after figure (6.75 ms)"    "6.75"   "$R/query-performance.md"
      file_has "invisible-aggregate figure (28.09 ms)"    "28.09"  "$R/query-performance.md"
      file_has "numbers labelled as a worked example"     "worked example" "$R/query-performance.md"
      file_has "read-only proof technique documented"     "read-only proof" "$R/query-performance.md"
      file_has "SCAN vs SEARCH distinction"               "COVERING INDEX" "$R/query-performance.md"
      file_has "sqlite_stat1 with/without check"          "sqlite_stat1" "$R/query-performance.md"
      
      echo "-- d1 facts --"
      file_has "rows_read documented"          "rows_read"          "$R/d1-edge.md"
      file_has "sql_duration_ms documented"    "sql_duration_ms"    "$R/d1-edge.md"
      file_has "one-line statement rule"       "SQLITE_ERROR 7500"  "$R/d1-edge.md"
      file_has "100 bound-parameter cap"       "100"                "$R/d1-edge.md"
      file_has "SQLITE_AUTH introspection block" "SQLITE_AUTH"      "$R/d1-edge.md"
      # FTS5 on D1 was NOT confirmable read-only. Recording it as unknown is the honest
      # result; an edit that replaces this with a confident claim is a regression.
      file_has "FTS5-on-D1 recorded as unknown" "could not be confirmed read-only" "$R/d1-edge.md"
      file_has "d1 insights covered"           "d1 insights"        "$R/d1-edge.md"
      file_has "Sessions API / replication"    "withSession"        "$R/d1-edge.md"
      file_has "Time Travel covered"           "time-travel"        "$R/d1-edge.md"
      file_has "cold-run variance figure"      "2,495"              "$R/d1-edge.md"
      
      echo "-- engine-agnostic coverage --"
      file_has "concurrency: BUSY vs LOCKED"   "SQLITE_LOCKED"      "$R/concurrency-durability.md"
      file_has "concurrency: BEGIN IMMEDIATE"  "BEGIN IMMEDIATE"    "$R/concurrency-durability.md"
      file_has "schema: foreign_keys OFF by default" "OFF by default" "$R/schema-design.md"
      file_has "schema: STRICT tables"         "STRICT"             "$R/schema-design.md"
      file_has "migrations: 12-step dance"     "12-step"            "$R/migration-patterns.md"
      file_has "features: trigram tokenizer"   "trigram"            "$R/feature-modules.md"
      file_has "hosts: node:sqlite"            "node:sqlite"        "$R/hosts.md"
      file_has "hosts: bun:sqlite"             "bun:sqlite"         "$R/hosts.md"
      file_has "operations: VACUUM INTO"       "VACUUM INTO"        "$R/operations.md"
      file_has "testing: deterministic seeding" "deterministic"     "$R/testing.md"
      
      # ── script contract (SKILL-RESOURCE-PROTOCOL) ───────────────────────────────
      echo "-- eqp-triage.py contract --"
      "$PYTHON" -m py_compile "$S/eqp-triage.py" 2>/dev/null && ok "py_compile eqp-triage.py" \
                                                             || no "py_compile eqp-triage.py"
      "$PYTHON" "$S/eqp-triage.py" --help >/dev/null 2>&1; expect_exit "--help" 0 $?
      help_out="$("$PYTHON" "$S/eqp-triage.py" --help 2>/dev/null)"
      expect_has "--help has EXAMPLES" "EXAMPLES" "$help_out"
      file_has "SKILL.md cites the script with a worked invocation" "eqp-triage.py --db" "$MD"
      
      echo "-- eqp-triage.py exit codes --"
      "$PYTHON" "$S/eqp-triage.py" --bogus-flag </dev/null >/dev/null 2>&1
      expect_exit "unknown flag -> 2" 2 $?
      "$PYTHON" "$S/eqp-triage.py" --sql "SELECT 1" </dev/null >/dev/null 2>&1
      expect_exit "--sql without --db -> 2" 2 $?
      "$PYTHON" "$S/eqp-triage.py" --db "$SB/nope.db" --sql "SELECT 1" </dev/null >/dev/null 2>&1
      expect_exit "missing database -> 3" 3 $?
      "$PYTHON" "$S/eqp-triage.py" --plan-file "$SB/nope.txt" </dev/null >/dev/null 2>&1
      expect_exit "missing plan file -> 3" 3 $?
      printf 'not a plan at all\n' | "$PYTHON" "$S/eqp-triage.py" >/dev/null 2>&1
      expect_exit "unparseable input -> 4" 4 $?
      
      # ── behavioural: real plans against a synthesized database ──────────────────
      echo "-- eqp-triage.py behaviour --"
      "$PYTHON" - "$SB/t.db" <<'PY'
      import sqlite3, sys
      conn = sqlite3.connect(sys.argv[1])
      conn.execute("CREATE TABLE q_product (id INTEGER PRIMARY KEY, org TEXT, "
                   "product_id TEXT, pad TEXT)")
      conn.execute("CREATE INDEX q_product_org ON q_product(org)")
      conn.executemany("INSERT INTO q_product (org, product_id, pad) VALUES (?,?,?)",
                       [("org%d" % (i % 50), "p%d" % i, "x" * 200) for i in range(2000)])
      conn.commit(); conn.close()
      PY
      [[ -f "$SB/t.db" ]] && ok "fixture database synthesized" || no "fixture database synthesized"
      
      # Leading-wildcard LIKE over a non-covering index: the exact shape from the
      # worked example. Must be flagged HIGH and exit 10.
      out="$("$PYTHON" "$S/eqp-triage.py" --db "$SB/t.db" --quiet \
              --sql "SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%org1%'" 2>/dev/null)"
      rc=$?
      expect_exit "non-covering scan -> 10" 10 "$rc"
      expect_has  "flags the scan as HIGH" "HIGH" "$out"
      
      # Add the covering index; the scan must be reclassified as LOW (acceptable).
      "$PYTHON" -c "import sqlite3,sys; c=sqlite3.connect(sys.argv[1]); \
      c.execute('CREATE INDEX q_org_prod ON q_product(org, product_id)'); c.commit()" "$SB/t.db"
      out="$("$PYTHON" "$S/eqp-triage.py" --db "$SB/t.db" --quiet \
              --sql "SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%org1%'" 2>/dev/null)"
      expect_has "covering scan classified LOW" "LOW	covering-scan" "$out"
      
      # An indexed equality seek is clean: no actionable findings, exit 0.
      "$PYTHON" "$S/eqp-triage.py" --db "$SB/t.db" --quiet \
        --sql "SELECT product_id FROM q_product WHERE org = 'x'" >/dev/null 2>&1
      expect_exit "indexed seek -> 0" 0 $?
      
      # Invalid SQL is a validation error, not a crash.
      "$PYTHON" "$S/eqp-triage.py" --db "$SB/t.db" --sql "SELECT FROM WHERE" >/dev/null 2>&1
      expect_exit "bad SQL -> 4" 4 $?
      
      # The script must never be able to write to the database it analyses.
      "$PYTHON" "$S/eqp-triage.py" --db "$SB/t.db" --quiet \
        --sql "DELETE FROM q_product" >/dev/null 2>&1
      rc=$?
      rows="$("$PYTHON" -c "import sqlite3,sys; print(sqlite3.connect(sys.argv[1]).execute(
        'SELECT count(*) FROM q_product').fetchone()[0])" "$SB/t.db")"
      [[ "$rows" == "2000" ]] && ok "read-only guard: rows intact after a DELETE statement" \
                              || no "read-only guard FAILED: row count now $rows (want 2000)"
      
      echo "-- eqp-triage.py input formats --"
      # Raw sqlite3 CLI text with tree-drawing characters
      out="$(printf 'QUERY PLAN\n|--SCAN q_product\n`--USE TEMP B-TREE FOR GROUP BY\n' \
              | "$PYTHON" "$S/eqp-triage.py" --quiet 2>/dev/null)"
      expect_has "parses raw sqlite3 tree output" "table-scan" "$out"
      expect_has "detects temp B-tree for GROUP BY" "temp-btree-group" "$out"
      # Legacy pipe-delimited format
      out="$(printf '0|0|0|SCAN TABLE q_product\n' | "$PYTHON" "$S/eqp-triage.py" --quiet 2>/dev/null)"
      expect_has "parses legacy 0|0|0| format" "table-scan" "$out"
      # wrangler d1 execute --json shape
      out="$(printf '[{"results":[{"id":2,"parent":0,"detail":"SCAN q_product USING INDEX q_product_org"}],"success":true,"meta":{"rows_read":58433}}]' \
              | "$PYTHON" "$S/eqp-triage.py" --json 2>/dev/null)"
      expect_has "parses wrangler --json output" "noncovering-scan" "$out"
      expect_has "--json emits the versioned schema" '"schema": "claude-mods.sqlite-ops.eqp/v1"' "$out"
      expect_has "--json envelope has data key" '"data"' "$out"
      expect_has "--json envelope has meta key" '"meta"' "$out"
      
      # --strict promotes low-severity findings to actionable
      printf 'SCAN t USING COVERING INDEX ix\n' | "$PYTHON" "$S/eqp-triage.py" --quiet >/dev/null 2>&1
      expect_exit "covering scan alone -> 0 by default" 0 $?
      printf 'SCAN t USING COVERING INDEX ix\n' | "$PYTHON" "$S/eqp-triage.py" --quiet --strict >/dev/null 2>&1
      expect_exit "covering scan -> 10 under --strict" 10 $?
      
      # Stream separation: with --quiet, stdout carries findings only.
      so="$(printf 'SCAN t\n' | "$PYTHON" "$S/eqp-triage.py" --quiet 2>/dev/null)"
      case "$so" in
        HIGH*) ok "stdout is data-only under --quiet";;
        *)     no "stdout polluted with non-data output: $so";;
      esac
      
      echo ""
      echo "=== $PASS passed, $FAIL failed ==="
      [[ "$FAIL" -eq 0 ]] || exit 1
      exit 0
      
  • SKILL.md 20 KB
    ---
    name: sqlite-ops
    description: "SQLite across every host and engine - query performance, concurrency, schema, feature modules, operations. Triggers on: sqlite, slow query, EXPLAIN QUERY PLAN, query plan, SCAN vs SEARCH, covering index, index not used, rows read, rows_read, sql_duration_ms, ANALYZE, sqlite_stat1, LIKE performance, database is locked, SQLITE_BUSY, WAL, busy_timeout, STRICT tables, type affinity, foreign_keys, VACUUM, integrity_check, fts5, trigram, json_extract, D1, cloudflare d1, wrangler d1, node:sqlite, better-sqlite3, bun:sqlite, aiosqlite, libsql, turso, migration, d1 batch, read replication, sessions api, d1 bookmark, migration timeout."
    license: MIT
    compatibility: "Guidance is engine-agnostic (SQLite 3.x semantics). Examples are labelled by host: sqlite3 CLI, Python sqlite3/aiosqlite, node:sqlite/better-sqlite3/bun:sqlite, Cloudflare D1 via wrangler, libSQL/Turso. scripts/eqp-triage.py needs Python 3.8+ (stdlib only)."
    allowed-tools: "Read Write Bash"
    metadata:
      author: claude-mods
      related-skills: "sql-ops, perf-ops, cloudflare-ops, postgres-ops"
    ---
    
    # SQLite Operations
    
    SQLite is one engine with many hosts. The **SQL semantics, query planner, and pragmas are
    the same** whether you reach it through the `sqlite3` CLI, Python, `node:sqlite`,
    better-sqlite3, Bun, Cloudflare D1, or libSQL/Turso — what differs is the *driver surface*
    and the *operational envelope* (who owns the file, what a "connection" costs, whether you
    can even run `PRAGMA`). Reason about the engine first; then check the host section for the
    traps that differ.
    
    ```
    Where does the problem live?
    │
    ├─ A statement is slow, or scans too much
    │  └─ EXPLAIN QUERY PLAN first, always → references/query-performance.md
    │
    ├─ "database is locked" / SQLITE_BUSY / writers blocking readers
    │  └─ WAL + busy_timeout + BEGIN IMMEDIATE → references/concurrency-durability.md
    │
    ├─ Wrong data got in, or a constraint didn't fire
    │  └─ Type affinity, STRICT, foreign_keys=OFF → references/schema-design.md
    │
    ├─ Search / JSON / geo / analytics feature question
    │  └─ FTS5, JSON, R-tree, window fns → references/feature-modules.md
    │
    ├─ Running on a managed/edge engine (D1, Turso)
    │  └─ references/d1-edge.md + references/hosts.md
    │
    └─ Corruption, size, backup, VACUUM
       └─ references/operations.md
    ```
    
    ## Measurement discipline (read this before optimising anything)
    
    Most SQLite "optimisations" are unmeasured. Four rules, in order of how often they are
    broken:
    
    1. **Measure the statement, not the tool call.** An expensive aggregate that ships inside
       a batch another query was already sending costs *no extra round trip* and is therefore
       invisible to per-call timing — while still scanning the whole table on every request.
       Decompose multi-part statements and time each part separately.
    2. **Report latency AND rows scanned.** They move independently. An optimisation can cut
       latency ~25x while leaving rows-read essentially unchanged (and on a billed engine like
       D1, rows read is the money metric — see `references/d1-edge.md`).
    3. **Never trust wall-clock time from a CLI.** Process startup dominates. Use the engine's
       own reported duration (`.timer on` in the CLI, `meta.timings.sql_duration_ms` on D1).
    4. **Take a median of 10+ runs and report the range.** First runs are cold. In one measured
       session a cold run hit 2,495 ms against a 171 ms median on the same statement — a
       1.5–1.7x first-run penalty was routine on multi-thousand-row reads.
    
    ```bash
    # sqlite3 CLI: engine-reported timing, not shell time
    sqlite3 app.db '.timer on' "SELECT count(*) FROM q_product WHERE org LIKE '%acme%';"
    
    # What the planner thinks the data looks like (empty = ANALYZE never ran)
    sqlite3 app.db 'SELECT * FROM sqlite_stat1;'
    ```
    
    ### Prove an index will help *before* you create it
    
    The highest-leverage trick in this skill, and the one that keeps schema work inside a
    deploy gate: **run the identical statement shape against a column an existing index
    already covers.** Same table, same row count, same predicate shape — only the column
    changes. The difference is your projected payoff, measured on live production data with
    **zero schema writes**.
    
    ```sql
    -- Hypothesis: a covering index on (org, product_id) makes this fast.
    -- Unindexed control (what you have today):
    SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%acme%';
    
    -- Proof shot: same shape, over a column an existing index already covers.
    -- If this is fast, the index is worth writing. If it isn't, the index is not your problem.
    SELECT DISTINCT org FROM q_product WHERE org LIKE '%acme%';
    ```
    
    In the worked example below the proof shot returned 6.75 ms against a 171.83 ms control —
    enough to justify the index without touching production schema.
    
    ## EXPLAIN QUERY PLAN — the 60-second read
    
    `EXPLAIN QUERY PLAN` (EQP) is the first command for any slow statement. It is cheap, safe,
    read-only, and available on every host that lets you run arbitrary SQL.
    
    ```sql
    EXPLAIN QUERY PLAN
    SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%acme%';
    ```
    
    | Plan line | Means | Verdict |
    |---|---|---|
    | `SEARCH t USING INDEX ix (col=?)` | B-tree seek, touches matching rows only | Best case |
    | `SEARCH t USING COVERING INDEX ix` | Seek, and every needed column is in the index — table never read | Best case |
    | `SCAN t USING COVERING INDEX ix` | Full pass, but over narrow index entries, not wide rows | Often fine — see below |
    | `SCAN t USING INDEX ix` | Full pass over the index **and** a row fetch per hit | Suspicious: the index is buying little |
    | `SCAN t` | Full table scan | Fix it, unless the table is tiny |
    | `USE TEMP B-TREE FOR ORDER BY` | Sorting because no index supplies the order | Cost signal |
    | `USE TEMP B-TREE FOR GROUP BY` | Same, for grouping | Cost signal |
    | `CORRELATED SCALAR SUBQUERY` | Subquery re-executed per outer row | Usually the whole problem |
    
    **The distinction that matters most:** `SCAN … USING COVERING INDEX` is not a failure.
    A covering scan reads narrow index entries instead of paging in wide rows, which is exactly
    how you make an *unseekable* predicate fast.
    
    **Deep dive**: `./references/query-performance.md` — index design, column order, partial and
    expression indexes, ANALYZE/`sqlite_stat1`, and the full catalogue of planner defeats.
    
    ### The unseekable-predicate trap (worked example)
    
    A leading-wildcard `LIKE '%x%'` can **never** use a B-tree — SQLite optimises `LIKE` only
    for an anchored prefix (`'x%'`). So a plain index on that column changes nothing, people
    observe no improvement, and conclude "indexing didn't help here". The index wasn't wrong;
    the *shape* was. The fix is to make the scan **covering**, so the unavoidable full pass
    reads narrow index entries instead of wide rows.
    
    ```sql
    -- Column order is load-bearing: FILTERED column first, PROJECTED column second.
    CREATE INDEX q_product_org_product ON q_product(org, product_id);
    ```
    
    > **Worked example — one database, not a constant.** Measured 2026-08-04 against a live
    > Cloudflare D1 (`atdw-mirror`, region OC, colo SYD), 12 runs each, median of server-side
    > `sql_duration_ms`; 73-column table, 58k rows.
    > Before: `SCAN q_product USING INDEX q_product_org`, **171.83 ms**, 60,736 rows read.
    > The identical statement shape over an already-covered column: **6.75 ms**, 58,433 rows
    > read. **~25x faster with rows-read essentially unchanged** — proof that the win came from
    > row width, not from touching fewer rows. Your table's numbers will differ; the *shape* of
    > the result is what transfers.
    >
    > Two further findings from the same session worth internalising:
    > - Once the covering index existed, SQLite **dropped the `GROUP BY` temp B-tree by itself**.
    >   A hand-rewrite to avoid the grouping measured 5.99 ms vs 5.85 ms — noise. Don't
    >   hand-optimise around a temp B-tree until you have re-read the plan post-index.
    > - An unindexed `MAX()` riding inside a batch another query was already sending cost
    >   **28.09 ms and 58,432 rows scanned on every response across four tools**, while the
    >   statement without it cost 0.17 ms / 2 rows. The same `MAX()` over an indexed column:
    >   0.17 ms / 1 row. It never showed up in per-query timing because it added no round trip.
    
    ### Verify the planner's choice with and without statistics
    
    A covering index may only be *chosen* once `ANALYZE` has populated `sqlite_stat1` — and
    many hosted engines never run `ANALYZE` for you. Test both states before you rely on it:
    
    ```sql
    ANALYZE;                                  -- populate sqlite_stat1
    EXPLAIN QUERY PLAN SELECT ...;            -- record the plan
    
    DELETE FROM sqlite_stat1;                 -- simulate a never-analyzed database
    ANALYZE sqlite_master;                    -- force the planner to reload (now-empty) stats
    EXPLAIN QUERY PLAN SELECT ...;            -- same plan? then you are safe either way
    ```
    
    In the worked example the covering index was chosen in **both** states — verified, not
    assumed. Do the same check rather than inheriting that result.
    
    ## Index design in one table
    
    | Predicate shape | Indexable? | What to build |
    |---|---|---|
    | `col = ?`, `col IN (…)`, `col > ?`, `BETWEEN` | Yes | B-tree on `col` |
    | `a = ? AND b = ?` | Yes | Composite `(a, b)` — equality columns first |
    | `a = ? ORDER BY b` | Yes | Composite `(a, b)` — kills the temp B-tree |
    | `col LIKE 'x%'` (anchored) | Yes, if `col` is TEXT with `BINARY` collation | B-tree on `col` |
    | `col LIKE '%x%'` (leading wildcard) | **No seek possible** | Make the scan covering, or use FTS5 trigram |
    | `lower(col) = ?` | Not on a plain index | Expression index `ON t(lower(col))` |
    | `status = 'open'` where 2% of rows qualify | Yes | Partial index `WHERE status = 'open'` |
    | `json_extract(doc,'$.k') = ?` | Not on a plain index | Expression index, or generated column + index |
    
    **Rules that repay themselves:** put the *filtered* column first and the *projected*
    column second in a covering index; index the column, never a function of it (unless it is
    an expression index); and every index you add taxes every write — audit before adding.
    
    ## Concurrency and durability — the 80/20
    
    | Symptom | Cause | Fix |
    |---|---|---|
    | `SQLITE_BUSY` | Another **connection** holds a lock; yours gave up waiting | `PRAGMA busy_timeout = 5000;` and keep write transactions short |
    | `SQLITE_LOCKED` | Conflict **within the same connection** (or a shared cache) | Fix the code — a retry loop will spin forever |
    | "database is locked" mid-transaction | `BEGIN` (DEFERRED) read that later writes → upgrade deadlock, **not retryable** | `BEGIN IMMEDIATE` for any transaction that will write |
    | Readers blocked by a writer | Rollback journal mode | `PRAGMA journal_mode = WAL;` (persistent, set once) |
    | `-wal` file grows without bound | Long-lived reader pins the checkpoint | Close/refresh readers; `PRAGMA wal_checkpoint(TRUNCATE);` |
    
    ```sql
    PRAGMA journal_mode = WAL;      -- persistent; survives reconnect
    PRAGMA busy_timeout = 5000;     -- per-connection; set on EVERY connection
    PRAGMA foreign_keys = ON;       -- per-connection, OFF by default — see below
    PRAGMA synchronous = NORMAL;    -- safe with WAL; FULL only if you fear power loss
    ```
    
    **Deep dive**: `./references/concurrency-durability.md` — WAL internals, the
    DEFERRED-upgrade deadlock, `synchronous` levels, checkpoint starvation, multi-process access.
    
    ## Schema — the three silent bugs
    
    1. **`PRAGMA foreign_keys` is OFF by default.** Per connection, every connection. Your
       `REFERENCES` clauses parse, are stored, and do nothing. This is the classic silent
       data-integrity bug in SQLite applications.
    2. **Type affinity is not a type.** A `TEXT` column will happily store an integer; a
       declared type is a *suggestion* about conversion. Use **`STRICT` tables** (SQLite 3.37+)
       when you want a declared type enforced.
    3. **`ALTER TABLE` is limited.** Adding a column and renaming are supported; dropping,
       retyping, and changing constraints need the 12-step recreate dance.
    
    ```sql
    CREATE TABLE product (
        id       INTEGER PRIMARY KEY,
        org      TEXT NOT NULL,
        price    REAL NOT NULL,
        doc      TEXT,
        -- indexable projection of a JSON field
        sku      TEXT GENERATED ALWAYS AS (json_extract(doc, '$.sku')) VIRTUAL
    ) STRICT;
    ```
    
    **Deep dive**: `./references/schema-design.md` (affinity, STRICT, generated columns,
    `WITHOUT ROWID`, constraints) and `./references/migration-patterns.md` (the 12-step ALTER
    dance, versioned migration runners).
    
    ## Feature modules at a glance
    
    | Need | Reach for | Note |
    |---|---|---|
    | Substring / fuzzy text search | FTS5 with the `trigram` tokenizer | The real answer to `LIKE '%x%'` at scale |
    | Word/phrase search with ranking | FTS5 + `bm25()` | External-content table avoids duplicating the corpus |
    | Semi-structured documents | `json_extract` / `->` / `->>`, JSONB (3.45+) | Index via generated column or expression index |
    | Bounding-box / interval overlap | R-tree virtual table | Compile-time module; check availability |
    | Running totals, ranking, gaps | Window functions (3.25+) | Same syntax as PostgreSQL |
    | Insert-or-update | `ON CONFLICT … DO UPDATE` (3.24+) | `excluded.col` refers to the proposed row |
    | Read back what you wrote | `RETURNING` (3.35+) | Makes atomic claim-a-job patterns single-statement |
    
    **Deep dive**: `./references/feature-modules.md`.
    
    ## Hosts
    
    The engine is the same; the envelope is not.
    
    | Host | Connection model | Watch out for |
    |---|---|---|
    | `sqlite3` CLI | Direct file | `.timer on` for real timings; `.mode`/`.headers` for output |
    | Python `sqlite3` | Direct file, per-connection pragmas | Implicit transaction handling; `check_same_thread` |
    | Python `aiosqlite` | Thread-backed async wrapper | Still one writer; see `./references/async-patterns.md` |
    | `node:sqlite` | Synchronous, built into Node | No external dependency; API still stabilising |
    | `better-sqlite3` | Synchronous, native addon | Fastest Node option; prepared statements are the unit of reuse |
    | `bun:sqlite` | Synchronous, built into Bun | API close to better-sqlite3, not identical |
    | **Cloudflare D1** | HTTP/RPC to a managed SQLite | Billed on **rows read**; 100-parameter cap; no `PRAGMA` surface |
    | libSQL / Turso | Server or embedded replica | Replica staleness; syntax extensions beyond stock SQLite |
    
    **Deep dive**: `./references/hosts.md` for per-host connection recipes and traps.
    
    On D1 specifically, three platform features have no stock-SQLite equivalent and are the most
    commonly missed:
    
    ```bash
    wrangler d1 insights <db> --sort-type=sum --sort-by=reads --limit=10   # rank REAL queries by cost
    wrangler d1 time-travel info <db>                                      # 30-day point-in-time restore point
    # Sessions API (env.DB.withSession(bookmark)) - read replicas, sequential consistency
    ```
    
    `./references/d1-edge.md` covers those plus the rows-read economics, the verified limits
    table, the error catalogue, and import/export. For the production incident patterns —
    a timed-out `migrations apply --remote` that landed anyway, `batch()` treating a 0-row
    scoped UPDATE as success, and the opt-in-to-replica rollout shape for read replication —
    see `./references/d1-production-patterns.md`.
    
    ## Operations
    
    ```bash
    sqlite3 app.db 'PRAGMA quick_check;'        # fast structural check
    sqlite3 app.db 'PRAGMA integrity_check;'    # full check — slow on big DBs
    sqlite3 app.db "VACUUM INTO 'backup.db';"   # consistent backup, no downtime, defragmented
    sqlite3 app.db '.dump' > backup.sql         # portable text backup
    sqlite3 app.db 'PRAGMA optimize;'           # run before closing a long-lived connection
    ```
    
    **Never** copy a live database file with `cp` while a writer is active — use
    `VACUUM INTO`, the backup API, or `.dump`.
    
    **Deep dive**: `./references/operations.md` — corruption causes and recovery, `VACUUM` vs
    `VACUUM INTO`, page/cache sizing, size analysis.
    
    ## Triage script
    
    `scripts/eqp-triage.py` reads an `EXPLAIN QUERY PLAN` result — either by running the
    statement against a database, or from piped plan text — and classifies each line by
    severity with a fix hint. Exits `10` when it finds something (the domain signal), `0`
    when the plan is clean.
    
    ```bash
    # Run against a database file (uses Python's bundled sqlite3 — no external binary needed)
    python3 scripts/eqp-triage.py --db app.db \
      --sql "SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%acme%'"
    
    # Triage a plan captured elsewhere (D1, a log, a colleague's paste)
    wrangler d1 execute atdw-mirror --remote --json \
      --command "EXPLAIN QUERY PLAN SELECT product_id FROM q_product WHERE org LIKE '%acme%'" \
      | python3 scripts/eqp-triage.py
    
    # Machine-readable findings
    python3 scripts/eqp-triage.py --db app.db --sql "SELECT ..." --json | jq '.data[]'
    ```
    
    ## Gotchas
    
    | Mistake | Why it bites | Fix |
    |---|---|---|
    | Adding an index for `LIKE '%x%'` | Leading wildcard can never seek | Covering index, or FTS5 trigram |
    | Timing with a shell stopwatch | CLI/driver startup dominates | Engine-reported duration; median of 10+ |
    | Timing the tool call, not the statement | Piggy-backed statements are invisible | Decompose and time each part |
    | Assuming `REFERENCES` is enforced | `foreign_keys` is OFF per connection | `PRAGMA foreign_keys = ON` on every connection |
    | Assuming a declared type is enforced | Affinity, not typing | `STRICT` tables |
    | Retrying `SQLITE_LOCKED` | Same-connection conflict never clears | Fix the code path |
    | `BEGIN` then write | DEFERRED→write upgrade deadlocks and is not retryable | `BEGIN IMMEDIATE` |
    | `cp` on a live database | Torn copy | `VACUUM INTO` / backup API |
    | `SELECT *` | Defeats covering indexes; widens every row read | Project only what you need |
    | `VACUUM` to "speed things up" | Rewrites the whole file, needs 2x space, holds a lock | `PRAGMA optimize` / targeted index work |
    | Trusting one cold run | 1.5–1.7x first-run penalty is routine | Median of 10+, report the range |
    | Inlining literals to dodge a parameter cap | That is how injection happens | Chunk the work; keep bound parameters |
    | Re-running a timed-out remote migration | The apply may have landed; the error was about the response | Verify schema state read-only first — `./references/d1-production-patterns.md` |
    | Reading a committed `batch()` as per-statement success | A conditional UPDATE matching 0 rows is not an error | Check `meta.changes`; 0 on a scoped write = 403/conflict |
    
    ## Reference files
    
    | Reference | Load when |
    |---|---|
    | `./references/query-performance.md` | Any slow statement: EQP, index design, ANALYZE, planner defeats, measurement method |
    | `./references/d1-edge.md` | Cloudflare D1: rows-read economics, `d1 insights`, Sessions API/replication, Time Travel, limits, errors |
    | `./references/d1-production-patterns.md` | Running D1 in production: verifying a timed-out migration, `batch()` 0-row write verification, the opt-in-to-replica replication rollout |
    | `./references/concurrency-durability.md` | Locking, WAL, busy_timeout, transaction modes, checkpointing, durability |
    | `./references/schema-design.md` | Affinity, STRICT, foreign keys, generated columns, `WITHOUT ROWID`, constraints |
    | `./references/schema-patterns.md` | Ready-made table designs: state, cache, event log, queue, session, dedup |
    | `./references/migration-patterns.md` | Versioned migrations, the 12-step ALTER dance, host-specific runners |
    | `./references/feature-modules.md` | FTS5, JSON/JSONB, R-tree, window functions, upsert, RETURNING |
    | `./references/hosts.md` | Per-host connection recipes and driver traps (Python, Node, Bun, D1, libSQL) |
    | `./references/async-patterns.md` | Python `aiosqlite` depth: async CRUD, batching, pooling |
    | `./references/operations.md` | Integrity checks, corruption recovery, VACUUM, backups, size and page tuning |
    | `./references/testing.md` | In-memory vs file databases, fixtures, deterministic seeding, migration tests |
    
    ## See also
    
    | Skill | When to combine |
    |---|---|
    | `sql-ops` | Vendor-neutral SQL: CTEs, window functions, JOIN strategy |
    | `perf-ops` | The wider performance workflow — profiling, load testing, before/after protocol |
    | `cloudflare-ops` | Workers, bindings, and deployment around a D1 database |
    | `postgres-ops` | When the workload has outgrown SQLite's single-writer model |
    | `python-database-ops` | SQLAlchemy / ORM layers over SQLite |
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related