Valcraven Docs
Conventions & Gotchas

Hard-won gotchas

A curated collection of the traps that cost real debugging time on Valcraven — Next.js dev quirks, the monorepo dev stack, Worker deploys, Durable Objects, CI, and more.

This is the page to read before you lose an afternoon. Each entry is a real failure mode the template has hit, generalized into a lesson you can apply to any app built on Valcraven. They're grouped by where they bite: local dev, the Docker dev stack, Cloudflare deploys, and the Git/CI workflow. When something "should work but doesn't," scan this page first.


Next.js dev-server quirks

The web app runs next dev on Next.js 16 with Turbopack. Three recurring issues, none of which are bugs in your code:

Turbopack cache corruption panic

next dev can crash mid-compile with a Rust panic from Turbopack's persistence layer — something like thread 'tokio-runtime-worker' panicked at .../turbo-persistence/...: range start index ... out of range. This is a Turbopack cache-corruption bug, not your application. Recover by clearing the dev cache and restarting:

rm -rf .next/cache .next/dev
# then restart: npm run dev

"Another next dev server is already running"

Next 16 allows only one dev server per directory, enforced by a lock file at .next/dev/lock (JSON with a pid/port). If a previous server didn't shut down cleanly, the stale lock blocks a restart with ⨯ Another next dev server is already running. After confirming no server is actually running, remove the lock:

rm -f .next/dev/lock

SQLITE_BUSY: database is locked

In dev the app talks to a local SQLite file (better-sqlite3). Opening a second connection to that same file while the dev server is running — e.g. running npx tsx -e '...', npm run db:migrate, or a seed script against the live database — causes SQLITE_BUSY: database is locked, which surfaces as 500s. Either:

  • Drive the running app through its HTTP API (with an authenticated cookie) instead of opening a second DB connection, or
  • Stop the dev server before running direct-DB scripts, then restart.

This only affects the SQLite dev database — production D1 has no equivalent single-writer file lock.


The monorepo Docker dev stack

The dev stack runs each service (web, realtime, the Tailscale/Caddy sidecar) in its own container. A subtlety worth knowing:

Each service populates its own node_modules from its own named volume. The web and realtime images can hoist dependencies differently, so they deliberately do not share a single node_modulesdocker-compose.yml gives each service both a root node_modules volume and a per-workspace volume (e.g. apps/web/node_modules, apps/realtime/node_modules). This isolation is intentional: it stops one service's install from clobbering another's dependency resolution.

The practical rule: after a package.json change, refresh each affected service's install separately, then rebuild:

docker compose run --rm app-dev npm ci
docker compose run --rm realtime-dev npm ci

If you ever see Module not found: Can't resolve 'agents/react' on the web side or Could not resolve "agents" on the realtime side after pulling changes, it's this — a service whose install is stale relative to the shared workspace tree. Refresh that service. See Cloudflare Workers architecture and the Realtime worker page for how the two Workers relate.


Cloudflare Worker rename & deploy traps

Valcraven deploys two Workers: the web app (Next.js via OpenNext) and a separate realtime Worker (the AI agent + realtime channels, hosted as Durable Objects). Renaming or first-deploying a Worker surfaces several traps.

Renaming a Worker: delete the old one before the route cutover

Cloudflare refuses to reassign a route that's owned by another Worker. If you rename a Worker and try to deploy the new name onto a route the old name still holds, the deploy fails with "Can't deploy routes that are assigned to another worker." The Worker script and its Durable Objects upload fine — only the route assignment fails. The fix is ordering: delete the old Worker first, then deploy the new one to claim the route (this creates a brief, seconds-long gap on that route between delete and redeploy).

A renamed (or newly created) Worker starts with no secrets

A new Worker script has empty secrets. After a rename you must re-set every secret (API keys, shared internal secrets, EMAIL_FROM, etc.) on the new Worker. Cloudflare secrets are write-only — you can't read the old values back — so any shared secret that must match between two Workers should be regenerated and set on both. If two Workers authenticate to each other with a shared secret and the values drift, the internal calls get rejected (401).

Web deploys automatically; the realtime Worker deploys manually

The two Workers ship differently, and it's easy to assume a release covered both when it didn't:

  • The web Worker auto-deploys via Cloudflare Workers Builds on push to your deploy branch.
  • The realtime Worker has no build pipeline — it deploys manually:
npm run deploy:realtime      # wrangler deploy --env production, in apps/realtime
# or ship both together:
npm run deploy:cf:all        # build:cf → deploy:cf (web) → deploy:realtime

So a normal merge-to-main release ships only the web Worker. If your change touched the realtime Worker, remember to npm run deploy:realtime separately. See Workers Builds and Cloudflare Workers deployment.

