Skip to content

fix(local): warm dev restarts dead databases, and --fresh actually wipes them - #307

Draft
kristof-siket wants to merge 10 commits into
mainfrom
fix/local-dev-postgres-warm-restart-and-fresh
Draft

kristof-siket wants to merge 10 commits into
mainfrom
fix/local-dev-postgres-warm-restart-and-fresh

Conversation

@kristof-siket

@kristof-siket kristof-siket commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

fix(local): warm dev restarts dead databases, and --fresh actually wipes them

Fixes two local-dev bugs found while building an app on 0.20.0 (three Postgres resources plus auth() and email()). They share one root cause.

Root cause

The postgres emulator daemon runs its @prisma/dev servers inside its own process. It is machine-wide, so another app's dev can restart or replace it (version skew, a crash, a reboot), and every server it held goes with it. The only thing that starts a server again is the local Database provider's reconcile (a PUT). On a warm start the props haven't changed, so Alchemy's default diff is noop and the PUT never happens.

  • Warm restart points at dead ports. The databases are never restarted, and dev still reports ready. On the reporting machine the daemon had been restarted by another project (wedding-website). The "new ports" that lsof showed (:51314/:51319/:51326) were that app's database. None of daily-journal's databases were running. This is the same gap ADR-0041 records and fixes for Compute with startServices (feat(cli): prisma-composer dev — bring the whole app up locally, credential-free #164). Postgres never got that fix.
  • --fresh keeps the auth DB. DELETE /apps/<app> could only delete PGlite data using a prismaDevModulePath the daemon had seen on a PUT in its own lifetime. Warm starts never PUT, so a restarted daemon had no path. It dropped the app's records but kept the data, and the next start under the same instance name reopened the old data under a newly generated auth secret. The result: Failed to decrypt private key on every session. Separately, teardown ran before the emulators hook and swallowed every error. So a daemon that was down at --fresh time was skipped completely, and a failed wipe still reported success.

Changes

  • LocalDatabaseProvider.diff always returns update, so every converge re-PUTs. The PUT is idempotent for a live server and restarts a dropped one on its pinned port.
    • While the daemon still records the same URL, the diff declares every attribute stables. Consumers then keep noop-ing, and a warm start only reconciles the Database resources, not migrations or Deployments.
    • If the URL moved (the daemon lost its record), the attributes are unknown until reconcile, and the Connection, env rows and dependent Deployments reconverge through Alchemy's normal diff.
  • postgres daemon DELETE resolves Composer's own @prisma/dev (a dependency of @prisma/composer-prisma-cloud, which ships the daemon), the same way local-target does. It no longer depends on a path remembered in memory from a PUT. The deleteApp(app) interface is unchanged, and state written by older daemons is wiped too. dev-emulators now lists @prisma/dev under dependencies.
  • --fresh teardown skips only daemons that aren't running, and lets a running daemon's failure surface as DEV.TEARDOWN_FAILED instead of being swallowed.
  • executeDev ensures the emulators before --fresh teardown (it used to be after preflight), so a daemon that was down is running and can be wiped. This matches step 4 of the local-dev spec ("Containers + emulators").
  • Design note added to docs/design/10-domains/local-dev.md, next to the existing startServices gap. The user guides already promise "warm start: same ports, same data" and "--fresh wipes data", so they don't change.

Verification

End-to-end against examples/auth (2 Postgres DBs + auth() + email()), under an isolated HOME so it had its own daemons:

Scenario Before After
Warm restart after killing the postgres daemon all DB resources noop, "ready", nothing on :51300/:51304, auth logs ECONNREFUSED 127.0.0.1:51300, sign-up 500 DBs back on :51300/:51304, sign-up 200
Plain warm restart – only database-db / mail.db-db reconcile; no migrate, warm, env or deploy churn
DB record lost + old port taken – DB moves to :51301, COMPOSER_AUTH_SERVICE_DB_URL and the auth/api/ops deployments reconverge, sign-up 200
--fresh with the daemon down beforehand – auth.user 1 → 0, new generated secret
--fresh over state written by the previous daemon build – wiped, dev comes up, sign-up 200

Regression tests:

  • dev-emulators/postgres.test.ts: DELETE after a daemon restart, with no PUT in between, deletes the data. This failed on the old daemon: the table was still there after the wipe.
  • local-target/postgres-always-reconciles.test.ts: the diff is never noop, and is stable only while the URL is pinned.
  • bun test passes for the local-target suite, the postgres daemon suite, the target invariants and cli/src/operations. Typecheck and biome are clean for the touched packages.

Not in this PR

--fresh also reallocates service ports (the app moved from :3021 to :3018). That's a separate issue: AUTH_BASE_URL pointing at a dynamically allocated port.

🤖 Generated with Claude Code

kristof-siket and others added 5 commits September 21, 2026 14:04
…just the record

The postgres daemon could only delete an app's persisted PGlite data with a
prismaDevModulePath it had seen on a PUT in its own lifetime. A daemon
restarted since the app's last PUT (and a warm dev never re-PUTs an unchanged
Database) dropped the app's records but kept the data, and the next start
under the same instance name reopened it: FRICTION #16, where --fresh minted
a new auth secret over the old auth.jwks key and every session 500'd.

DELETE now takes the path in an optional JSON body, falls back to the one a
PUT carried, and fails with a 500 when it has neither, rather than drop a
record whose data stays on disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Kristof Siket <siket@prisma.io>
…ows a failed wipe

