Skip to content

opensaas dev brings up the Dev database, generates, reconciles and spawns the app - #1218

Merged
borisno2 merged 4 commits into
prisma-8from
claude/issue-1157-opensaas-dev-loop
Sep 6, 2026
Merged

opensaas dev brings up the Dev database, generates, reconciles and spawns the app#1218
borisno2 merged 4 commits into
prisma-8from
claude/issue-1157-opensaas-dev-loop

Conversation

@borisno2

@borisno2 borisno2 commented Sep 6, 2026

Copy link
Copy Markdown
Member

Implements #1157. Part of #1125.

opensaas dev grows from a config watcher into the dev loop of ADR-0063.

What it does now

  1. Starts the Dev database through core's startDevDatabase — persistent dataDir at .opensaas/dev-db, a free loopback port, and vector loaded when the config declares the pgvector pack (mapped from db.extensions, so a plugin-added pack counts too).
  2. Generates (the existing generateCommand() path).
  3. Reconciles with prisma db update against that database.
  4. Spawns the appnext dev, or the command after opensaas dev -- <cmd> — with no DATABASE_URL injected. The child takes the state-file branch of the lookup and reports 'dev-database' provenance, which is what the single-connection binding and marker suppression key off. PATH gains the project's node_modules/.bin so next resolves without a shell.
  5. Keeps watching opensaas.config.ts, regenerating on change as before. The database is a foreground sidecar: stop() runs on child exit and on SIGINT/SIGTERM, which are forwarded to the child.

DATABASE_URL (or DIRECT_DATABASE_URL) already set is the Database escape: no Dev database starts, the environment passes through untouched. The branch is decided by the lookup's own provenance, via a new findDatabaseConnection export on @opensaas/stack-core/internal — no second copy of the variable list.

The two riders

  • Every Prisma CLI spawn is asynchronous, with stdin closed. contract-emit.ts's spawnSync was a deadlock waiting to happen once generation runs while the socket server is up. Rather than adding a second async path, resolvePrismaBinary and the spawn moved into one runPrismaCli helper (src/generator/prisma-cli.ts) and emitContract became async, awaited by generateCommandgenerate standalone is unchanged in behaviour, and there is one spawn implementation, not two. The four test call sites were updated to await it.
  • A destructive plan on boot stops at Prisma's consent prompt. The boot db update runs with stdio: 'inherit', so the prompt reaches the user's terminal and the loop does not proceed past it; a refusal (or, with no terminal to ask, CLI.CONSENT_REQUIRED) leaves the database untouched, never starts the app, and exits non-zero. Mid-session staging, promotion and opensaas db update are Staged reconcile on config change, destructive handling, app restart, and opensaas db update #1158's and are not built here.

Tests

packages/cli/tests/dev-loop.test.ts drives the real opensaas dev binary on a real fixture project and observes the database and the spawned app, never the watcher:

  • with no DATABASE_URL: the app resolves 'dev-database', sees no injected URL, inserts and reads a row, and finds vector installed; the data directory persists and the state file is cleaned up
  • a destructive edit on boot: the plan is printed, the app never starts, exit is non-zero
  • the Database escape: a URL in the environment is used and no Dev database is started
  • twelve projects run concurrently and get twelve distinct ports with no state-file collision (≈16s for the file)

src/generator/prisma-cli.test.ts runs the acceptance criterion directly: a timer scheduled beside a Prisma run fires first (a spawnSync could not), and the spawn's stdio is ['ignore', 'pipe', 'pipe'] captured / 'inherit' interactive.

Verification

pnpm build, pnpm lint, pnpm manypkg fix, pnpm format clean; @opensaas/stack-cli 335/335 and @opensaas/stack-core 1527/1528 (1 pre-existing skip) pass.

🤖 Generated with Claude Code

…s and runs the app

`opensaas dev` becomes the dev loop (ADR-0063): it starts the Dev database on
a free loopback port under `.opensaas/dev-db`, loading `vector` when the config
declares the pgvector pack; runs `generate`; runs `prisma db update`; and spawns
the app — `next dev`, or the command after `--` — with no `DATABASE_URL`
injected, so the child resolves the database through the state file and reports
`'dev-database'` provenance. A URL already in the environment is the Database
escape: no sidecar starts and the environment passes through untouched. The
database dies with the process, and the config is still watched.

Every Prisma CLI spawn is asynchronous now, behind one `runPrismaCli` helper: a
`spawnSync` blocks the event loop the socket server is served on and deadlocks
the first `db update`. `contract emit` closes stdin as before; the boot
`db update` inherits the terminal, so a destructive plan stops at Prisma's own
consent prompt and the app is never started.

Implements #1157. Part of #1125.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Sep 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4d77d89

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@opensaas/stack-cli Minor
@opensaas/stack-core Minor
@opensaas/stack-auth Minor
@opensaas/stack-rag Minor
@opensaas/stack-storage Minor
@opensaas/stack-tiptap Minor
@opensaas/stack-ui Minor
@opensaas/stack-storage-s3 Minor
@opensaas/stack-storage-vercel Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@vercel

vercel Bot commented Sep 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
stack-docs Ready Ready Preview Sep 6, 2026 1:04pm UTC

Comment thread packages/cli/src/commands/dev.ts Outdated
ignoreInitial: true,
})
let database: DevDatabase | undefined
if (findDatabaseConnection({ cwd })?.provenance === 'env') {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The Database escape only looks at process.env, but every other participant loads the project .env.

findDatabaseConnection({ cwd }) reads process.env as the CLI process has it. Nothing in bin/opensaas.js, src/index.ts or dev.ts loads a .env. But the generated prisma.config.ts does (if (existsSync(envFile)) process.loadEnvFile(envFile) — see __snapshots__/prisma-config.test.ts.snap), and next dev loads .env / .env.local / .env.development on its own.

Scenario — the mainstream setup, since every example ships a .env.example with DATABASE_URL: a project keeps DATABASE_URL=postgres://…/myapp_dev in .env and the user runs opensaas dev.

  1. This check sees no variable, so the loop takes the else branch: it starts a PGlite sidecar, prints Dev database listening on …, and writes .opensaas/dev-db.json.
  2. reconcile() spawns prisma db update, whose prisma.config.ts loads .env first — so it reconciles the user's real Postgres, not the sidecar just started.
  3. next dev loads .env too, so the app resolves provenance 'env' and opens a pool against the real database — losing exactly the single-connection binding and marker suppression the whole no-injection design exists to preserve.

So the loop announces a Dev database nobody uses, and the state file it leaves behind will silently answer any later process that doesn't load .env (a plain node seed.mjs) with an empty PGlite instead of the intended error. The escape check needs the same .env load the generated prisma.config.ts performs before it decides which branch to take.

Comment thread packages/cli/src/commands/dev.ts Outdated
console.log(chalk.yellow('\n\nStopping dev mode...'))
watcher.close()
process.exit(0)
await generateCommand()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

A failing generateCommand() hard-exits the process, so the Dev database started above is never stopped.

generateCommand() does not throw on failure — every one of its error branches, and its outer catch, ends in process.exit(1) (generate.ts). By this line the sidecar is already running: PGlite has .opensaas/dev-db open and .opensaas/dev-db.json is written.

Scenario: a config with an unsatisfiable needs, a config refusal, a contract emit failure — any of them — and the process dies at process.exit(1). stop() never runs, so PGlite is never close()d (its data directory is left without a clean shutdown) and the state file is left on disk.

The line below has the same shape: reconcile() does not catch, and runPrismaCli rejects when resolvePrismaBinary throws (prisma not installed — a plausible first-run state). That rejection propagates out of devCommand past both stop() calls and terminates the process on the unhandled rejection.

Wrapping everything from the startDevDatabase call to the child's exit in a try/finally { await stop() } would cover the throwing paths; the process.exit paths additionally need a process.on('exit') hook, or generateCommand needs to throw rather than exit when it is called as a library.

Comment thread packages/cli/src/commands/dev.ts Outdated
const watcher = chokidar.watch(configPath, { persistent: true, ignoreInitial: true })
watcher.on('change', async () => {
console.log(chalk.yellow('\nConfig changed, regenerating...\n'))
await generateCommand()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

A regeneration failure from the watcher kills the loop and orphans the running app.

This handler was harmless before the loop grew a database and a child process: the old devCommand only watched and regenerated, so generateCommand()'s process.exit(1) just ended a watcher.

Now, scenario: the user is running opensaas dev, next dev is up, and they save an opensaas.config.ts with a typo or a config refusal (a mistyped db.indexes entry, an unsatisfiable needs). generateCommand() prints and calls process.exit(1). That takes down the parent immediately — stop() does not run, the in-process PGlite dies with it — while the spawned next dev is a separate process that keeps running, still attached to the terminal, now talking to a database that no longer exists. The user sees connection errors from an app they cannot easily tell is orphaned.

At minimum the handler should catch and report rather than let the regeneration take the loop (and the child) down with it.

Comment thread packages/cli/src/commands/dev.ts Outdated
const forward = (signal: 'SIGINT' | 'SIGTERM'): void => {
if (child.exitCode === null && child.signalCode === null) child.kill(signal)
}
process.once('SIGINT', () => forward('SIGINT'))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Signals are only handled once the app child exists — the window before it is exactly where the loop asks the user a question.

These process.once registrations happen after spawnApp. Everything before them — startDevDatabase, generateCommand, and the interactive prisma db update — runs with Node's default SIGINT/SIGTERM behaviour, which terminates the process outright.

Scenario: a destructive plan on boot puts Prisma's consent prompt on the terminal (the whole point of the 'interactive' stdio). The user reads the drop list, decides no, and presses Ctrl-C — the most natural response to a prompt they don't want to answer. The terminal delivers SIGINT to the group, the dev process has no handler, and it dies without stop(): PGlite is never closed, and .opensaas/dev-db.json is left behind naming a dead pid. (readDevDatabaseState's liveness check absorbs the stale file, but the unclean PGlite shutdown of the persistent .opensaas/dev-db data directory is not covered by anything.)

Registering the signal handlers as soon as database is assigned — running stop() when there is no child yet — would close the window.

Comment thread packages/cli/src/commands/dev.ts Outdated
const dataDir = path.join(cwd, DEV_DATABASE_DIR)
fs.mkdirSync(path.dirname(dataDir), { recursive: true })

database = await startDevDatabase({

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Nothing stops a second opensaas dev from opening the same data directory.

The branch above only diverts on provenance 'env'. A live state file — another opensaas dev already running in this project — reports 'dev-database', which falls through to here.

Scenario: the user leaves opensaas dev running in one terminal and starts it again in another (a very ordinary mistake, and the loop gives no hint it is already up). The second call hands PGlite the same .opensaas/dev-db path, which dev-database.ts's own Known limits call out as unsupported: "PGlite opens a data directory in one process at a time." There is no lock to refuse it, so two processes write the same directory. It then overwrites .opensaas/dev-db.json with its own URL, so the first loop's app child and any second-terminal prisma db update are silently redirected to the second sidecar; and when the second stops, clearDevDatabaseState matches its own URL and deletes the record, leaving the still-running first sidecar unreachable through the lookup.

readDevDatabaseState already returns the live record here — refusing to boot (or adopting it) when one exists is cheap.

Comment thread packages/cli/src/commands/dev.ts Outdated
const [file, ...args] = command
if (file === undefined) throw new Error('No app command to run.')

return spawn(file, args, {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The shell-free spawn cannot run the default command on Windows.

pathWithProjectBinaries is documented as making next dev "resolve without a shell", and prisma-cli.ts goes out of its way to handle win32, so the platform is in scope here.

On Windows, node_modules/.bin/next is next.cmd. libuv finds it via PATHEXT, and Node then refuses to execute a .cmd/.bat without shell: true (the CVE-2024-27980 fix), so spawn('next', ['dev']) fails with EINVAL. The child.once('error') handler below turns that into "Could not run next dev", i.e. the documented default invocation of opensaas dev never starts an app on Windows.

Either add the .cmd handling (resolve the shim explicitly, or shell: true on win32) or state the POSIX-only limit the way resolvePrismaBinary does.

// (ADR-0063) — a timer that never got to run is what that looks like.
expect(order).toEqual(['timer', 'prisma'])
expect(result.exitCode).toBe(0)
expect(result.output).toContain('8.0.0-rc')

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This assertion pins the test to a prerelease version string. The test is about ordering and exit status; the moment the pinned prisma moves to 8.0.0 proper (or 8.1.x), --version no longer contains 8.0.0-rc and this fails for a reason that has nothing to do with what it verifies. expect(result.output).toContain('prisma') — or dropping the line — keeps the acceptance criterion without the dependency-bump tripwire.

@borisno2 borisno2 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Code review — verdict: REQUEST CHANGES

(Posted as a Comment review: GitHub does not accept a formal review event from the PR's own author. The verdict above is the review's verdict.)

The shape of this is right — one async spawn implementation instead of two, the no-injection binding, provenance-driven escape, and an integration test that drives the real binary on a real project rather than the watcher's internals. Two things block it: a .env split-brain that defeats the no-injection design in the most common project layout, and process-lifecycle paths that exit without stop(). Seven line-level findings are posted inline; the summary and the additional points from a second pass are below.

Blocking

1. The Database escape misses .env, and the two halves of the loop then disagreepackages/cli/src/commands/dev.ts:134 (high, inline)

findDatabaseConnection({ cwd }) reads process.env only. The generated prisma.config.ts calls process.loadEnvFile('.env') before resolving (packages/cli/src/generator/prisma-config.ts:65-67), and next dev loads .env/.env.local itself. With DATABASE_URL in .env — which is exactly what every example's .env.example prescribes — opensaas dev takes the dev-database branch and starts a PGlite sidecar, while the prisma db update one line later reconciles the user's real Postgres, and the spawned app resolves 'env'. The sidecar is reconciled against nothing, the single-connection binding the no-injection design exists for is lost, and DDL lands on a database the user did not point the loop at. The escape check must resolve through the same env-file loading the rest of the loop does.

2. Failure paths exit the process without stop()dev.ts:167, :156, :190 (medium, inline)

  • generateCommand() never throws; every failure ends in process.exit(1). Called at :167 the Dev database is already up, so a config refusal kills the process with PGlite unclosed and the state file left behind.
  • runPrismaCli rejects when prisma is not installed (prisma-cli.ts:92 throws synchronously inside an async function). That rejection escapes devCommand past both stop() calls.
  • The watcher's generateCommand() at :156 exits mid-session: in-process PGlite dies, the spawned next dev is orphaned against a database that no longer exists.
  • SIGINT/SIGTERM are only installed at :190, after the child spawns. Ctrl-C at Prisma's consent prompt — the natural way to decline a destructive plan — skips stop() entirely and leaves the persistent data directory without a clean PGlite shutdown.

A single try { … } finally { await stop() } around everything from the database start, plus signal handlers registered as soon as the database is up, closes all four.

3. A second opensaas dev in the same project opens the same data directorydev.ts:145 (medium, inline)

The first loop's state file gives provenance 'dev-database', not 'env', so the second run falls straight through the escape and opens .opensaas/dev-db — which dev-database.ts's own Known limits say only one process may open. It also overwrites the state file (redirecting the first loop's clients) and deletes it on stop.

Non-blocking

  • env: { ...process.env, PATH: … } (dev.ts:82) — the injection side is correct: nothing is added, DATABASE_URL is genuinely absent, and the escape branch leaves the environment untouched (dev-loop.test.ts:129/:179 assert both). But on Windows the variable is Path; the spread reproduces that key literally and then adds a separate PATH, so the child can read Path and never see the project's .bin. Pair this with the next.cmd spawn issue already flagged inline at dev.ts:79.
  • The spawnApp docblock says "spawned with the environment as it stands" (dev.ts:68-74) while the code below it modifies PATH. The warning it carries is the right kind — the obvious "helpful" edit here is to inject DATABASE_URL — but the first sentence is already false.
  • Comment discipline. DEFAULT_APP_COMMAND's docblock (dev.ts:16) restates ['next', 'dev']; delete it. The devCommand docblock (:104-115) is three paragraphs of rationale that ADR-0063 already owns — it is not a consumer-facing surface, so trim it to the line or two that orient a reader and let the ADR reference carry the rest. prisma-cli.ts's long header earns its place (pnpm hoisting, the Windows sh-shim constraint, an explicit Known limits block), as does the mkdir-not-recursive note at dev.ts:140.
  • findDatabaseConnection is correctly typed — readonly fields, a string-literal provenance union, no any and no casts — and require(packageJson) in prisma-cli.ts:31 is narrowed by annotation rather than assertion, which is the right call. Minor: findDatabaseConnection is now a pure one-line alias of the private lookupDatabaseUrl; with the internal export in place, one of the two can go.
  • emitContract conversion is clean. Every call site is awaited — generate.ts:292, contract-emission.test.ts:41, emit-type-fixture.ts:74 — and runPrismaCli resolves on 'close', not 'exit', so captured output is complete. No dropped promises.
  • **prisma-cli.test.ts:44** pins toContain('8.0.0-rc')`; the next Prisma bump breaks it for a reason unrelated to the ordering criterion under test (inline).
  • Coverage gap. dev-loop.test.ts is genuinely good, but nothing exercises the lifecycle risk this PR introduces: no test kills the loop with SIGINT and asserts the child dies and the state file is gone, and none covers a failure between database start and app spawn. Given findings 1–3 all live on those paths, at least the SIGINT case is worth having.

… child

The Dev-database branch declined to inject a URL but never removed one the
process had inherited, so an ambient DATABASE_URL leaked into the app child and
put the generated runtime on the 'env' provenance branch, where it would
deadlock on Prisma's first-use contract-marker read. The escape branch still
passes the environment through untouched.

The unit suite now pins DATABASE_URL in setup and restores it in teardown, so
its outcome no longer depends on the machine's environment, and covers the
escape branch as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@borisno2

borisno2 commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

CI's test job failed one assertion in packages/cli/src/commands/dev.test.tsexpected '***localhost:5432/postgres' to be undefined.

Cause: the Dev-database branch built the app child's environment from process.env and declined to inject a DATABASE_URL, but never removed one already there. The CI test job has DATABASE_URL set ambiently, so it leaked into the child; locally there is none, so it passed. Under ADR-0063 that is not cosmetic — a child that inherits a stray URL resolves 'env' provenance instead of 'dev-database', losing the single-connection binding and the marker suppression, and would deadlock on Prisma's first-use contract-marker read.

Changed:

  • packages/cli/src/commands/dev.tsspawnApp now takes whether a sidecar started and deletes DATABASE_URL from the child's environment on that branch. The Database escape branch is untouched and still passes the environment through whole.
  • packages/cli/src/commands/dev.test.ts — the suite pins DATABASE_URL in setup and restores it in teardown so it no longer depends on the ambient environment; the dev-database case now sets one deliberately (making the assertion prove the removal rather than the machine's luck), and a new case covers the inverse: with a URL already resolved, no sidecar starts and the environment passes through.
  • Changeset lucky-hounds-listen.md reworded to say the inherited variable is removed, not merely uninjected.

Verified: pnpm build clean; the CLI suite (30 files / 336 tests) passes both with DATABASE_URL set and unset. Confirmed the new test fails without the product fix. pnpm lint reports no errors (2 pre-existing warnings elsewhere); manypkg fix and format produced no further changes.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Core Package Coverage (./packages/core)

Status Category Percentage Covered / Total
🟢 Lines 94.55% (🎯 65%) 2708 / 2864
🟢 Statements 93.1% (🎯 65%) 2958 / 3177
🟢 Functions 97.26% (🎯 62%) 534 / 549
🟢 Branches 87.88% (🎯 50%) 2075 / 2361
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/core/src/db/url.ts 100% 100% 100% 100%
Generated in workflow #2004 for commit 4d77d89 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for UI Package Coverage (./packages/ui)

Status Category Percentage Covered / Total
🔵 Lines 78.45% 244 / 311
🔵 Statements 77.95% 251 / 322
🔵 Functions 69.81% 74 / 106
🔵 Branches 66.94% 160 / 239
File CoverageNo changed files found.
Generated in workflow #2004 for commit 4d77d89 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for CLI Package Coverage (./packages/cli)

Status Category Percentage Covered / Total
🔵 Lines 72.31% 1392 / 1925
🔵 Statements 72.37% 1493 / 2063
🔵 Functions 81.81% 234 / 286
🔵 Branches 60.19% 738 / 1226
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/cli/src/commands/dev.ts 80.39% 61.22% 52.94% 86.36% 44-45, 83, 145, 148-149, 151, 152, 160-161, 176, 193-194, 197, 215-216
packages/cli/src/commands/generate.ts 6.21% 2.08% 85.71% 5.03% 79-345
packages/cli/src/generator/config-load.ts 0% 100% 0% 0% 26-35
packages/cli/src/generator/contract-emit.ts 28.57% 25% 50% 33.33% 10-13, 28
packages/cli/src/generator/prisma-cli.ts 83.33% 52.63% 100% 84.61% 42-50
Generated in workflow #2004 for commit 4d77d89 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Auth Package Coverage (./packages/auth)

Status Category Percentage Covered / Total
🔵 Lines 99.48% 195 / 196
🔵 Statements 98.13% 210 / 214
🔵 Functions 100% 44 / 44
🔵 Branches 90.77% 187 / 206
File CoverageNo changed files found.
Generated in workflow #2004 for commit 4d77d89 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Storage Package Coverage (./packages/storage)

Status Category Percentage Covered / Total
🔵 Lines 79.66% 235 / 295
🔵 Statements 81.17% 263 / 324
🔵 Functions 87.91% 80 / 91
🔵 Branches 77.46% 220 / 284
File CoverageNo changed files found.
Generated in workflow #2004 for commit 4d77d89 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for RAG Package Coverage (./packages/rag)

Status Category Percentage Covered / Total
🔵 Lines 54.38% 397 / 730
🔵 Statements 53.85% 419 / 778
🔵 Functions 64.06% 82 / 128
🔵 Branches 47.25% 198 / 419
File CoverageNo changed files found.
Generated in workflow #2004 for commit 4d77d89 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Storage S3 Package Coverage (./packages/storage-s3)

Status Category Percentage Covered / Total
🔵 Lines 100% 40 / 40
🔵 Statements 100% 40 / 40
🔵 Functions 100% 9 / 9
🔵 Branches 100% 19 / 19
File CoverageNo changed files found.
Generated in workflow #2004 for commit 4d77d89 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Storage Vercel Package Coverage (./packages/storage-vercel)

Status Category Percentage Covered / Total
🔵 Lines 100% 68 / 68
🔵 Statements 100% 71 / 71
🔵 Functions 100% 15 / 15
🔵 Branches 97.87% 46 / 47
File CoverageNo changed files found.
Generated in workflow #2004 for commit 4d77d89 by the Vitest Coverage Report Action

…ry exit path

`findDatabaseConnection` read only `process.env`, while the generated
`prisma.config.ts` and `next dev` both load the project's `.env`. A
`DATABASE_URL` there therefore started a sidecar nobody used and pointed
`prisma db update` at the developer's real database. The loop now loads
`.env` before deciding the escape, so the check and what it predicts see the
same connection; a shell variable still outranks the file.

The boot sequence is wrapped in `try`/`finally` and the signal handlers are
installed before the database starts, so a rejecting Prisma CLI, a failed
reconcile and Ctrl-C at the consent prompt all reach `stop()`. A synchronous
`exit` listener covers what `finally` cannot: `generateCommand` reports a
refusal by exiting the process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@borisno2

borisno2 commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

Thanks — all three findings folded in on ea494af7.

1. HIGH — the Database escape missed .env. Confirmed both halves: the generated prisma.config.ts does process.loadEnvFile(join(import.meta.dirname, '.env')) before calling findDatabaseUrl() (packages/cli/src/generator/prisma-config.ts), and runPrismaCli spawns the CLI with the project as cwd and no env override, so the reconcile really did resolve through a file the loop itself never read.

Fixed in the loop rather than in findDatabaseConnection: devCommand now calls loadProjectEnvFile(cwd) before the escape decision, using the same process.loadEnvFile the generated config uses — no new dotenv dependency, and the CLI already requires Node >= 22.18. Precedence is conventional for free: loadEnvFile leaves variables already present in the environment untouched (verified on Node 24 — a shell FOO survives a FOO in .env), so a shell variable still outranks the file. Core is deliberately unchanged: findDatabaseConnection is called from the generated runtime, where Next has already loaded .env; it is the loop that was reading a narrower environment than its own children.

Scope note: only .env is loaded, matching prisma.config.ts exactly. next dev additionally reads .env.local/.env.development; making the loop chase those would put it ahead of Prisma rather than in step with it, so it is left alone.

Two tests: dev.test.ts writes a .env with DATABASE_URL and nothing in the shell, and asserts no sidecar starts, stop() is never reached and the child keeps the URL; tests/dev-loop.test.ts does it for real — a .env pointing at a separate database, DATABASE_URL/DIRECT_DATABASE_URL stripped from the loop's environment — and asserts PROVENANCE env, rows read, and no .opensaas/dev-db on disk. Both fail without the change.

2. MEDIUM — exits without stop(). Everything from the escape decision to the child's exit is now inside one try, with stop() in the finally; SIGINT/SIGTERM handlers are installed before the database starts and removed in the same finally. That covers the runPrismaCli rejection, the failed reconcile, a spawnApp throw, and Ctrl-C at the consent prompt — the interrupt no longer terminates the process by default, the Prisma child takes the group signal and exits, and the loop unwinds through finally.

One correction to the suggested remedy: try/finally cannot cover generateCommand's process.exit(1)process.exit skips finally, so the async stop() is unreachable on that path no matter where the try starts. Rather than refactor generate.ts (13 exit sites, and a contract change for every caller) inside this PR, the loop registers a synchronous 'exit' listener that does what can be done there: kills the app child and drops this run's state file, so the watcher case no longer leaves next dev running against a database that is gone. The clean-shutdown version of that path wants generateCommand to throw, which is worth its own issue — say the word and I'll file it.

Coverage: a test asserts stop() runs when runPrismaCli rejects between database start and app spawn, and another asserts a SIGINT handler is installed by the time startDevDatabase is called and removed by the time the command returns.

3. LOW — comments. DEFAULT_APP_COMMAND's docblock is gone; devCommand's three paragraphs are down to one orientation line pointing at ADR-0063. The spawnApp "environment as it stands" sentence was already removed in 544fe1ef; what is left there is the DATABASE_URL-removal warning, which is still accurate.

Routed elsewhere, not touched here: two opensaas dev instances sharing one data directory → #1198. Windows PATH/Path casing and the next.cmd spawn → #1219 (ADR-0063 leaves Windows provisional pending verification on a real host). The 8.0.0-rc pin in prisma-cli.test.ts is left as-is.

pnpm build, pnpm lint (0 errors), manypkg, format clean. The CLI suite passes both with and without DATABASE_URL in the environment (340 tests, 30 files, each way). Changeset lucky-hounds-listen.md updated in place — no second changeset.

@borisno2 borisno2 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Re-review — verdict: APPROVE

(Posted as a Comment review: GitHub does not accept a formal Approve event from the PR's own author. The verdict above is this review's verdict.)

Checked ea494af7 against the three blocking/major findings, and re-ran the tests both as they stand and with each fix reverted. All three are resolved, the new tests genuinely discriminate, and the diff since 544fe1ef is confined to dev.ts, its two test files and the changeset — nothing routed elsewhere was smuggled in.

1. HIGH — the Database escape missed .envRESOLVED

devCommand now calls loadProjectEnvFile(cwd) at packages/cli/src/commands/dev.ts:127, before the escape decision at :172, using process.loadEnvFile behind an existsSync guard (:59-62) — the same call and the same single file the generated prisma.config.ts makes, so the loop is exactly in step with its own prisma db update rather than ahead of it. No new dependency.

The two claims that mattered both hold on inspection and in practice:

  • The escape sees what its children see. lookupDatabaseUrl consults DIRECT_DATABASE_URL/DATABASE_URL before the state file (packages/core/src/db/url.ts:44-49), so a URL arriving from .env yields 'env' and takes the escape branch. Nothing mutates the environment between :127 and the spawnApp at :210.
  • Shell outranks .env. Verified on Node 24.10: with DATABASE_URL in the shell and in .env, process.loadEnvFile leaves the shell value in place and still imports the file's other keys. This is now covered by a test rather than only asserted — dev.test.ts:158 writes a .env with nothing in the shell, and dev.test.ts:130/:142 cover the shell-set cases.

The seam with the earlier delete env.DATABASE_URL change is sound. In the sidecar branch the .env cannot have contributed a non-empty DATABASE_URL/DIRECT_DATABASE_URL — that would have produced 'env' and taken the escape — so loadProjectEnvFile cannot smuggle a URL into the child, and the delete at :91 still catches an empty-string or ambient value. I confirmed this end to end: tests/dev-loop.test.ts:187 runs the real binary with both connection variables stripped from its environment and a .env pointing at a separate database, and asserts PROVENANCE env, real rows, and no .opensaas/dev-db on disk.

Discrimination checked by reverting. Deleting the loadProjectEnvFile(cwd) call and rebuilding: dev.test.ts:173 fails (startDevDatabase called), and the integration test fails with the app talking to the sidecar instead of the .env database — the exact split-brain the finding described.

2. MEDIUM — exits without stop()RESOLVED

dev.ts:171-226: everything from the escape decision to the child's exit is inside one try, with stop() in the finally; SIGINT/SIGTERM/exit handlers are installed at :167-169, before the database starts, and removed at :221-223.

  • Handlers are removed on the normal path. process.off for all three in the finally; dev.test.ts:189 pins listenerCount('SIGINT') at baseline+1 inside startDevDatabase and back to baseline on return, so a repeated in-process run leaks nothing.
  • Double stop() is safe. DevDatabase.stop is guarded by a stopped flag (packages/core/src/db/dev-database.ts:194-206) and is in any case only called from the single finally.
  • The 'exit' listener is synchronous-only. child.kill and fs.rmSync (:159-162) — no promise is created and dropped there.
  • The process.exit limitation claim is correct and honestly scoped. process.exit does not unwind, so no try/finally placement can reach an async stop() on generateCommand's exit paths. The comment at :154-158 says exactly that and confines itself to what the listener does cover, and #1223 carries the real fix. That mitigation also closes the orphaned-next dev case I raised at :156 — the child is killed synchronously before the process goes.

Reverting the fix (signals registered after the spawn, no stop() in finally) fails four tests, including the runPrismaCli-rejection case at dev.test.ts:178 and the handler-timing case at :189.

3. LOW — comment discipline → RESOLVED

DEFAULT_APP_COMMAND is a bare const at dev.ts:16. devCommand's three paragraphs are one orientation line at :114 pointing at ADR-0063. The comments added in this round all carry something the code cannot: the loadProjectEnvFile docblock names the two other participants that load .env and the non-obvious no-overwrite semantics; the 'exit' block names the process.exit constraint and its own limits; the signal-registration note names the default-SIGINT behaviour it exists to displace. No restatement introduced.

Also confirmed

  • No any, no type assertions in the new code. config-load.ts actually removes one (jiti.import<…> in place of the old as { default: … }); the only as never in dev.test.ts:98 is the pre-existing process.exit stub.
  • Routed-elsewhere findings genuinely untouched: no single-writer guard or dataDir change (#1198), no Path casing or .cmd handling in spawnApp (#1219), and prisma-cli.test.ts:44 still pins 8.0.0-rc.
  • CLI unit suite passes with and without an ambient DATABASE_URL (171 tests in src/), plus the .env integration case against a real database.

Non-blocking residuals

  • A second Ctrl-C is still an unclean exit. The handlers are process.once, so after the first SIGINT is consumed a second one gets Node's default termination, which runs neither the finally nor the 'exit' listener. That is the realistic sequence when the app child does not die on the first interrupt. Re-arming (or using process.on plus the explicit off you already have) would close it.
  • onExit's fs.rmSync(database.stateFile) is less careful than stop()'s clearDevDatabaseState(stateFile, url), which only clears the record when the URL still matches this run. On the process.exit path with two loops in one project it would delete another run's record — squarely inside #1198's territory, so noting rather than blocking.
  • The changeset says the loop "shuts the database down on every exit path" while #1223 exists precisely because one path is not covered. The sentence that follows enumerates the three that are; tightening the claim would keep the release note honest.

…t unwind

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@borisno2
borisno2 merged commit 745cc0b into prisma-8 Sep 6, 2026
6 checks passed
@borisno2
borisno2 deleted the claude/issue-1157-opensaas-dev-loop branch September 6, 2026 13:13
borisno2 added a commit that referenced this pull request Sep 6, 2026
…e.ts

The base moved under this branch while it was in review, so GitHub could
compute no merge ref and CI never ran for 300b17d. The one content conflict
was `packages/core/src/internal.ts`, where this branch's `where/like.ts`
re-exports and the base's `findDatabaseConnection` (#1218) were added at the
same point; both are kept.

#1220 landed the Where vocabulary from its own branch rather than adopting
this branch's engine-owned escaper, leaving a second implementation:
`containsPattern()` in `secured/vocabulary.ts`. It performed the same three
replacements in the same order with the same wrapping as `likeContainsPattern`,
so it is deleted and the `contains` case now calls the shared builder. One
escaper, as ADR-0060 requires. #1220's Where-vocabulary and filter tests pass
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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