Setting a secret on a Workers-Builds-deployed Worker

wrangler secret put fails on a Worker deployed through Workers Builds (it runs in "versions" mode) with a message telling you to use wrangler versions secret put or to deploy first. Deploy the current build first, then set the secret — or use the versions-based secret command. Secrets persist across subsequent code deploys.


Durable Object migration caveats

Durable Object class changes are declared as migrations in wrangler.toml. Cloudflare's documented migration operations are new_sqlite_classes, renamed_classes, and deleted_classes. There is an additional transferred_classes op (move a DO class from another Worker script via from_script), but it is undocumented and unreliable — avoid it.

In particular, do not try to preserve DO state across a Worker rename. A renamed Worker is a brand-new script with no migration history; a sequence that both creates a class and transfers the same class into itself is self-contradictory and can deploy fine in local wrangler dev (miniflare tolerates it) while failing the real production deploy. The correct approach for a rename is to let the new Worker create fresh Durable Objects with clean new_sqlite_classes migrations and abandon the old Worker's DO state (delete the old Worker). This is fine for rebuildable working state like an agent's scheduled wakes; if you have durable state you truly can't lose, design an explicit data export/import rather than relying on a class transfer. See Realtime worker for the DO layout.


npm run lint may fail locally but pass in CI

You may find npm run lint failing on your machine with a plugin-resolution error such as A configuration object specifies rule "react-hooks/set-state-in-effect", but could not find plugin "react-hooks". This fires at config-load time, before any file is linted, so it's independent of whatever you changed — and it's sensitive to your local Node version and how the monorepo hoists ESLint plugins.

Don't chase it. It's a local-environment artifact, not a real lint failure; the same lint runs green in CI from a clean install. Use npm run typecheck, npm run build, and the unit suite as your reliable local gates, and let CI be the authority on lint. Do not edit eslint.config.mjs to satisfy your local environment — you'll likely break the lint that works in CI.


If you run parallel work in separate git worktrees and symlink node_modules into each worktree to avoid repeated installs, the symlink can get committed into the repo by accident. The cause: a .gitignore entry of node_modules/ (with a trailing slash) matches directories only — a symlink named node_modules is not matched, so git add -A stages it (mode 120000). A committed self-referential symlink then breaks fresh clones with ELOOP: too many levels of symbolic links, corrupting the branch for everyone.

To stay safe:

  • Ignore node_modules without a trailing slash (matches dirs, files, and symlinks).
  • Verify git status shows no node_modules entry before committing anything in a worktree.
  • Prefer running npm ci in each worktree over symlinking when the reinstall cost is tolerable.
  • On teardown, only rm paths you've confirmed are symlinks — never a real node_modules directory (and be careful with tools/subagents that create their own worktrees and may symlink or wipe the parent checkout's node_modules).

EMAIL_FROM must be set — or all email silently fails

The email layer (lib/email.ts) falls back to a placeholder from-address (noreply@YOUR_DOMAIN) when EMAIL_FROM is unset. Cloudflare's email service rejects a send from an unverified/placeholder domain, and transactional sends (verification, welcome, password reset) swallow the error in a try/catch — so email is silently broken with no visible failure until something surfaces it (a preview send, an admin action).

Set EMAIL_FROM to a verified sender for your domain, and in production set it as a secret rather than a plain [vars] value, so it survives redeploys instead of being overwritten each time. See Email and Secrets & env.


Release with a merge commit, not a squash

If you run a Gitflow-style setup (a long-lived develop integration branch that periodically ships to main), merge the release PR with a real merge commit — don't squash it. A squash gives main a single commit with no shared lineage to develop's individual commits, so the next release conflicts on every file the previous release touched (Git treats the squash as an unrelated change), even when the content is identical. A merge commit brings develop's lineage into main, keeping the two branches in sync with no back-merge dance. Squashing individual feature PRs into develop is fine; it's the develop → main release specifically that must be a merge commit.


Never edit the generated .open-next/worker.js

The OpenNext build regenerates .open-next/worker.js on every build, so any edit you make there is wiped. The template's actual Worker entrypoint is worker.js (referenced as main in wrangler.toml), which imports the OpenNext handler, re-exports the Durable Objects, and adds the scheduled() export for cron. Edit worker.js and lib/scheduled.ts — never the generated file. See Cloudflare Workers architecture.


  • Code conventions — the day-to-day rules (imports, lazy init, API-route boilerplate, "things to never do").
  • Testing — harness scope, the client-React coverage gap, and the CI-triggers caveat.
  • D1 dev/prod parity — the parity bugs that only appear once you deploy to D1.

On this page