import InfoBox from '../components/InfoBox.astro';
import PullQuote from '../components/PullQuote.astro';

The gate from [the day before](/posts/the-repo-grows-up#where-this-leaves-things) checked code before it reached main; it checked nothing about the database underneath, or about the people who had started actually playing on it. Most of what closed those gaps over the next three days did not come from hand-branched feature work: nine of the range's eleven pull requests were GitHub's own coding agent working a queue of filed issues under branch names it chose for itself. What it found waiting there was a Turso instance that rejected its own migration table twice, a dictionary that existed on paper but not in production, and the first real players who needed the game to explain itself before it asked them to type a word.

## Yesterday's cache, reloaded

An installed "Add to Home Screen" copy of the game kept reopening to yesterday's build, wrong bundle URLs and all, because SvelteKit's content-hashed JS and CSS already carry immutable cache headers but the HTML document that references them did not; the fix is one conditional in the request hook, so the shell always asks again while the hashed assets underneath stay cached indefinitely. A companion fix the same evening made switching language on the free-play page start a fresh game immediately, mirroring a pattern the daily challenge already needed: state that depends on a setting has to know when the setting changes.

```typescript
if (response.headers.get('content-type')?.startsWith('text/html')) {
	response.headers.set('Cache-Control', 'no-store');
}
```

## A migration table Turso would not accept

Render deployments began crashing on startup against the table Drizzle's automatic migrator uses to track which migrations have already run, created with a `SERIAL PRIMARY KEY`, valid Postgres and nothing Turso's SQLite dialect will accept, so every deploy failed before the app served a single request. The fix replaced the automatic migrator with a small hand-written one; a second, unrelated command needed the identical fix, because `pnpm db:migrate`, used to stand up a database from scratch, still called `drizzle-kit migrate` directly and hit the same error, until a thin script routed it at the same hand-written runner so there was exactly one way to apply a migration, not two.

```sql
-- was: id SERIAL PRIMARY KEY        (Postgres-only, rejected by Turso)
-- now: id integer PRIMARY KEY AUTOINCREMENT NOT NULL
```

<InfoBox title="What Render's logs actually said">
  `DrizzleQueryError` in drizzle-orm 0.44 and later keeps the underlying driver error on `.cause`,
  but not in its own `.message`, and Node's default error printing shows only the message. Render's
  logs showed the failing `CREATE TABLE` statement and nothing about why the database had refused
  it. Unwrapping `.cause` before re-throwing, alongside pinning Node 22 for the libsql native
  binary's ABI requirements, turned that log line back into something worth reading.
</InfoBox>

## The tables existed, the words did not

A database migrated cleanly for the first time, and still rejected every word a player typed, because `build-dictionary.ts` had only ever written to a local SQLite file through `better-sqlite3`, a driver with no path to a remote Turso URL; production had the schema and none of the roughly 370,000 words meant to fill it, and the daily challenge quietly cached that day's empty word set on its first run, which then stayed empty even once the dictionary existed, because nothing had told the seeder that an empty result should be retried rather than remembered. `push-dictionary.ts` closed both gaps at once, built on the same loader and enricher functions as the local build and aimed at any libsql-compatible target, local file or Turso URL alike, alongside a change so an empty word set is never cached in the first place.

<PullQuote>The tables existed. The words did not.</PullQuote>

## Teaching the first players

With the database behaving, the rest of the range went to the players who had started arriving: a how-to-play modal for first-time visitors, its close button and body text sized to WCAG's touch-target and readability minimums; score moved out of the shared toolbar into each mode's own header; and best word, the single highest-scoring word of a session or a period, tracked and surfaced everywhere the score already was. A late fix in the same range taught client-side word validation to accept æ, ø, and å again, [Norwegian's own letters](/posts/premature-norwegian#norwegian-in-one-evening) rejected by a preflight check that still assumed plain ASCII.

<InfoBox title="The share card, technically">
  Sharing a result now renders the final board to a 630×420 canvas at two-times pixel density, grid,
  snake, and a score badge panel, and attaches it as a PNG through the Web Share API where the
  browser supports file sharing, falling back to text and a clipboard copy where it doesn't.
</InfoBox>

## Where this leaves things

One deliberate subtraction closed the range: the automatic PR preview infrastructure, [added the day before](/posts/the-repo-grows-up#where-this-leaves-things) as a per-PR Turso database cloned from a seed and provisioned by a GitHub workflow, came back out again after its own database connectivity problems made it cost more than the single production setup it was meant to protect. Three days after the first deploy, the lesson is the same one that day taught, restated three more times: whether it is a cache header, a migration path, or a dictionary push script, a daily game is only as good as its ability to reliably serve today's puzzle.