Teardown swallowed every error from every daemon, so a DELETE that failed
still reported a clean --fresh. And it ran before the emulators hook, so a
postgres daemon that wasn't running (after a reboot, say) was skipped and its
persisted apps.json record and data survived --fresh.

Dev now ensures the emulators before --fresh teardown. Teardown only skips a
daemon that isn't running, sends the app's @prisma/dev path with the postgres
DELETE, and lets a running daemon's failure surface as DEV.TEARDOWN_FAILED.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Kristof Siket <siket@prisma.io>
The Database reconcile's PUT is the only thing that starts a local database
server, and the postgres daemon holds its servers in-process: a restart
(version-skew replacement, a crash, another app's dev) drops them all. With
unchanged props Alchemy's default diff is noop, so a warm dev never re-PUT,
reported ready, and services got ECONNREFUSED on the recorded ports
(FRICTION #15). It is the Postgres twin of the Compute gap startServices
closed (ADR-0041).

The provider's diff is now always update. It declares every attribute stable
while the daemon still records the same URL (the port is pinned, so the PUT
can't move it), so consumers keep noop-ing and a warm start only re-PUTs the
databases. If the URL moved, the attributes are unknown until reconcile and
the Connection and env rows reconverge through Alchemy's own diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Kristof Siket <siket@prisma.io>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Kristof Siket <siket@prisma.io>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Kristof Siket <siket@prisma.io>
@prisma-gizmo

prisma-gizmo Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

✅ Gizmo reviewed bacb05a — posted 0 inline comment(s) this pass.

Open findings: none

Change walkthrough

This PR fixes two local-dev failures that share one root cause: the machine-global postgres daemon runs its @prisma/dev servers in-process, so a daemon restart (another app's dev, version skew, crash, reboot) drops every server, and nothing re-PUTs them because Alchemy's default diff is noop on unchanged props. It also fixes --fresh, which could delete an app's records while leaving their PGlite data on disk.

Local Database provider — postgres.ts:64-90 adds the local-target providers' first custom diff: it always returns update so every converge re-PUTs (the idempotent ensureDatabase restarts a dropped server on its pinned port), but declares every output attribute stable while the daemon still records the URL Alchemy has — so Connections, env rows, and Deployments keep noop-ing on warm starts. If the URL moved, stables is dropped and those consumers reconverge through Alchemy's normal diff.

Daemon API — postgres-main.ts:841-850 gives DELETE /apps/<app> an optional { prismaDevModulePath } body, falling back to the last PUT-observed path; with neither and databases present it now fails with a 500 before touching records, rather than deleting records whose data would be silently reopened on the next start. The client passes the path as a JSON body only when given, keeping hint-less calls wire-identical to before.

Teardown — teardown.ts:18-52 sends Composer's pinned @prisma/dev path, tolerates only daemons that aren't running (client constructors throw synchronously on a missing/dead registry), and lets a running daemon's DELETE failure surface as DEV.TEARDOWN_FAILED instead of swallowing every error. Local state paths are removed only when the wipe succeeds.

CLI pipeline — execute-dev.ts:89-98 moves the emulators hook before --fresh teardown (it was after preflight), so a daemon that was down is running and can be wiped — matching step 4 of the local-dev spec.

Docs and tests — a design note beside the existing startServices/#164 Compute twin records the Postgres gap in local-dev.md:129-145. Two new daemon regression tests cover DELETE-after-restart with and without a path, and a new provider test pins the never-noop/stable-only-while-pinned diff contract, following the existing instance-name-drift test's idiom.

@coderabbitai

coderabbitai Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@prisma/composer@307
npm i https://pkg.pr.new/@prisma/composer-cli@307
npm i https://pkg.pr.new/@prisma/composer-prisma-cloud@307

commit: 72fac77

kristof-siket and others added 2 commits September 21, 2026 15:25
…h delete

main made resolvePrismaDevModulePath() argument-free (it resolves the
@prisma/dev Composer pins, not the app's). Teardown follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Kristof Siket <siket@prisma.io>
@kristof-siket
kristof-siket marked this pull request as ready for review September 21, 2026 13:36
…docs

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Kristof Siket <siket@prisma.io>
@kristof-siket
kristof-siket marked this pull request as draft September 21, 2026 13:39

@prisma-gizmo prisma-gizmo Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New findings: none · trace

@prisma-gizmo prisma-gizmo Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All Gizmo review threads are resolved and the head commit has been reviewed. Approving.

kristof-siket and others added 2 commits September 21, 2026 15:43
…, like PUT

Replace the optional DELETE body, the per-daemon-lifetime path fallback and
the 'refuse without a path' branch with one contract: DELETE carries
{ prismaDevModulePath } exactly as PUT does, parsed by the same helper. The
fallback map was the bug: a restarted daemon had no entry. Teardown passes
Composer's own @prisma/dev path. Also drops an unreachable name check from
the Database diff and shortens the design note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Kristof Siket <siket@prisma.io>
Revert the DELETE-body approach: deleteApp(app) is unchanged. The daemon
deleted persisted data only with a path remembered in memory from a PUT, so a
restarted daemon kept the data. It now resolves the @prisma/dev it ships with
(a dependency of @prisma/composer-prisma-cloud since the version pin), the
same way local-target resolves it, so no caller supplies a path and state
written by older daemons is wiped too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Kristof Siket <siket@prisma.io>
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