Skip to content

feat(db): move from SQLite Cloud to Turso - #50

Merged
ralyodio merged 1 commit into
masterfrom
turso-migration
Aug 11, 2026
Merged

feat(db): move from SQLite Cloud to Turso#50
ralyodio merged 1 commit into
masterfrom
turso-migration

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Replaces SQLite Cloud with Turso (libSQL), so an idle database is something a cron can revive instead of something that waits on a human at a dashboard.

Why

Today's outage — coupon submission failing with "The coupon database is temporarily unavailable" — was a parked SQLite Cloud free node. Error 10010 on every query and every new connection, revivable only from the dashboard, with no account-level API key in any vault.

Turso also sleeps, and I want to be precise about that rather than oversell it:

SQLite Cloud (free) Turso (free)
Short idle connection drops scales to zero, wakes automatically
Long idle node paused group archived after 10 days
Revive a human, dashboard only POST …/groups/{group}/unarchive
Local/CI database none file:local.db, same client

The win is recoverability, not "never sleeps".

Why the diff is small

All 77 db.sql calls across 22 files go through one tagged-template surface, and libSQL rows are array-like and object-like, serializing to plain named objects. So no call site changed. lib/db.ts is the migration.

One real incompatibility: libSQL refuses undefined (undefined cannot be passed as argument to the database), which the routes pass freely for absent optional fields. Coerced to NULL at the binding boundary.

The reconnect/retry logic is gone with the old driver — no long-lived websocket to go stale, so the bug that silently emptied /blog can't recur in that form.

Two pre-existing bugs found while recreating the schema

  • lib/schema.sql was incomplete — it stopped at blog_posts, omitting coupon_votes and bounties. Rebuilding from it gave you a silently broken database. Now dumped from a real migrated database.
  • Three declared indexes were never created. idx_coupons_store, idx_stores_slug, idx_categories_slug existed in schema.sql but not in migrate.mjs, so no database built by the script has ever had them — production included. Added.

Also updated the footer, privacy policy and llms.txt, which all named SQLite Cloud as the database provider. The privacy page lists it as a third-party service, so leaving it would have been factually wrong about where user data lives.

Verification

Ran the app against a local file DB and the real remote Turso database:

  • All 12 DB-backed routes 200 — /, /blog, /search, /stores, /stores/[slug], /bounties, /coupons/[id], /sitemap.xml, /blog/rss.xml, and the /api/* handlers
  • Coupon submit → 201 Created on both, with fields omitted from the payload landing as proper NULLs (not "undefined") — this is the exact operation that failed in production
  • Vote → {"success":true,"votes":1}; duplicate vote → 409 already_voted
  • Bounty claim → succeeded, second claim → 409 (the RETURNING id race guard, the riskiest SQL shape here)
  • Against an empty database, list pages still 200 and detail pages 404 rather than 500
  • pnpm typecheck passes; pnpm lint reports the same 12 pre-existing errors as before, none in changed files

What this PR deliberately does NOT do

No data is migrated, and no environment variables are changed. The export has to come off the SQLite Cloud node, which is still parked — so the Turso database currently holds the schema and nothing else, and Railway still points at SQLite Cloud.

Merging this alone does not cut over. Remaining steps, in order:

  1. Restart the node at https://dashboard.sqlitecloud.io (this also ends the current outage on its own)
  2. Export the data and import it into Turso — libsql://c0upons-profullstack.aws-us-west-2.turso.io
  3. Set TURSO_DATABASE_URL + TURSO_AUTH_TOKEN in Railway and the c0upons--prod vault (turso db tokens create c0upons)
  4. Deploy, keeping SQLITECLOUD_URL in place for one deploy so rollback is a variable change
  5. Optional follow-up: teach the keep-alive workflow to call unarchive on failure — needs a platform token as a repo secret, which I did not provision

🤖 Generated with Claude Code

Today's outage was a parked SQLite Cloud free node: every query and every
new connection answered error 10010, and only a human restart from the
dashboard could clear it. Turso sleeps too — a free group is archived
after ten days idle — but it comes back through an API call, so the same
failure becomes something a cron can heal instead of something that waits
on someone noticing.

The app talks to the database through exactly one surface, `db.sql`, so
the swap lives in lib/db.ts. libSQL rows are array-like and object-like
and serialize to plain named objects, which means all 77 call sites keep
working untouched: `rows.length`, destructuring a single COUNT row,
`NextResponse.json(rows)`, and the bounty claim's `RETURNING id` guard
all behave as before. Verified by running the app against both a local
file database and the real Turso database.

One genuine incompatibility: libSQL refuses `undefined`, which the routes
pass freely for absent optional fields. Coercing to NULL at the binding
boundary keeps a missing image_url a NULL instead of a 500.

The reconnect-and-retry dance is gone with the old driver. There is no
long-lived websocket to go stale, so the failure that silently emptied
/blog cannot recur in that form.

Two pre-existing problems surfaced while recreating the schema, both
fixed here:

  * lib/schema.sql stopped at blog_posts, omitting coupon_votes and
    bounties, so anyone rebuilding from it got a silently incomplete
    database. It is now dumped from a real migrated database.
  * idx_coupons_store, idx_stores_slug and idx_categories_slug were
    declared in schema.sql but never created by migrate.mjs, so no
    database built by the script has ever had them.

Footer, privacy policy and llms.txt named SQLite Cloud as the database
provider; the privacy page lists it as a third-party service, so leaving
it would have been inaccurate about where user data lives.

Not included: the data itself. The export has to come off the SQLite
Cloud node, which is still parked, so the Turso database currently holds
the schema and nothing else. Railway and vault variables are deliberately
untouched — cutting over before the import would point production at an
empty database.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

17 finding(s)

MEDIUM: 17

Severity Rule Location
MEDIUM sql-template-interpolation apps/web/app/api/bounties/[id]/claim/route.ts:18
MEDIUM sql-template-interpolation apps/web/app/api/bounties/[id]/claim/route.ts:34
MEDIUM sql-template-interpolation apps/web/app/api/bounties/[id]/claim/route.ts:43
MEDIUM sql-template-interpolation apps/web/app/api/bounties/route.ts:81
MEDIUM sql-template-interpolation apps/web/app/api/coupons/route.ts:68
MEDIUM sql-template-interpolation apps/web/app/api/coupons/route.ts:75
MEDIUM sql-template-interpolation apps/web/app/api/coupons/vote/route.ts:31
MEDIUM sql-template-interpolation apps/web/app/api/coupons/vote/route.ts:33
MEDIUM sql-template-interpolation apps/web/app/api/stores/[slug]/route.ts:9
MEDIUM sql-template-interpolation apps/web/app/api/webhooks/crawlproof/route.ts:83
MEDIUM sql-template-interpolation apps/web/app/api/webhooks/crawlproof/route.ts:90
MEDIUM js-open-redirect apps/web/app/bounties/new/page.tsx:57
MEDIUM js-unescaped-html-sink apps/web/app/layout.tsx:77
MEDIUM sql-template-interpolation apps/web/app/stores/[slug]/page.tsx:12
MEDIUM sh-predictable-temp-path apps/web/public/install.sh:37
MEDIUM sh-predictable-temp-path apps/web/public/install.sh:39
MEDIUM sql-template-interpolation apps/web/scripts/migrate.mjs:174

Snippets are redacted; ThreatCrush never prints matched credential material.

@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​libsql/​client@​0.17.410010010087100

View full report

@ralyodio
ralyodio merged commit 2ff0d98 into master Aug 11, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant