From 0a51f76e684c0910dd2f9653dbc5f652a7227f2b Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Wed, 22 Apr 2026 13:21:59 +0200 Subject: [PATCH 1/8] docs(rsc-poc): add shaping plan for RSC concurrency safety PoC Project plan for TML-2164 (VP3 of WS3 Runtime pipeline). Documents hypotheses H1-H5 about Prisma Next's behavior under Next.js App Router concurrent rendering, deliverables (two Next.js 16 apps, k6 stress scripts, one integration test for the predicted always-mode race, findings doc + conditional ADR), and work breakdown. --- projects/rsc-concurrency-safety/plan.md | 278 ++++++++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 projects/rsc-concurrency-safety/plan.md diff --git a/projects/rsc-concurrency-safety/plan.md b/projects/rsc-concurrency-safety/plan.md new file mode 100644 index 0000000000..b4d415393d --- /dev/null +++ b/projects/rsc-concurrency-safety/plan.md @@ -0,0 +1,278 @@ +# RSC Concurrency Safety PoC — Plan + +**Linear:** [TML-2164](https://linear.app/prisma-company/issue/TML-2164/rsc-concurrency-safety-poc) +**Milestone:** VP3 of WS3 (Runtime pipeline) +**Project branch:** `tml-2164-rsc-concurrency-safety-poc` +**Status:** Shaping — draft plan, pending team validation. + +This file is the **project plan**. The project spec (`spec.md`) will be +written once the plan is validated; for now the ticket + this plan define +scope. + +--- + +## 1. Objective + +Determine whether Prisma Next's runtime and ORM client behave correctly when +multiple React Server Components query through a **shared** instance under +Next.js App Router concurrent rendering. + +Stop condition (from the ticket): the PoC either works correctly **or** we've +identified the specific concurrency issues and know what to fix. Pool sizing +guidance, edge runtime validation, and production-ready concurrency +guarantees are explicitly **out of scope** — those are May. + +## 2. Hypotheses (what we expect to find) + +These come from reading the source, not from running anything. The PoC +confirms or refutes them. + +### H1 — Collection cache race is a non-issue +The `orm()` Proxy in `packages/3-extensions/sql-orm-client/src/orm.ts` has a +**synchronous** `get` trap: construct `Collection`, store in `Map`, return. +No `await` inside the trap, so concurrent first-access cannot interleave on +Node's event loop. The worst case is redundant work, which is impossible +because the trap is a single microtask-free path. + +**Expected outcome:** PoC confirms. No bug to fix. + +### H2 — `verified` / `startupVerified` in `onFirstUse` and `startup` modes: bug, but not a correctness bug +In `RuntimeCoreImpl.verifyPlanIfNeeded` (runtime-executor), the flag flips +monotonically `false → true`. Concurrent cold-start queries can each fire +their own marker-read roundtrip before the first one lands (`await` between +the `if (this.verified) return` check and the `this.verified = true` write). +The operations are idempotent and all succeed against the same marker, so +results are correct — but up to N-1 of those roundtrips are wasted work +that a user would (rightly) file a bug against. + +**Expected outcome:** N redundant marker reads on cold start, observable via +telemetry. Results are correct; the wasted roundtrips are a real bug worth +fixing (dedupe in-flight verification via a shared promise). Severity: +low-to-moderate, not a correctness violation. + +### H3 — `verified` / `startupVerified` in `always` mode: **real correctness bug** +In `verify.mode === 'always'`, the method sets `this.verified = false` at +entry, then awaits the marker read, then sets `this.verified = true`. +Interleaving: + +1. Query A: `verified = false` +2. Query B: `verified = false` +3. Query A: marker read → `verified = true` +4. Query B: checks `if (this.verified) return` → **skips its own + verification** + +This violates the `always` contract (every execution must verify). Severity +depends on the semantics users rely on for `always`; at minimum it's a +surprising silent violation. + +**Expected outcome:** reproducible under load. Correctness violation, not +just wasted work. + +### H4 — Connection pool pressure is a sizing/liveness concern, not a safety bug +5 parallel Server Components × N concurrent requests contend for the pg +pool. Expected symptoms: tail-latency cliff when `concurrency × components > +pool size`; possible deadlock if a request holds a connection while waiting +for another. We measure, we don't fix (pool sizing guidance is May). + +### H5 — Mongo stack has none of the hazards in H2/H3 +Source-level audit: +- `MongoRuntimeImpl` (`packages/2-mongo-family/7-runtime/src/mongo-runtime.ts`): + no verification state, no markers — nothing to race on. +- `mongoOrm()` (`packages/2-mongo-family/5-query-builders/orm/src/mongo-orm.ts`): + **eagerly** constructs all collections in a `for` loop at init time — no + lazy cache. + +So the Mongo app is **not** re-running the same experiment. Its value in +this PoC: +- Coverage of a second family under RSC concurrency at all. +- Baseline: if Postgres shows redundant marker reads on cold start and Mongo + shows nothing analogous, that **localizes** the issue to the SQL runtime. +- Mongo driver pool behavior under RSC concurrency (genuinely different + question from pg pool behavior). + +The plan calls out in the findings doc that the two apps are probing +different things on purpose. + +## 3. Deliverables + +### 3.1 Two Next.js 16 App Router apps + +Both live under `examples/` so they survive project close-out. + +``` +examples/rsc-poc-postgres/ # Postgres + pg pool + SQL runtime +examples/rsc-poc-mongo/ # Mongo + mongo driver + mongo runtime +``` + +Each app: +- Next.js 16, App Router, React 19, `export const dynamic = 'force-dynamic'`. +- No Cache Components, no PPR, no `'use cache'` — caching masks the + behavior we're trying to observe. +- HMR-safe `globalThis` singleton for the Prisma Next runtime/db in + `src/lib/db.ts`. +- 5 parallel Server Components on `/` covering a **mix** of code paths + (ORM + SQL DSL + includes + raw reads; see §4). +- `/stress/always` route (Postgres only): same page but with the runtime + pinned to `verify.mode === 'always'` to reproduce H3. +- One Server Action (`POST`-style): proves mutations alongside concurrent + reads don't explode (ticket says reads; we agreed to add one smoke + action). +- Structured telemetry surfaced to a dev-only `` at page + bottom: marker-read count, verification-fire count, connection-acquire + count, per-component timings. +- README documenting how to run, what to look at, known-clean / + known-broken routes. + +### 3.2 Load script per app + +``` +examples/rsc-poc-postgres/scripts/stress.k6.js +examples/rsc-poc-mongo/scripts/stress.k6.js +``` + +**Tool:** k6. + +Scenarios: +- `baseline`: 10 VUs × 30s hitting `/`. +- `spike` (Postgres only): 50 VUs × 30s hitting `/stress/always`, designed + to reproduce H3 by maximizing async interleaving. +- `pool-pressure`: gradually ramp VUs 1 → 100 against `/` with a small pool + (`max: 5`) to characterize H4. + +Scripts emit JSON summaries we can commit as reference output. + +### 3.3 One integration test (Postgres) + +`examples/rsc-poc-postgres/test/always-mode-race.test.ts` + +Asserts the one race we can predict up-front (H3): + +> When `verify.mode === 'always'` and K concurrent queries share a runtime, +> the number of verification marker reads **equals** K. + +Implemented by counting marker-read statements via a spy driver (or the +telemetry middleware + a pg-query-capture hook — pick the lighter one +during implementation). If the test passes, we've reproduced the bug; if +it fails, H3 was wrong and we update the findings doc. + +Observational output remains the primary deliverable per §3.1; this test +exists specifically to lock in one predicted invariant so regressions are +caught later. + +### 3.4 Findings doc (final) + +During execution: notes live under +`projects/rsc-concurrency-safety/notes.md`. + +At close-out: migrate to `docs/reference/rsc-concurrency-findings.md` +covering: +- What we observed on each app under each scenario. +- Whether H1–H5 held. +- Concrete fixes for whatever's broken (or argument for "safe as-is"). +- Recommended user-facing pattern (the `globalThis` singleton). +- Explicit list of things deferred to May. + +### 3.5 ADR (conditional) + +If H3 reproduces and the fix isn't trivial (e.g. it requires changing the +semantics of `verify.mode === 'always'` or introducing a mutex/ticket around +verification), draft an ADR under +`projects/rsc-concurrency-safety/adr-draft-verification-concurrency.md` and +migrate to `docs/architecture docs/adrs/` at close-out. + +## 4. The 5 Server Components (shape) + +Postgres app, `/`: +1. `` — ORM: `db.User.orderBy(...).take(10).all()` +2. `` — ORM with include: `db.Post.include('user').take(10).all()` + (exercises multi-query include dispatch) +3. `` — SQL DSL: `db.sql.post.where(...).select(...).build()` + then `runtime.execute(plan)` +4. `` — ORM aggregate: `db.User.groupBy(...).aggregate(...)` +5. `` — pgvector similarity search via ORM + +Mongo app, `/`: five analogous queries over the retail-store domain +(products / orders / categories). Exact shapes decided during +implementation; the point is "five concurrent reads, varied shapes". + +Server Action: `submitFeedback` (Postgres) / `addToCart` (Mongo). One +insert. Invoked manually from a form on `/`; not hit by k6. + +## 5. Out of scope + +Explicit non-goals so reviewers don't ask: +- Pool sizing guidance (May). +- Edge runtime validation (May). +- Transaction semantics across Server Components (VP1). +- Production-ready concurrency guarantees (May). +- Fixing H3 itself in this PoC, beyond documenting and (if shaped enough) + an ADR draft. The fix belongs in a follow-on issue under the same VP3. +- Cache Components / PPR / `'use cache'` (would mask the behavior). +- Benchmarks (Side-quest milestone). + +## 6. Risks & mitigations + +| Risk | Mitigation | +|---|---| +| H3 does not reproduce under k6 load | Add a deterministic test with `setImmediate`/manual scheduling to force interleaving; if still absent, update H3. | +| pg driver's own internal serialization hides the race | Inspect `@prisma-next/driver-postgres` to confirm whether queries are serialized per connection; design stress to use N connections. | +| Next.js 16 churn: RSC semantics / caching defaults shift under us during the PoC | Pin a specific Next.js 16 minor; document version in each app's README. | +| "Telemetry counting" conflates retries, middleware hooks, and actual marker reads | Count at the driver level (spy), not at middleware level, for the H3 assertion. | +| Two apps double the maintenance burden | Keep them minimal; no shared UI kit; copy-paste over abstraction. | + +## 7. Work breakdown & sequencing + +Each bullet is a candidate PR. Branches off `tml-2164-rsc-concurrency-safety-poc`. + +1. **Shaping PR** — this plan + a short `spec.md` under + `projects/rsc-concurrency-safety/`. Validate with team before starting + implementation. *(Blocks everything else.)* +2. **Postgres app scaffold** — `examples/rsc-poc-postgres/` with one trivial + Server Component, `globalThis` singleton, dev-only diag panel, READMEs. + Reuses `prisma-next-demo`'s contract/schema to avoid re-writing it. +3. **Postgres: 5 Server Components + Server Action** — the actual page. +4. **Postgres: `/stress/always` route + k6 scripts** — reproduce H3 + observationally. +5. **Postgres: integration test for H3** — deterministic assertion. +6. **Mongo app scaffold** — `examples/rsc-poc-mongo/`, reusing + `retail-store`'s contract/seed. +7. **Mongo: 5 Server Components + Server Action + k6 scripts**. +8. **Findings write-up** — `projects/rsc-concurrency-safety/notes.md` + consolidated; decide whether an ADR is needed based on results. +9. **Close-out PR** — migrate findings to `docs/reference/`, (optionally) + ADR to `docs/architecture docs/adrs/`, delete + `projects/rsc-concurrency-safety/`. Apps stay. + +Rough sizing (calibration, not a commitment): 1 is hours; 2, 6 are +half-day each; 3, 7 are a day each; 4, 5 are a day total; 8, 9 are a day. +~5 working days assuming no surprises. Surprises are the whole point, so +assume more. + +## 8. Open questions (to resolve during spec validation) + +- Pool size for `pool-pressure` scenario — `max: 5` is a guess designed to + force contention quickly. Revisit after first run. + +### Resolved during shaping + +- **Scope of the shared runtime:** process-scoped (one runtime per Node + process, held via the `globalThis` HMR-safe singleton pattern). Not + request-scoped via `cache()`. This matches framework-integration-analysis + §"Hard problem 2" and is exactly the configuration that exposes H1–H3. +- **Postgres entry point:** use the bundled `@prisma-next/postgres` runtime + (what `prisma-next-demo` uses). It's the copy-paste path users will take. + +## 9. Acceptance criteria + +- [ ] Two Next.js 16 apps exist under `examples/rsc-poc-postgres/` and + `examples/rsc-poc-mongo/`, each with 5 parallel RSC reads + 1 Server + Action, runnable locally per README. +- [ ] k6 scripts exist and have been run at least once; summaries committed + under `scripts/` as reference. +- [ ] `/stress/always` either reproduces H3 (confirmed via integration test) + **or** findings doc explains why it doesn't. +- [ ] Findings doc covers H1–H5 with evidence. +- [ ] Stakeholder (project lead) signs off that VP3's stop condition is + met. +- [ ] At close-out: findings migrated to `docs/`, (optional) ADR migrated, + `projects/rsc-concurrency-safety/` deleted. \ No newline at end of file From d666f14bab97be4fd57b9d501545edaad6f6e9c7 Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Wed, 22 Apr 2026 13:35:13 +0200 Subject: [PATCH 2/8] examples(rsc-poc-postgres): scaffold Next.js 16 PoC app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffold-only PR for step 2 of the RSC concurrency safety PoC plan. This adds a minimal Next.js 16 App Router app that boots, reads one user via the ORM client in a Server Component, and renders the at page bottom. No stress scenarios, no Server Action, no parallel components yet — those land in step 3. Harness highlights: - Process-scoped runtime singleton via `globalThis` (`src/lib/db.ts`), keyed by (verifyMode, poolMax) so /\ and /stress/always don't share state. Survives Next.js HMR. - `InstrumentedPool` (`src/lib/pool.ts`) — subclass of `pg.Pool` that counts connection acquires/releases and marker reads (detected by SQL text containing `prisma_contract.marker`). Subclassing (not composition) is load-bearing: `@prisma-next/postgres` uses `instanceof PgPool` to route bindings. - `diag.ts` — `globalThis`-backed counter registry keyed by verifyMode, so the panel and tests can observe marker-read / connection-acquire behavior without perturbing it. Schema + seed are reused verbatim from `prisma-next-demo` per the plan's copy-paste-over-abstraction guidance. Refs: TML-2164, `projects/rsc-concurrency-safety/plan.md` --- examples/rsc-poc-postgres/.env.example | 17 + examples/rsc-poc-postgres/.gitignore | 7 + examples/rsc-poc-postgres/README.md | 163 ++++ examples/rsc-poc-postgres/app/globals.css | 196 +++++ examples/rsc-poc-postgres/app/layout.tsx | 18 + examples/rsc-poc-postgres/app/page.tsx | 61 ++ examples/rsc-poc-postgres/biome.jsonc | 6 + examples/rsc-poc-postgres/next.config.js | 9 + examples/rsc-poc-postgres/package.json | 58 ++ .../rsc-poc-postgres/prisma-next.config.ts | 24 + .../rsc-poc-postgres/prisma/schema.prisma | 69 ++ examples/rsc-poc-postgres/scripts/drop-db.ts | 35 + examples/rsc-poc-postgres/scripts/seed.ts | 129 ++++ .../src/components/diag-panel.tsx | 65 ++ examples/rsc-poc-postgres/src/lib/db.ts | 160 ++++ examples/rsc-poc-postgres/src/lib/diag.ts | 116 +++ examples/rsc-poc-postgres/src/lib/pool.ts | 120 +++ .../rsc-poc-postgres/src/prisma/contract.d.ts | 646 ++++++++++++++++ .../rsc-poc-postgres/src/prisma/contract.json | 724 ++++++++++++++++++ examples/rsc-poc-postgres/tsconfig.json | 29 + examples/rsc-poc-postgres/turbo.json | 17 + pnpm-lock.yaml | 227 ++++++ 22 files changed, 2896 insertions(+) create mode 100644 examples/rsc-poc-postgres/.env.example create mode 100644 examples/rsc-poc-postgres/.gitignore create mode 100644 examples/rsc-poc-postgres/README.md create mode 100644 examples/rsc-poc-postgres/app/globals.css create mode 100644 examples/rsc-poc-postgres/app/layout.tsx create mode 100644 examples/rsc-poc-postgres/app/page.tsx create mode 100644 examples/rsc-poc-postgres/biome.jsonc create mode 100644 examples/rsc-poc-postgres/next.config.js create mode 100644 examples/rsc-poc-postgres/package.json create mode 100644 examples/rsc-poc-postgres/prisma-next.config.ts create mode 100644 examples/rsc-poc-postgres/prisma/schema.prisma create mode 100644 examples/rsc-poc-postgres/scripts/drop-db.ts create mode 100644 examples/rsc-poc-postgres/scripts/seed.ts create mode 100644 examples/rsc-poc-postgres/src/components/diag-panel.tsx create mode 100644 examples/rsc-poc-postgres/src/lib/db.ts create mode 100644 examples/rsc-poc-postgres/src/lib/diag.ts create mode 100644 examples/rsc-poc-postgres/src/lib/pool.ts create mode 100644 examples/rsc-poc-postgres/src/prisma/contract.d.ts create mode 100644 examples/rsc-poc-postgres/src/prisma/contract.json create mode 100644 examples/rsc-poc-postgres/tsconfig.json create mode 100644 examples/rsc-poc-postgres/turbo.json diff --git a/examples/rsc-poc-postgres/.env.example b/examples/rsc-poc-postgres/.env.example new file mode 100644 index 0000000000..b082ab3ef6 --- /dev/null +++ b/examples/rsc-poc-postgres/.env.example @@ -0,0 +1,17 @@ +# Copy to .env and set a Postgres connection string. +# +# For local development, any Postgres 14+ instance with the `vector` extension +# available works. Easiest path: `@prisma/dev` (see prisma-next-demo's README) +# or a Docker one-liner: +# +# docker run --rm -d --name rsc-poc-pg -p 5432:5432 \ +# -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=rsc_poc \ +# pgvector/pgvector:pg17 +# +# Then: +# +# pnpm emit # generate contract.json + contract.d.ts +# pnpm db:init # apply schema and marker +# pnpm seed # populate sample data +# pnpm dev # start Next.js +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/rsc_poc diff --git a/examples/rsc-poc-postgres/.gitignore b/examples/rsc-poc-postgres/.gitignore new file mode 100644 index 0000000000..2c0b4101c3 --- /dev/null +++ b/examples/rsc-poc-postgres/.gitignore @@ -0,0 +1,7 @@ +next-env.d.ts +.next +dist +node_modules +*.tsbuildinfo +.env +.env.local diff --git a/examples/rsc-poc-postgres/README.md b/examples/rsc-poc-postgres/README.md new file mode 100644 index 0000000000..7f90064b48 --- /dev/null +++ b/examples/rsc-poc-postgres/README.md @@ -0,0 +1,163 @@ +# rsc-poc-postgres + +Next.js 16 App Router proof-of-concept for **Prisma Next runtime behavior +under RSC concurrent rendering**. Paired with `rsc-poc-mongo`; together they +cover VP3 of the WS3 runtime-pipeline milestone (Linear: [TML-2164][t]). + +See `projects/rsc-concurrency-safety/plan.md` for the full project plan, +including hypotheses H1–H5 and acceptance criteria. + +[t]: https://linear.app/prisma-company/issue/TML-2164/rsc-concurrency-safety-poc + +## What this app exists to probe + +The runtime (`RuntimeCoreImpl`) has mutable `verified` / `startupVerified` +flags, and the ORM client lazily populates a `Collection` cache. React +Server Components render concurrently within a single request, all sharing +one runtime instance. This app forces that configuration and instruments it +so we can observe what happens. + +Specifically, this Postgres app targets: + +- **H2** — Redundant marker reads on cold start under concurrent first-use + (`onFirstUse` / `startup` modes). Bug (wasted roundtrips), not a + correctness violation. +- **H3** — Skipped verification under concurrency when + `verify.mode === 'always'`. Real correctness bug; reproduced on the + `/stress/always` route. +- **H4** — pg pool pressure when component count exceeds pool size. + Characterized by the `pool-pressure` k6 scenario, not fixed. + +The companion `rsc-poc-mongo` app is a baseline — the Mongo runtime and ORM +have none of these hazards by construction, so comparing the two +**localizes** the SQL-stack issues. + +## Status + +**Scaffold only.** The five parallel Server Components, `/stress/always` +route, Server Action, k6 scripts, and integration test land in subsequent +PRs. See the project plan for the work breakdown. + +## Prerequisites + +- Node.js ≥ 24 (see root `package.json` `engines.node`) +- pnpm (see root `package.json` `packageManager`) +- A Postgres 14+ instance with the `vector` extension available. The + easiest path is the `pgvector/pgvector:pg17` Docker image: + + ```sh + docker run --rm -d --name rsc-poc-pg -p 5432:5432 \ + -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=rsc_poc \ + pgvector/pgvector:pg17 + ``` + +- [k6](https://k6.io/) for running the stress scripts (install via + `brew install k6` on macOS). Not needed for `pnpm dev`. + +## Getting started + +```sh +cp .env.example .env # then edit DATABASE_URL if needed +pnpm install # from the repo root +pnpm --filter rsc-poc-postgres emit # generate contract.json + contract.d.ts +pnpm --filter rsc-poc-postgres db:init # apply schema + marker +pnpm --filter rsc-poc-postgres seed # populate sample data +pnpm --filter rsc-poc-postgres dev # start Next.js on :3000 +``` + +Open http://localhost:3000 and watch the diagnostics panel at the bottom +of the page for marker-read and connection-acquire counters. + +## Routes + +| Route | Purpose | Verify mode | +|-------------------|------------------------------------------------------|---------------| +| `/` | Five parallel Server Components (varied shapes). | `onFirstUse` | +| `/stress/always` | Same page, pinned to `always` mode to reproduce H3. | `always` | + +Additional routes arrive in later PRs; this table is the planned surface. + +## Stress scripts + +```sh +pnpm stress:baseline # 10 VUs × 30s against / +pnpm stress:spike # 50 VUs × 30s against /stress/always (H3) +pnpm stress:pool-pressure # ramp 1 → 100 VUs with small pool (H4) +``` + +Each scenario writes a JSON summary next to the script for posterity. + +## How the singleton works + +`src/lib/db.ts` pins the runtime to `globalThis` via a `Symbol.for(...)` +key. This survives Next.js dev-mode HMR (module re-evaluation would +otherwise leak a new `pg.Pool` on every edit within seconds). In +production, there is no HMR — the pattern collapses to a regular +module-level singleton per Node process. + +Each unique `(verifyMode, poolMax)` combination gets its own entry in the +registry, so `/` and `/stress/always` never share a runtime. They are +probing different hypotheses and must not contaminate each other's +counters. + +## How the diagnostics work + +`src/lib/pool.ts` defines `InstrumentedPool`, a subclass of `pg.Pool`. +Subclassing (not wrapping) is deliberate: `@prisma-next/postgres`'s +`resolvePostgresBinding()` uses `instanceof PgPool` to decide whether to +route the input into the `pgPool` binding branch. A composition wrapper +would fail that check. + +`InstrumentedPool` overrides `connect()` to: + +1. Count pool connection acquires. +2. Instrument the acquired `PoolClient` in place so that: + - `client.query(sql, ...)` matches `sql` against the stable marker-read + fragment (`prisma_contract.marker`) and bumps the marker-read counter + if it's a verification query. + - `client.release()` bumps the release counter. + +Counters live in `src/lib/diag.ts`, also pinned to `globalThis` so they +survive HMR. The `` Server Component reads a snapshot and +renders it at page bottom. + +## Layout + +``` +app/ Next.js App Router entrypoints + layout.tsx Root layout + page.tsx Home (five parallel RSC — WIP) + globals.css Minimal dark theme +prisma/ + schema.prisma PSL schema (reused from prisma-next-demo) +src/ + components/ + diag-panel.tsx Server Component that renders counter snapshots + lib/ + db.ts globalThis-scoped runtime singleton + diag.ts In-process counter registry + pool.ts Instrumented pg.Pool subclass + prisma/ + contract.json Generated (gitignored by convention? see below) + contract.d.ts Generated +scripts/ + drop-db.ts Reset schema + seed.ts Populate sample data + stress.k6.js k6 stress scenarios (WIP) +test/ Integration tests (WIP) +prisma-next.config.ts +next.config.js +package.json +tsconfig.json +``` + +Contract artifacts (`contract.json`, `contract.d.ts`) are committed +alongside the source — the plan's stop condition requires the app to run +out of the box after `pnpm install && pnpm emit`. + +## Related + +- Project plan: `projects/rsc-concurrency-safety/plan.md` +- Framework integration analysis §"Hard problem 2": + `docs/reference/framework-integration-analysis.md` +- Companion Mongo app: `examples/rsc-poc-mongo/` (planned) \ No newline at end of file diff --git a/examples/rsc-poc-postgres/app/globals.css b/examples/rsc-poc-postgres/app/globals.css new file mode 100644 index 0000000000..7bb7eb407c --- /dev/null +++ b/examples/rsc-poc-postgres/app/globals.css @@ -0,0 +1,196 @@ +:root { + --bg: #0b0d10; + --fg: #e6e8eb; + --muted: #8a929b; + --accent: #6ea8fe; + --border: #1f242b; + --card: #11151a; + --success: #4ade80; + --warn: #fbbf24; + --error: #f87171; + --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + --sans: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--fg); + font-family: var(--sans); + font-size: 14px; + line-height: 1.5; +} + +main { + max-width: 1100px; + margin: 0 auto; + padding: 2rem 1.5rem 6rem; +} + +h1 { + font-size: 1.4rem; + font-weight: 600; + margin: 0 0 0.25rem; +} + +h2 { + font-size: 1rem; + font-weight: 600; + margin: 0 0 0.5rem; + color: var(--fg); +} + +p { + margin: 0 0 0.75rem; +} + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +code, +pre { + font-family: var(--mono); + font-size: 0.85em; +} + +pre { + background: var(--card); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.75rem 1rem; + overflow-x: auto; + margin: 0 0 0.75rem; +} + +.muted { + color: var(--muted); +} + +.grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); +} + +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem 1.25rem; +} + +.card h2 { + font-size: 0.9rem; + margin-bottom: 0.5rem; +} + +.card ul { + margin: 0; + padding-left: 1.2rem; +} + +.card li { + margin-bottom: 0.25rem; +} + +.badge { + display: inline-block; + padding: 0.1rem 0.5rem; + border-radius: 999px; + background: var(--border); + color: var(--fg); + font-size: 0.75rem; + font-family: var(--mono); + margin-right: 0.25rem; +} + +.badge.ok { + background: rgba(74, 222, 128, 0.15); + color: var(--success); +} + +.badge.warn { + background: rgba(251, 191, 36, 0.15); + color: var(--warn); +} + +.badge.err { + background: rgba(248, 113, 113, 0.15); + color: var(--error); +} + +.diag-panel { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: var(--card); + border-top: 1px solid var(--border); + padding: 0.75rem 1.5rem; + font-family: var(--mono); + font-size: 0.8rem; + z-index: 100; +} + +.diag-panel .row { + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + align-items: center; +} + +.diag-panel .label { + color: var(--muted); + margin-right: 0.35rem; +} + +.diag-panel .value { + color: var(--fg); +} + +form { + display: flex; + gap: 0.5rem; + align-items: center; + margin: 0 0 1rem; +} + +input[type="text"], +input[type="email"] { + background: var(--card); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.4rem 0.6rem; + font-family: var(--sans); + font-size: 0.9rem; +} + +button { + background: var(--accent); + color: #0b0d10; + border: 0; + border-radius: 6px; + padding: 0.4rem 0.9rem; + font-family: var(--sans); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; +} + +button:hover { + opacity: 0.9; +} diff --git a/examples/rsc-poc-postgres/app/layout.tsx b/examples/rsc-poc-postgres/app/layout.tsx new file mode 100644 index 0000000000..8d942d7f70 --- /dev/null +++ b/examples/rsc-poc-postgres/app/layout.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from 'react'; +import './globals.css'; + +export const metadata = { + title: 'RSC Concurrency PoC — Postgres', + description: + 'Next.js 16 App Router PoC for Prisma Next runtime behavior under RSC concurrent rendering.', +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + +
{children}
+ + + ); +} diff --git a/examples/rsc-poc-postgres/app/page.tsx b/examples/rsc-poc-postgres/app/page.tsx new file mode 100644 index 0000000000..3e22c57d07 --- /dev/null +++ b/examples/rsc-poc-postgres/app/page.tsx @@ -0,0 +1,61 @@ +import { DiagPanel } from '../src/components/diag-panel'; +import { getDb } from '../src/lib/db'; + +export const dynamic = 'force-dynamic'; + +/** + * Placeholder home page — proves the harness boots and that the Prisma Next + * runtime singleton, `InstrumentedPool`, and `` hang together. + * + * The five parallel Server Components (H1–H4 scenarios) land in step 3 of + * the project plan; this scaffold is deliberately boring. + */ +export default async function Home() { + const db = getDb(); + const users = await db.orm.User.take(1).all(); + const sample = users[0]; + + return ( + <> +

RSC Concurrency PoC — Postgres

+

+ Scaffold only. The five parallel Server Components arrive in the next PR; see{' '} + projects/rsc-concurrency-safety/plan.md for the breakdown. +

+ +
+
+

Smoke check

+ {sample ? ( +

+ Fetched user {sample.id} ({sample.email}) via the ORM client in a Server + Component. +

+ ) : ( +

+ No users yet — run pnpm db:init and pnpm seed. +

+ )} +
+ +
+

Next up

+
    +
  • 5 parallel Server Components (ORM, SQL DSL, include, aggregate, pgvector)
  • +
  • + /stress/always — reproduces hypothesis H3 +
  • +
  • One Server Action (smoke-level)
  • +
  • k6 scripts: baseline, spike, pool-pressure
  • +
  • + Integration test asserting marker reads == query count in{' '} + always mode +
  • +
+
+
+ + + + ); +} diff --git a/examples/rsc-poc-postgres/biome.jsonc b/examples/rsc-poc-postgres/biome.jsonc new file mode 100644 index 0000000000..5d7b5be3c9 --- /dev/null +++ b/examples/rsc-poc-postgres/biome.jsonc @@ -0,0 +1,6 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", + // Example apps use a standalone biome config rather than extending the root + // config, to keep example-specific settings self-contained. + "extends": "//" +} diff --git a/examples/rsc-poc-postgres/next.config.js b/examples/rsc-poc-postgres/next.config.js new file mode 100644 index 0000000000..209232adf3 --- /dev/null +++ b/examples/rsc-poc-postgres/next.config.js @@ -0,0 +1,9 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + // The Prisma Next postgres runtime depends on `pg`, which is a Node-native + // module that doesn't play nicely with Next.js's bundler. Keeping it external + // ensures it's loaded from node_modules at runtime on the server. + serverExternalPackages: ['pg', '@prisma-next/postgres', '@prisma-next/driver-postgres'], +}; + +export default nextConfig; diff --git a/examples/rsc-poc-postgres/package.json b/examples/rsc-poc-postgres/package.json new file mode 100644 index 0000000000..874fe0ffaf --- /dev/null +++ b/examples/rsc-poc-postgres/package.json @@ -0,0 +1,58 @@ +{ + "name": "rsc-poc-postgres", + "private": true, + "type": "module", + "engines": { + "node": ">=24" + }, + "scripts": { + "emit": "prisma-next contract emit", + "emit:check": "pnpm emit && git diff --exit-code src/prisma/contract.json src/prisma/contract.d.ts", + "db:init": "prisma-next db init", + "db:drop": "tsx scripts/drop-db.ts", + "seed": "tsx scripts/seed.ts", + "dev": "next dev", + "build": "next build", + "start": "next start", + "stress:baseline": "k6 run scripts/stress.k6.js -e SCENARIO=baseline", + "stress:spike": "k6 run scripts/stress.k6.js -e SCENARIO=spike", + "stress:pool-pressure": "k6 run scripts/stress.k6.js -e SCENARIO=pool_pressure", + "test": "vitest run --config vitest.config.ts", + "typecheck": "tsc --project tsconfig.json --noEmit", + "lint": "biome check . --error-on-warnings" + }, + "dependencies": { + "@prisma-next/adapter-postgres": "workspace:*", + "@prisma-next/contract": "workspace:*", + "@prisma-next/driver-postgres": "workspace:*", + "@prisma-next/extension-pgvector": "workspace:*", + "@prisma-next/family-sql": "workspace:*", + "@prisma-next/middleware-telemetry": "workspace:*", + "@prisma-next/postgres": "workspace:*", + "@prisma-next/sql-contract": "workspace:*", + "@prisma-next/sql-contract-psl": "workspace:*", + "@prisma-next/sql-orm-client": "workspace:*", + "@prisma-next/sql-runtime": "workspace:*", + "@prisma-next/target-postgres": "workspace:*", + "arktype": "^2.1.29", + "dotenv": "^16.4.5", + "next": "^16.1.7", + "pg": "catalog:", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@prisma-next/cli": "workspace:*", + "@prisma-next/emitter": "workspace:*", + "@prisma-next/sql-contract-emitter": "workspace:*", + "@prisma-next/test-utils": "workspace:*", + "@prisma-next/tsconfig": "workspace:*", + "@types/node": "catalog:", + "@types/pg": "catalog:", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "tsx": "^4.19.2", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/examples/rsc-poc-postgres/prisma-next.config.ts b/examples/rsc-poc-postgres/prisma-next.config.ts new file mode 100644 index 0000000000..b3a382b92e --- /dev/null +++ b/examples/rsc-poc-postgres/prisma-next.config.ts @@ -0,0 +1,24 @@ +import 'dotenv/config'; +import postgresAdapter from '@prisma-next/adapter-postgres/control'; +import { defineConfig } from '@prisma-next/cli/config-types'; +import postgresDriver from '@prisma-next/driver-postgres/control'; +import pgvector from '@prisma-next/extension-pgvector/control'; +import sql from '@prisma-next/family-sql/control'; +import { prismaContract } from '@prisma-next/sql-contract-psl/provider'; +import postgres from '@prisma-next/target-postgres/control'; + +export default defineConfig({ + family: sql, + target: postgres, + driver: postgresDriver, + adapter: postgresAdapter, + extensionPacks: [pgvector], + contract: prismaContract('./prisma/schema.prisma', { + output: 'src/prisma/contract.json', + target: postgres, + }), + db: { + // biome-ignore lint/style/noNonNullAssertion: loaded from .env + connection: process.env['DATABASE_URL']!, + }, +}); diff --git a/examples/rsc-poc-postgres/prisma/schema.prisma b/examples/rsc-poc-postgres/prisma/schema.prisma new file mode 100644 index 0000000000..3e0fa35743 --- /dev/null +++ b/examples/rsc-poc-postgres/prisma/schema.prisma @@ -0,0 +1,69 @@ +types { + Embedding1536 = pgvector.Vector(1536) +} + +type Address { + street String + city String + zip String? + country String +} + +enum user_type { + admin + user +} + +model User { + id String @id @default(uuid()) + email String + createdAt DateTime @default(now()) + kind user_type + address Address? + posts Post[] + tasks Task[] + + @@map("user") +} + +model Post { + id String @id @default(uuid()) + title String + userId String + createdAt DateTime @default(now()) + embedding Embedding1536? + + user User @relation(fields: [userId], references: [id]) + + @@map("post") +} + +model Task { + id String @id @default(uuid()) + title String + description String? + status String @default("open") + type String + userId String + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id]) + + @@discriminator(type) + @@map("task") +} + +model Bug { + severity String + stepsToRepro String? + @@base(Task, "bug") + @@map("bug") +} + +model Feature { + priority String + targetRelease String? + @@base(Task, "feature") + @@map("feature") +} + diff --git a/examples/rsc-poc-postgres/scripts/drop-db.ts b/examples/rsc-poc-postgres/scripts/drop-db.ts new file mode 100644 index 0000000000..0032cc3884 --- /dev/null +++ b/examples/rsc-poc-postgres/scripts/drop-db.ts @@ -0,0 +1,35 @@ +import 'dotenv/config'; +import pg from 'pg'; + +async function dropDatabase() { + const databaseUrl = process.env['DATABASE_URL']; + if (!databaseUrl) { + console.error('DATABASE_URL environment variable is required'); + process.exit(1); + } + + const client = new pg.Client({ connectionString: databaseUrl }); + + try { + await client.connect(); + console.log('Connected to database'); + + // Drop the public schema and recreate it (removes all tables) + await client.query('DROP SCHEMA IF EXISTS public CASCADE'); + await client.query('CREATE SCHEMA public'); + console.log('✔ Dropped and recreated public schema'); + + // Also drop the prisma_contract schema if it exists + await client.query('DROP SCHEMA IF EXISTS prisma_contract CASCADE'); + console.log('✔ Dropped prisma_contract schema'); + + console.log('\nDatabase reset complete'); + } catch (error) { + console.error('Error:', error); + process.exit(1); + } finally { + await client.end(); + } +} + +dropDatabase(); diff --git a/examples/rsc-poc-postgres/scripts/seed.ts b/examples/rsc-poc-postgres/scripts/seed.ts new file mode 100644 index 0000000000..fcd17e7d78 --- /dev/null +++ b/examples/rsc-poc-postgres/scripts/seed.ts @@ -0,0 +1,129 @@ +/** + * Database Seed Script + * + * Populates the PoC database with sample data using Prisma Next's SQL builder. + * + * Run with: pnpm seed + * + * Creates: + * - 2 users (alice, bob) + * - 3 posts with vector embeddings (for similarity search demos) + * + * Prerequisites: + * - DATABASE_URL environment variable set + * - Database schema and marker applied (run `pnpm emit` then `pnpm db:init`) + */ +import 'dotenv/config'; +import pgvector from '@prisma-next/extension-pgvector/runtime'; +import postgres from '@prisma-next/postgres/runtime'; +import type { Contract } from '../src/prisma/contract.d'; +import contractJson from '../src/prisma/contract.json' with { type: 'json' }; + +async function main() { + const databaseUrl = process.env['DATABASE_URL']; + if (!databaseUrl) { + throw new Error('DATABASE_URL environment variable is required'); + } + + const db = postgres({ contractJson, extensions: [pgvector] }); + const runtime = await db.connect({ url: databaseUrl }); + + try { + await runtime.execute( + db.sql.user + .insert({ + email: 'alice@example.com', + createdAt: new Date(), + kind: 'admin', + address: { street: '123 Main St', city: 'San Francisco', zip: '94102', country: 'US' }, + }) + .build(), + ); + + await runtime.execute( + db.sql.user + .insert({ + email: 'bob@example.com', + createdAt: new Date(), + kind: 'user', + address: { street: '456 Oak Ave', city: 'Portland', zip: null, country: 'US' }, + }) + .build(), + ); + + const aliceRows = await runtime.execute( + db.sql.user + .select('id', 'email') + .where((f, fns) => fns.eq(f.email, 'alice@example.com')) + .limit(1) + .build(), + ); + const alice = aliceRows[0] ?? null; + + const bobRows = await runtime.execute( + db.sql.user + .select('id', 'email') + .where((f, fns) => fns.eq(f.email, 'bob@example.com')) + .limit(1) + .build(), + ); + const bob = bobRows[0] ?? null; + + if (!alice || !bob) { + throw new Error('Failed to create users'); + } + + console.log(`Created user: ${alice.email} (id: ${alice.id})`); + console.log(`Created user: ${bob.email} (id: ${bob.id})`); + + const generateEmbedding = (seed: number): number[] => { + const embedding: number[] = []; + for (let i = 0; i < 1536; i++) { + embedding.push(Math.sin(seed + i) * 0.1); + } + return embedding; + }; + + await runtime.execute( + db.sql.post + .insert({ + title: 'First Post', + userId: alice.id, + embedding: generateEmbedding(1), + createdAt: new Date(), + }) + .build(), + ); + + await runtime.execute( + db.sql.post + .insert({ + title: 'Second Post', + userId: alice.id, + embedding: generateEmbedding(2), + createdAt: new Date(), + }) + .build(), + ); + + await runtime.execute( + db.sql.post + .insert({ + title: 'Third Post', + userId: bob.id, + embedding: generateEmbedding(3), + createdAt: new Date(), + }) + .build(), + ); + + console.log('Seed completed successfully!'); + } finally { + await runtime.close(); + } +} + +main().catch((e) => { + console.error('Error seeding database:', e); + process.exitCode = 1; +}); diff --git a/examples/rsc-poc-postgres/src/components/diag-panel.tsx b/examples/rsc-poc-postgres/src/components/diag-panel.tsx new file mode 100644 index 0000000000..32365ba980 --- /dev/null +++ b/examples/rsc-poc-postgres/src/components/diag-panel.tsx @@ -0,0 +1,65 @@ +import type { VerifyMode } from '../lib/db'; +import { getPool } from '../lib/db'; +import { snapshot } from '../lib/diag'; + +/** + * Dev-only diagnostics panel, rendered at the bottom of a Server Component + * page to surface the counters that back hypotheses H2–H4. + * + * This is a Server Component itself — it reads the in-process diagnostic + * snapshot at render time. Because the snapshot is updated by + * `InstrumentedPool` as the page's other Server Components execute their + * queries in parallel, the values reflect the state **at the moment this + * component is rendered**, which in RSC may be partially or fully after the + * parallel siblings have finished. + * + * The panel does not cause its own query, so it won't perturb the counters + * it's reporting. Numbers are cumulative since process start (not + * per-request); a page reload shows monotonically increasing values. + */ +export interface DiagPanelProps { + readonly verifyMode: VerifyMode; + readonly poolMax?: number; +} + +export function DiagPanel({ verifyMode, poolMax }: DiagPanelProps) { + const snap = snapshot(verifyMode); + const pool = getPool({ verifyMode, ...(poolMax !== undefined ? { poolMax } : {}) }); + + const totalCount = pool?.totalCount ?? 0; + const idleCount = pool?.idleCount ?? 0; + const waitingCount = pool?.waitingCount ?? 0; + const unbalanced = snap.connectionAcquires !== snap.connectionReleases; + + return ( + + ); +} diff --git a/examples/rsc-poc-postgres/src/lib/db.ts b/examples/rsc-poc-postgres/src/lib/db.ts new file mode 100644 index 0000000000..40cfcb2b5c --- /dev/null +++ b/examples/rsc-poc-postgres/src/lib/db.ts @@ -0,0 +1,160 @@ +/** + * Process-scoped Prisma Next runtime singleton for Next.js App Router. + * + * Why `globalThis`? + * + * Next.js dev mode evaluates this module multiple times under HMR. A plain + * module-level `let` would produce a new runtime (and a new pg pool) on every + * edit, exhausting Postgres connection slots within seconds. Pinning the + * instance to `globalThis` survives HMR re-evaluation while still giving us + * one runtime per Node process in production. + * + * This is exactly the configuration the RSC concurrency PoC is designed to + * stress: one runtime, many concurrent Server Components sharing it. + * + * ## Registry shape + * + * We support multiple (verifyMode, poolMax) singletons in the same process + * because `/` and `/stress/always` test different things and must not share + * a runtime. Each unique combination gets its own entry in the registry. + * + * ## Why we construct the pg Pool ourselves + * + * The bundled `@prisma-next/postgres` runtime will build a `pg.Pool` for us + * if we pass `{ url }`, but we need an **instrumented** subclass + * (`InstrumentedPool`) that counts connection acquires, releases, and marker + * reads so the diagnostics panel and the H3 integration test have something + * to observe. `@prisma-next/postgres` accepts a pre-built `pg.Pool` via the + * `{ pg }` option, routing it through `resolvePostgresBinding()`'s + * `instanceof PgPool` check — `InstrumentedPool` subclasses `pg.Pool`, so + * the check passes. + */ + +import pgvector from '@prisma-next/extension-pgvector/runtime'; +import { createTelemetryMiddleware } from '@prisma-next/middleware-telemetry'; +import type { PostgresClient } from '@prisma-next/postgres/runtime'; +import postgres from '@prisma-next/postgres/runtime'; +import type { RuntimeVerifyOptions } from '@prisma-next/sql-runtime'; +import { budgets, lints } from '@prisma-next/sql-runtime'; +import type { Contract } from '../prisma/contract.d'; +import contractJson from '../prisma/contract.json' with { type: 'json' }; +import { InstrumentedPool } from './pool'; + +export type VerifyMode = RuntimeVerifyOptions['mode']; + +interface DbEntry { + readonly client: PostgresClient; + readonly pool: InstrumentedPool; + readonly verifyMode: VerifyMode; + readonly poolMax: number; +} + +type DbRegistry = Map; + +const REGISTRY_KEY = Symbol.for('prisma-next.rsc-poc-postgres.registry'); + +interface GlobalWithRegistry { + [REGISTRY_KEY]?: DbRegistry; +} + +function getRegistry(): DbRegistry { + const g = globalThis as unknown as GlobalWithRegistry; + let registry = g[REGISTRY_KEY]; + if (!registry) { + registry = new Map(); + g[REGISTRY_KEY] = registry; + } + return registry; +} + +export interface DbOptions { + /** + * Contract verification mode. Defaults to `onFirstUse` (matches the bundled + * `@prisma-next/postgres` default). The `/stress/always` route uses + * `'always'` to reproduce hypothesis H3 (skipped-verification under + * concurrency). + */ + readonly verifyMode?: VerifyMode; + /** + * Max pg pool size. The `pool-pressure` stress scenario uses a small value + * (e.g. 5) to characterize hypothesis H4 (pool contention under RSC + * concurrency). Defaults to 10 to match pg's default. + */ + readonly poolMax?: number; +} + +function registryKey(verifyMode: VerifyMode, poolMax: number): string { + return `${verifyMode}|${poolMax}`; +} + +function readDatabaseUrl(): string { + const url = process.env['DATABASE_URL']; + if (!url) { + throw new Error( + 'DATABASE_URL is not set. Copy .env.example to .env and set a Postgres connection string.', + ); + } + return url; +} + +function createEntry(verifyMode: VerifyMode, poolMax: number): DbEntry { + const pool = new InstrumentedPool({ + connectionString: readDatabaseUrl(), + max: poolMax, + connectionTimeoutMillis: 5_000, + idleTimeoutMillis: 30_000, + verifyMode, + }); + + const client = postgres({ + contractJson, + pg: pool, + extensions: [pgvector], + verify: { mode: verifyMode, requireMarker: false }, + middleware: [ + createTelemetryMiddleware(), + lints(), + budgets({ + maxRows: 10_000, + defaultTableRows: 10_000, + tableRows: { user: 10_000, post: 10_000 }, + maxLatencyMs: 5_000, + }), + ], + }); + + return { client, pool, verifyMode, poolMax }; +} + +/** + * Returns a Prisma Next Postgres client pinned to the given `verifyMode` and + * `poolMax`. Each unique (verifyMode, poolMax) combination gets its own + * singleton, so the default `/` page and `/stress/always` route don't share + * a runtime — they're probing different hypotheses. + */ +export function getDb(options: DbOptions = {}): PostgresClient { + const verifyMode: VerifyMode = options.verifyMode ?? 'onFirstUse'; + const poolMax = options.poolMax ?? 10; + const key = registryKey(verifyMode, poolMax); + const registry = getRegistry(); + + let entry = registry.get(key); + if (!entry) { + entry = createEntry(verifyMode, poolMax); + registry.set(key, entry); + } + return entry.client; +} + +/** + * Returns the underlying `InstrumentedPool` for a given (verifyMode, poolMax) + * combination, or `undefined` if no runtime has been instantiated for it yet. + * Used by the diagnostics panel to report pool stats (`pool.totalCount`, + * `pool.idleCount`, `pool.waitingCount`). + */ +export function getPool(options: DbOptions = {}): InstrumentedPool | undefined { + const verifyMode: VerifyMode = options.verifyMode ?? 'onFirstUse'; + const poolMax = options.poolMax ?? 10; + const key = registryKey(verifyMode, poolMax); + return getRegistry().get(key)?.pool; +} diff --git a/examples/rsc-poc-postgres/src/lib/diag.ts b/examples/rsc-poc-postgres/src/lib/diag.ts new file mode 100644 index 0000000000..71df9c8ceb --- /dev/null +++ b/examples/rsc-poc-postgres/src/lib/diag.ts @@ -0,0 +1,116 @@ +/** + * In-process diagnostic counters for the RSC concurrency PoC. + * + * These counters are populated by instrumented wrappers around `pg.Pool` (see + * `pool.ts`) and surfaced in the dev-only `` at page bottom. They + * also back the H3 integration test. + * + * Counters are pinned to `globalThis` for the same reason the db singleton is: + * Next.js HMR re-evaluates this module on every edit, and we need counts to + * survive re-evaluation during a single dev session so the panel doesn't lie + * after a hot reload. + * + * Each counter is keyed by `verifyMode` so `/` (onFirstUse) and + * `/stress/always` (always) report independently — they're testing different + * things. + */ + +import type { VerifyMode } from './db'; + +export interface DiagCounters { + /** Number of times `prisma_contract.marker` has been read since process start. */ + markerReads: number; + /** Number of times a pg pool connection has been acquired since process start. */ + connectionAcquires: number; + /** Number of times a pg pool connection has been released since process start. */ + connectionReleases: number; +} + +export interface DiagSnapshot extends DiagCounters { + readonly verifyMode: VerifyMode; + readonly timestampMs: number; +} + +type DiagRegistry = Map; + +const REGISTRY_KEY = Symbol.for('prisma-next.rsc-poc-postgres.diag'); + +interface GlobalWithDiag { + [REGISTRY_KEY]?: DiagRegistry; +} + +function getRegistry(): DiagRegistry { + const g = globalThis as unknown as GlobalWithDiag; + let registry = g[REGISTRY_KEY]; + if (!registry) { + registry = new Map(); + g[REGISTRY_KEY] = registry; + } + return registry; +} + +function getOrCreate(verifyMode: VerifyMode): DiagCounters { + const registry = getRegistry(); + let counters = registry.get(verifyMode); + if (!counters) { + counters = { + markerReads: 0, + connectionAcquires: 0, + connectionReleases: 0, + }; + registry.set(verifyMode, counters); + } + return counters; +} + +/** + * SQL fragment that identifies a contract marker read. The runtime's + * `verifyPlanIfNeeded()` goes through `driver.query(sql, params)` with a + * stable SQL template from `PostgresAdapterImpl`; we match on the marker + * table reference to detect it regardless of exact whitespace. + */ +const MARKER_SQL_FRAGMENT = 'prisma_contract.marker'; + +export function isMarkerReadSql(sql: string): boolean { + return sql.includes(MARKER_SQL_FRAGMENT); +} + +export function recordMarkerRead(verifyMode: VerifyMode): void { + const counters = getOrCreate(verifyMode); + counters.markerReads += 1; +} + +export function recordConnectionAcquire(verifyMode: VerifyMode): void { + const counters = getOrCreate(verifyMode); + counters.connectionAcquires += 1; +} + +export function recordConnectionRelease(verifyMode: VerifyMode): void { + const counters = getOrCreate(verifyMode); + counters.connectionReleases += 1; +} + +export function snapshot(verifyMode: VerifyMode): DiagSnapshot { + const counters = getOrCreate(verifyMode); + return { + verifyMode, + timestampMs: Date.now(), + markerReads: counters.markerReads, + connectionAcquires: counters.connectionAcquires, + connectionReleases: counters.connectionReleases, + }; +} + +export function snapshotAll(): readonly DiagSnapshot[] { + const registry = getRegistry(); + return [...registry.keys()].map((mode) => snapshot(mode)); +} + +export function reset(verifyMode?: VerifyMode): void { + const registry = getRegistry(); + if (verifyMode === undefined) { + registry.clear(); + return; + } + registry.delete(verifyMode); +} diff --git a/examples/rsc-poc-postgres/src/lib/pool.ts b/examples/rsc-poc-postgres/src/lib/pool.ts new file mode 100644 index 0000000000..a41bd67eef --- /dev/null +++ b/examples/rsc-poc-postgres/src/lib/pool.ts @@ -0,0 +1,120 @@ +/** + * Instrumented `pg.Pool` subclass for the RSC concurrency PoC. + * + * Counts: + * - Connection acquires (`pool.connect()`) — every time the driver borrows a + * client from the pool, whether for `execute()` or for `query()`. + * - Connection releases (`client.release()`) — so we can verify acquires and + * releases balance under load (H4). + * - Marker reads — identified by SQL text containing `prisma_contract.marker`, + * the stable fragment emitted by `PostgresAdapterImpl.readMarkerStatement()`. + * The runtime's `verifyPlanIfNeeded()` issues the marker read via + * `driver.query()`, which in `PostgresPoolDriverImpl` does + * `pool.connect()` → `client.query(sql, params)` → `client.release()`. + * So we detect it at the client level. + * + * We subclass `pg.Pool` (rather than wrapping by composition) because the + * bundled `@prisma-next/postgres` runtime uses `instanceof PgPool` in + * `resolvePostgresBinding()` to route `pg`-input into the `pgPool` branch. A + * composition wrapper would fail that check. + */ +import type { PoolClient, PoolConfig } from 'pg'; +import { Pool as PgPool } from 'pg'; +import type { VerifyMode } from './db'; +import { + isMarkerReadSql, + recordConnectionAcquire, + recordConnectionRelease, + recordMarkerRead, +} from './diag'; + +export interface InstrumentedPoolOptions extends PoolConfig { + readonly verifyMode: VerifyMode; +} + +const INSTRUMENTED_MARKER = Symbol.for('prisma-next.rsc-poc-postgres.client.instrumented'); + +/** + * Extracts the SQL text from the shapes `pg` accepts for `query()`. Returns + * `null` for non-text shapes (e.g. `Cursor` or other `Submittable`) — the + * marker read uses a plain string so we never need to look inside those. + */ +function extractSql(queryTextOrConfig: unknown): string | null { + if (typeof queryTextOrConfig === 'string') { + return queryTextOrConfig; + } + if ( + queryTextOrConfig !== null && + typeof queryTextOrConfig === 'object' && + 'text' in queryTextOrConfig && + typeof (queryTextOrConfig as { text: unknown }).text === 'string' + ) { + return (queryTextOrConfig as { text: string }).text; + } + return null; +} + +/** + * Instruments a `PoolClient` in place so that `query()` records marker reads + * and `release()` bumps the release counter. Idempotent: multiple calls on the + * same client are no-ops after the first. + */ +function instrumentPoolClient(client: PoolClient, verifyMode: VerifyMode): PoolClient { + const flag = client as unknown as Record; + if (flag[INSTRUMENTED_MARKER]) { + return client; + } + flag[INSTRUMENTED_MARKER] = true; + + const originalQuery = client.query.bind(client) as (...a: unknown[]) => unknown; + const originalRelease = client.release.bind(client) as (err?: Error | boolean) => void; + + const patchedQuery = (...args: unknown[]): unknown => { + const sql = extractSql(args[0]); + if (sql !== null && isMarkerReadSql(sql)) { + recordMarkerRead(verifyMode); + } + return originalQuery(...args); + }; + + const patchedRelease = (err?: Error | boolean): void => { + recordConnectionRelease(verifyMode); + originalRelease(err); + }; + + // pg's PoolClient types use overloaded signatures for query()/release() that + // are impractical to reproduce here without losing clarity. Assigning the + // patched functions back keeps the duck-typed driver call sites happy while + // preserving runtime behavior. + const mutable = client as unknown as { + query: typeof patchedQuery; + release: typeof patchedRelease; + }; + mutable.query = patchedQuery; + mutable.release = patchedRelease; + + return client; +} + +/** + * `pg.Pool` subclass with diagnostic counters. Keeps `instanceof PgPool` true + * so it satisfies `resolvePostgresBinding()`'s instance check. + * + * We only override `connect()` — the driver doesn't use `pool.query()` for + * anything we care about (marker reads go through an acquired client, not + * `pool.query`). + */ +export class InstrumentedPool extends PgPool { + readonly #verifyMode: VerifyMode; + + constructor(options: InstrumentedPoolOptions) { + const { verifyMode, ...poolConfig } = options; + super(poolConfig); + this.#verifyMode = verifyMode; + } + + override connect(): Promise { + recordConnectionAcquire(this.#verifyMode); + return super.connect().then((client) => instrumentPoolClient(client, this.#verifyMode)); + } +} diff --git a/examples/rsc-poc-postgres/src/prisma/contract.d.ts b/examples/rsc-poc-postgres/src/prisma/contract.d.ts new file mode 100644 index 0000000000..d24e30d442 --- /dev/null +++ b/examples/rsc-poc-postgres/src/prisma/contract.d.ts @@ -0,0 +1,646 @@ +// ⚠️ GENERATED FILE - DO NOT EDIT +// This file is automatically generated by 'prisma-next contract emit'. +// To regenerate, run: prisma-next contract emit +import type { CodecTypes as PgTypes } from '@prisma-next/adapter-postgres/codec-types'; +import type { JsonValue } from '@prisma-next/adapter-postgres/codec-types'; +import type { Char } from '@prisma-next/adapter-postgres/codec-types'; +import type { Varchar } from '@prisma-next/adapter-postgres/codec-types'; +import type { Numeric } from '@prisma-next/adapter-postgres/codec-types'; +import type { Bit } from '@prisma-next/adapter-postgres/codec-types'; +import type { VarBit } from '@prisma-next/adapter-postgres/codec-types'; +import type { Timestamp } from '@prisma-next/adapter-postgres/codec-types'; +import type { Timestamptz } from '@prisma-next/adapter-postgres/codec-types'; +import type { Time } from '@prisma-next/adapter-postgres/codec-types'; +import type { Timetz } from '@prisma-next/adapter-postgres/codec-types'; +import type { Interval } from '@prisma-next/adapter-postgres/codec-types'; +import type { CodecTypes as PgVectorTypes } from '@prisma-next/extension-pgvector/codec-types'; +import type { Vector } from '@prisma-next/extension-pgvector/codec-types'; +import type { OperationTypes as PgVectorOperationTypes } from '@prisma-next/extension-pgvector/operation-types'; +import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { QueryOperationTypes as PgVectorQueryOperationTypes } from '@prisma-next/extension-pgvector/operation-types'; + +import type { + ContractWithTypeMaps, + TypeMaps as TypeMapsType, +} from '@prisma-next/sql-contract/types'; +import type { + Contract as ContractType, + ExecutionHashBase, + ProfileHashBase, + StorageHashBase, +} from '@prisma-next/contract/types'; + +export type StorageHash = + StorageHashBase<'sha256:76c1bd5f5733774ae1182e83ca882f623cdf12e78a76c2fb06666d60bbdd6452'>; +export type ExecutionHash = + ExecutionHashBase<'sha256:516d134296237bb5f427dfe28f42f79077d0b72cbcae281fdd1ba3c974b9568e'>; +export type ProfileHash = + ProfileHashBase<'sha256:1a8dbe044289f30a1de958fe800cc5a8378b285d2e126a8c44b58864bac2c18e'>; + +export type CodecTypes = PgTypes & PgVectorTypes; +export type OperationTypes = PgVectorOperationTypes; +export type LaneCodecTypes = CodecTypes; +export type QueryOperationTypes = PgAdapterQueryOps & PgVectorQueryOperationTypes; +type DefaultLiteralValue = CodecId extends keyof CodecTypes + ? CodecTypes[CodecId]['output'] + : _Encoded; +export type AddressOutput = { + readonly street: CodecTypes['pg/text@1']['output']; + readonly city: CodecTypes['pg/text@1']['output']; + readonly zip: CodecTypes['pg/text@1']['output'] | null; + readonly country: CodecTypes['pg/text@1']['output']; +}; +export type AddressInput = { + readonly street: CodecTypes['pg/text@1']['input']; + readonly city: CodecTypes['pg/text@1']['input']; + readonly zip: CodecTypes['pg/text@1']['input'] | null; + readonly country: CodecTypes['pg/text@1']['input']; +}; +export type FieldOutputTypes = { + readonly Bug: { + readonly severity: CodecTypes['pg/text@1']['output']; + readonly stepsToRepro: CodecTypes['pg/text@1']['output'] | null; + }; + readonly Feature: { + readonly priority: CodecTypes['pg/text@1']['output']; + readonly targetRelease: CodecTypes['pg/text@1']['output'] | null; + }; + readonly Post: { + readonly id: Char<36>; + readonly title: CodecTypes['pg/text@1']['output']; + readonly userId: CodecTypes['pg/text@1']['output']; + readonly createdAt: CodecTypes['pg/timestamptz@1']['output']; + readonly embedding: CodecTypes['pg/vector@1']['output'] | null; + }; + readonly Task: { + readonly id: Char<36>; + readonly title: CodecTypes['pg/text@1']['output']; + readonly description: CodecTypes['pg/text@1']['output'] | null; + readonly status: CodecTypes['pg/text@1']['output']; + readonly type: CodecTypes['pg/text@1']['output']; + readonly userId: CodecTypes['pg/text@1']['output']; + readonly createdAt: CodecTypes['pg/timestamptz@1']['output']; + }; + readonly User: { + readonly id: Char<36>; + readonly email: CodecTypes['pg/text@1']['output']; + readonly createdAt: CodecTypes['pg/timestamptz@1']['output']; + readonly kind: CodecTypes['pg/enum@1']['output']; + readonly address: AddressOutput | null; + }; +}; +export type FieldInputTypes = { + readonly Bug: { + readonly severity: CodecTypes['pg/text@1']['input']; + readonly stepsToRepro: CodecTypes['pg/text@1']['input'] | null; + }; + readonly Feature: { + readonly priority: CodecTypes['pg/text@1']['input']; + readonly targetRelease: CodecTypes['pg/text@1']['input'] | null; + }; + readonly Post: { + readonly id: CodecTypes['sql/char@1']['input']; + readonly title: CodecTypes['pg/text@1']['input']; + readonly userId: CodecTypes['pg/text@1']['input']; + readonly createdAt: CodecTypes['pg/timestamptz@1']['input']; + readonly embedding: CodecTypes['pg/vector@1']['input'] | null; + }; + readonly Task: { + readonly id: CodecTypes['sql/char@1']['input']; + readonly title: CodecTypes['pg/text@1']['input']; + readonly description: CodecTypes['pg/text@1']['input'] | null; + readonly status: CodecTypes['pg/text@1']['input']; + readonly type: CodecTypes['pg/text@1']['input']; + readonly userId: CodecTypes['pg/text@1']['input']; + readonly createdAt: CodecTypes['pg/timestamptz@1']['input']; + }; + readonly User: { + readonly id: CodecTypes['sql/char@1']['input']; + readonly email: CodecTypes['pg/text@1']['input']; + readonly createdAt: CodecTypes['pg/timestamptz@1']['input']; + readonly kind: CodecTypes['pg/enum@1']['input']; + readonly address: AddressInput | null; + }; +}; +export type TypeMaps = TypeMapsType< + CodecTypes, + OperationTypes, + QueryOperationTypes, + FieldOutputTypes, + FieldInputTypes +>; + +type ContractBase = ContractType< + { + readonly tables: { + readonly bug: { + columns: { + readonly severity: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly stepsToRepro: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: true; + }; + }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + readonly feature: { + columns: { + readonly priority: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly targetRelease: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: true; + }; + }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + readonly post: { + columns: { + readonly id: { + readonly nativeType: 'character'; + readonly codecId: 'sql/char@1'; + readonly nullable: false; + readonly typeParams: { readonly length: 36 }; + }; + readonly title: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly userId: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly createdAt: { + readonly nativeType: 'timestamptz'; + readonly codecId: 'pg/timestamptz@1'; + readonly nullable: false; + readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + }; + readonly embedding: { + readonly nativeType: 'vector'; + readonly codecId: 'pg/vector@1'; + readonly nullable: true; + readonly typeRef: 'Embedding1536'; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly [ + { + readonly columns: readonly ['userId']; + readonly references: { readonly table: 'user'; readonly columns: readonly ['id'] }; + readonly constraint: true; + readonly index: true; + }, + ]; + }; + readonly task: { + columns: { + readonly id: { + readonly nativeType: 'character'; + readonly codecId: 'sql/char@1'; + readonly nullable: false; + readonly typeParams: { readonly length: 36 }; + }; + readonly title: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly description: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: true; + }; + readonly status: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + readonly default: { + readonly kind: 'literal'; + readonly value: DefaultLiteralValue<'pg/text@1', 'open'>; + }; + }; + readonly type: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly userId: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly createdAt: { + readonly nativeType: 'timestamptz'; + readonly codecId: 'pg/timestamptz@1'; + readonly nullable: false; + readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly [ + { + readonly columns: readonly ['userId']; + readonly references: { readonly table: 'user'; readonly columns: readonly ['id'] }; + readonly constraint: true; + readonly index: true; + }, + ]; + }; + readonly user: { + columns: { + readonly id: { + readonly nativeType: 'character'; + readonly codecId: 'sql/char@1'; + readonly nullable: false; + readonly typeParams: { readonly length: 36 }; + }; + readonly email: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly createdAt: { + readonly nativeType: 'timestamptz'; + readonly codecId: 'pg/timestamptz@1'; + readonly nullable: false; + readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + }; + readonly kind: { + readonly nativeType: 'user_type'; + readonly codecId: 'pg/enum@1'; + readonly nullable: false; + readonly typeRef: 'user_type'; + }; + readonly address: { + readonly nativeType: 'jsonb'; + readonly codecId: 'pg/jsonb@1'; + readonly nullable: true; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + }; + readonly types: { + readonly user_type: { + readonly codecId: 'pg/enum@1'; + readonly nativeType: 'user_type'; + readonly typeParams: { readonly values: readonly ['admin', 'user'] }; + }; + readonly Embedding1536: { + readonly codecId: 'pg/vector@1'; + readonly nativeType: 'vector'; + readonly typeParams: { readonly length: 1536 }; + }; + }; + readonly storageHash: StorageHash; + }, + { + readonly Bug: { + readonly fields: { + readonly severity: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly stepsToRepro: { + readonly nullable: true; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'bug'; + readonly fields: { + readonly severity: { readonly column: 'severity' }; + readonly stepsToRepro: { readonly column: 'stepsToRepro' }; + }; + }; + readonly base: 'Task'; + }; + readonly Feature: { + readonly fields: { + readonly priority: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly targetRelease: { + readonly nullable: true; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'feature'; + readonly fields: { + readonly priority: { readonly column: 'priority' }; + readonly targetRelease: { readonly column: 'targetRelease' }; + }; + }; + readonly base: 'Task'; + }; + readonly Post: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'sql/char@1'; + readonly typeParams: { readonly length: 36 }; + }; + }; + readonly title: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly userId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly createdAt: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/timestamptz@1' }; + }; + readonly embedding: { + readonly nullable: true; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/vector@1' }; + }; + }; + readonly relations: { + readonly user: { + readonly to: 'User'; + readonly cardinality: 'N:1'; + readonly on: { + readonly localFields: readonly ['userId']; + readonly targetFields: readonly ['id']; + }; + }; + }; + readonly storage: { + readonly table: 'post'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly title: { readonly column: 'title' }; + readonly userId: { readonly column: 'userId' }; + readonly createdAt: { readonly column: 'createdAt' }; + readonly embedding: { readonly column: 'embedding' }; + }; + }; + }; + readonly Task: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'sql/char@1'; + readonly typeParams: { readonly length: 36 }; + }; + }; + readonly title: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly description: { + readonly nullable: true; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly status: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly type: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly userId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly createdAt: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/timestamptz@1' }; + }; + }; + readonly relations: { + readonly user: { + readonly to: 'User'; + readonly cardinality: 'N:1'; + readonly on: { + readonly localFields: readonly ['userId']; + readonly targetFields: readonly ['id']; + }; + }; + }; + readonly storage: { + readonly table: 'task'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly title: { readonly column: 'title' }; + readonly description: { readonly column: 'description' }; + readonly status: { readonly column: 'status' }; + readonly type: { readonly column: 'type' }; + readonly userId: { readonly column: 'userId' }; + readonly createdAt: { readonly column: 'createdAt' }; + }; + }; + readonly discriminator: { readonly field: 'type' }; + readonly variants: { + readonly Bug: { readonly value: 'bug' }; + readonly Feature: { readonly value: 'feature' }; + }; + }; + readonly User: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'sql/char@1'; + readonly typeParams: { readonly length: 36 }; + }; + }; + readonly email: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly createdAt: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/timestamptz@1' }; + }; + readonly kind: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/enum@1' }; + }; + readonly address: { + readonly nullable: true; + readonly type: { readonly kind: 'valueObject'; readonly name: 'Address' }; + }; + }; + readonly relations: { + readonly posts: { + readonly to: 'Post'; + readonly cardinality: '1:N'; + readonly on: { + readonly localFields: readonly ['id']; + readonly targetFields: readonly ['userId']; + }; + }; + readonly tasks: { + readonly to: 'Task'; + readonly cardinality: '1:N'; + readonly on: { + readonly localFields: readonly ['id']; + readonly targetFields: readonly ['userId']; + }; + }; + }; + readonly storage: { + readonly table: 'user'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly email: { readonly column: 'email' }; + readonly createdAt: { readonly column: 'createdAt' }; + readonly kind: { readonly column: 'kind' }; + readonly address: { readonly column: 'address' }; + }; + }; + }; + } +> & { + readonly target: 'postgres'; + readonly targetFamily: 'sql'; + readonly roots: { readonly user: 'User'; readonly post: 'Post'; readonly task: 'Task' }; + readonly capabilities: { + readonly postgres: { + readonly jsonAgg: true; + readonly lateral: true; + readonly limit: true; + readonly orderBy: true; + readonly 'pgvector.cosine': true; + readonly returning: true; + }; + readonly sql: { + readonly defaultInInsert: true; + readonly enums: true; + readonly returning: true; + }; + }; + readonly extensionPacks: { + readonly pgvector: { + readonly capabilities: { readonly postgres: { readonly 'pgvector.cosine': true } }; + readonly familyId: 'sql'; + readonly id: 'pgvector'; + readonly kind: 'extension'; + readonly targetId: 'postgres'; + readonly types: { + readonly codecTypes: { + readonly import: { + readonly alias: 'PgVectorTypes'; + readonly named: 'CodecTypes'; + readonly package: '@prisma-next/extension-pgvector/codec-types'; + }; + readonly typeImports: readonly [ + { + readonly alias: 'Vector'; + readonly named: 'Vector'; + readonly package: '@prisma-next/extension-pgvector/codec-types'; + }, + ]; + }; + readonly operationTypes: { + readonly import: { + readonly alias: 'PgVectorOperationTypes'; + readonly named: 'OperationTypes'; + readonly package: '@prisma-next/extension-pgvector/operation-types'; + }; + }; + readonly queryOperationTypes: { + readonly import: { + readonly alias: 'PgVectorQueryOperationTypes'; + readonly named: 'QueryOperationTypes'; + readonly package: '@prisma-next/extension-pgvector/operation-types'; + }; + }; + readonly storage: readonly [ + { + readonly familyId: 'sql'; + readonly nativeType: 'vector'; + readonly targetId: 'postgres'; + readonly typeId: 'pg/vector@1'; + }, + ]; + }; + readonly version: '0.0.1'; + }; + }; + readonly execution: { + readonly executionHash: ExecutionHash; + readonly mutations: { + readonly defaults: readonly [ + { + readonly ref: { readonly table: 'post'; readonly column: 'id' }; + readonly onCreate: { readonly kind: 'generator'; readonly id: 'uuidv4' }; + }, + { + readonly ref: { readonly table: 'task'; readonly column: 'id' }; + readonly onCreate: { readonly kind: 'generator'; readonly id: 'uuidv4' }; + }, + { + readonly ref: { readonly table: 'user'; readonly column: 'id' }; + readonly onCreate: { readonly kind: 'generator'; readonly id: 'uuidv4' }; + }, + ]; + }; + }; + readonly meta: {}; + readonly valueObjects: { + readonly Address: { + readonly fields: { + readonly street: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly city: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly zip: { + readonly nullable: true; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly country: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + }; + }; + }; + readonly profileHash: ProfileHash; +}; + +export type Contract = ContractWithTypeMaps; + +export type Tables = Contract['storage']['tables']; +export type Models = Contract['models']; diff --git a/examples/rsc-poc-postgres/src/prisma/contract.json b/examples/rsc-poc-postgres/src/prisma/contract.json new file mode 100644 index 0000000000..81222ff166 --- /dev/null +++ b/examples/rsc-poc-postgres/src/prisma/contract.json @@ -0,0 +1,724 @@ +{ + "schemaVersion": "1", + "targetFamily": "sql", + "target": "postgres", + "profileHash": "sha256:1a8dbe044289f30a1de958fe800cc5a8378b285d2e126a8c44b58864bac2c18e", + "roots": { + "post": "Post", + "task": "Task", + "user": "User" + }, + "models": { + "Bug": { + "base": "Task", + "fields": { + "severity": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "stepsToRepro": { + "nullable": true, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "severity": { + "column": "severity" + }, + "stepsToRepro": { + "column": "stepsToRepro" + } + }, + "table": "bug" + } + }, + "Feature": { + "base": "Task", + "fields": { + "priority": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "targetRelease": { + "nullable": true, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "priority": { + "column": "priority" + }, + "targetRelease": { + "column": "targetRelease" + } + }, + "table": "feature" + } + }, + "Post": { + "fields": { + "createdAt": { + "nullable": false, + "type": { + "codecId": "pg/timestamptz@1", + "kind": "scalar" + } + }, + "embedding": { + "nullable": true, + "type": { + "codecId": "pg/vector@1", + "kind": "scalar" + } + }, + "id": { + "nullable": false, + "type": { + "codecId": "sql/char@1", + "kind": "scalar", + "typeParams": { + "length": 36 + } + } + }, + "title": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "userId": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + }, + "relations": { + "user": { + "cardinality": "N:1", + "on": { + "localFields": [ + "userId" + ], + "targetFields": [ + "id" + ] + }, + "to": "User" + } + }, + "storage": { + "fields": { + "createdAt": { + "column": "createdAt" + }, + "embedding": { + "column": "embedding" + }, + "id": { + "column": "id" + }, + "title": { + "column": "title" + }, + "userId": { + "column": "userId" + } + }, + "table": "post" + } + }, + "Task": { + "discriminator": { + "field": "type" + }, + "fields": { + "createdAt": { + "nullable": false, + "type": { + "codecId": "pg/timestamptz@1", + "kind": "scalar" + } + }, + "description": { + "nullable": true, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "id": { + "nullable": false, + "type": { + "codecId": "sql/char@1", + "kind": "scalar", + "typeParams": { + "length": 36 + } + } + }, + "status": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "title": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "type": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "userId": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + }, + "relations": { + "user": { + "cardinality": "N:1", + "on": { + "localFields": [ + "userId" + ], + "targetFields": [ + "id" + ] + }, + "to": "User" + } + }, + "storage": { + "fields": { + "createdAt": { + "column": "createdAt" + }, + "description": { + "column": "description" + }, + "id": { + "column": "id" + }, + "status": { + "column": "status" + }, + "title": { + "column": "title" + }, + "type": { + "column": "type" + }, + "userId": { + "column": "userId" + } + }, + "table": "task" + }, + "variants": { + "Bug": { + "value": "bug" + }, + "Feature": { + "value": "feature" + } + } + }, + "User": { + "fields": { + "address": { + "nullable": true, + "type": { + "kind": "valueObject", + "name": "Address" + } + }, + "createdAt": { + "nullable": false, + "type": { + "codecId": "pg/timestamptz@1", + "kind": "scalar" + } + }, + "email": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "id": { + "nullable": false, + "type": { + "codecId": "sql/char@1", + "kind": "scalar", + "typeParams": { + "length": 36 + } + } + }, + "kind": { + "nullable": false, + "type": { + "codecId": "pg/enum@1", + "kind": "scalar" + } + } + }, + "relations": { + "posts": { + "cardinality": "1:N", + "on": { + "localFields": [ + "id" + ], + "targetFields": [ + "userId" + ] + }, + "to": "Post" + }, + "tasks": { + "cardinality": "1:N", + "on": { + "localFields": [ + "id" + ], + "targetFields": [ + "userId" + ] + }, + "to": "Task" + } + }, + "storage": { + "fields": { + "address": { + "column": "address" + }, + "createdAt": { + "column": "createdAt" + }, + "email": { + "column": "email" + }, + "id": { + "column": "id" + }, + "kind": { + "column": "kind" + } + }, + "table": "user" + } + } + }, + "valueObjects": { + "Address": { + "fields": { + "city": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "country": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "street": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "zip": { + "nullable": true, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + } + } + }, + "storage": { + "storageHash": "sha256:76c1bd5f5733774ae1182e83ca882f623cdf12e78a76c2fb06666d60bbdd6452", + "tables": { + "bug": { + "columns": { + "severity": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "stepsToRepro": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": true + } + }, + "foreignKeys": [], + "indexes": [], + "uniques": [] + }, + "feature": { + "columns": { + "priority": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "targetRelease": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": true + } + }, + "foreignKeys": [], + "indexes": [], + "uniques": [] + }, + "post": { + "columns": { + "createdAt": { + "codecId": "pg/timestamptz@1", + "default": { + "expression": "now()", + "kind": "function" + }, + "nativeType": "timestamptz", + "nullable": false + }, + "embedding": { + "codecId": "pg/vector@1", + "nativeType": "vector", + "nullable": true, + "typeRef": "Embedding1536" + }, + "id": { + "codecId": "sql/char@1", + "nativeType": "character", + "nullable": false, + "typeParams": { + "length": 36 + } + }, + "title": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "userId": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + } + }, + "foreignKeys": [ + { + "columns": [ + "userId" + ], + "constraint": true, + "index": true, + "references": { + "columns": [ + "id" + ], + "table": "user" + } + } + ], + "indexes": [], + "primaryKey": { + "columns": [ + "id" + ] + }, + "uniques": [] + }, + "task": { + "columns": { + "createdAt": { + "codecId": "pg/timestamptz@1", + "default": { + "expression": "now()", + "kind": "function" + }, + "nativeType": "timestamptz", + "nullable": false + }, + "description": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": true + }, + "id": { + "codecId": "sql/char@1", + "nativeType": "character", + "nullable": false, + "typeParams": { + "length": 36 + } + }, + "status": { + "codecId": "pg/text@1", + "default": { + "kind": "literal", + "value": "open" + }, + "nativeType": "text", + "nullable": false + }, + "title": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "type": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "userId": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + } + }, + "foreignKeys": [ + { + "columns": [ + "userId" + ], + "constraint": true, + "index": true, + "references": { + "columns": [ + "id" + ], + "table": "user" + } + } + ], + "indexes": [], + "primaryKey": { + "columns": [ + "id" + ] + }, + "uniques": [] + }, + "user": { + "columns": { + "address": { + "codecId": "pg/jsonb@1", + "nativeType": "jsonb", + "nullable": true + }, + "createdAt": { + "codecId": "pg/timestamptz@1", + "default": { + "expression": "now()", + "kind": "function" + }, + "nativeType": "timestamptz", + "nullable": false + }, + "email": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "id": { + "codecId": "sql/char@1", + "nativeType": "character", + "nullable": false, + "typeParams": { + "length": 36 + } + }, + "kind": { + "codecId": "pg/enum@1", + "nativeType": "user_type", + "nullable": false, + "typeRef": "user_type" + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": [ + "id" + ] + }, + "uniques": [] + } + }, + "types": { + "Embedding1536": { + "codecId": "pg/vector@1", + "nativeType": "vector", + "typeParams": { + "length": 1536 + } + }, + "user_type": { + "codecId": "pg/enum@1", + "nativeType": "user_type", + "typeParams": { + "values": [ + "admin", + "user" + ] + } + } + } + }, + "execution": { + "executionHash": "sha256:516d134296237bb5f427dfe28f42f79077d0b72cbcae281fdd1ba3c974b9568e", + "mutations": { + "defaults": [ + { + "onCreate": { + "id": "uuidv4", + "kind": "generator" + }, + "ref": { + "column": "id", + "table": "post" + } + }, + { + "onCreate": { + "id": "uuidv4", + "kind": "generator" + }, + "ref": { + "column": "id", + "table": "task" + } + }, + { + "onCreate": { + "id": "uuidv4", + "kind": "generator" + }, + "ref": { + "column": "id", + "table": "user" + } + } + ] + } + }, + "capabilities": { + "postgres": { + "jsonAgg": true, + "lateral": true, + "limit": true, + "orderBy": true, + "pgvector.cosine": true, + "returning": true + }, + "sql": { + "defaultInInsert": true, + "enums": true, + "returning": true + } + }, + "extensionPacks": { + "pgvector": { + "capabilities": { + "postgres": { + "pgvector.cosine": true + } + }, + "familyId": "sql", + "id": "pgvector", + "kind": "extension", + "targetId": "postgres", + "types": { + "codecTypes": { + "import": { + "alias": "PgVectorTypes", + "named": "CodecTypes", + "package": "@prisma-next/extension-pgvector/codec-types" + }, + "typeImports": [ + { + "alias": "Vector", + "named": "Vector", + "package": "@prisma-next/extension-pgvector/codec-types" + } + ] + }, + "operationTypes": { + "import": { + "alias": "PgVectorOperationTypes", + "named": "OperationTypes", + "package": "@prisma-next/extension-pgvector/operation-types" + } + }, + "queryOperationTypes": { + "import": { + "alias": "PgVectorQueryOperationTypes", + "named": "QueryOperationTypes", + "package": "@prisma-next/extension-pgvector/operation-types" + } + }, + "storage": [ + { + "familyId": "sql", + "nativeType": "vector", + "targetId": "postgres", + "typeId": "pg/vector@1" + } + ] + }, + "version": "0.0.1" + } + }, + "meta": {}, + "_generated": { + "warning": "⚠️ GENERATED FILE - DO NOT EDIT", + "message": "This file is automatically generated by \"prisma-next contract emit\".", + "regenerate": "To regenerate, run: prisma-next contract emit" + } +} \ No newline at end of file diff --git a/examples/rsc-poc-postgres/tsconfig.json b/examples/rsc-poc-postgres/tsconfig.json new file mode 100644 index 0000000000..401a7df76a --- /dev/null +++ b/examples/rsc-poc-postgres/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": ["@prisma-next/tsconfig/base"], + "compilerOptions": { + "outDir": "dist", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "preserve", + "allowJs": true, + "noEmit": true, + "incremental": true, + "isolatedModules": true, + "resolveJsonModule": true, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "app/**/*.ts", + "app/**/*.tsx", + "src/**/*.ts", + "src/**/*.tsx", + "test/**/*.ts", + "scripts/**/*.ts", + "next-env.d.ts", + ".next/types/**/*.ts" + ], + "exclude": ["dist", ".next", "node_modules"] +} diff --git a/examples/rsc-poc-postgres/turbo.json b/examples/rsc-poc-postgres/turbo.json new file mode 100644 index 0000000000..f2aa1b4767 --- /dev/null +++ b/examples/rsc-poc-postgres/turbo.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "inputs": [ + "src/**", + "app/**", + "public/**", + "next.config.js", + "package.json", + "tsconfig.json" + ], + "outputs": [".next/**", "!.next/cache/**"] + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e036f05a4..4694769746 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -440,6 +440,100 @@ importers: specifier: 'catalog:' version: 4.0.17(@types/node@24.10.4)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.1) + examples/rsc-poc-postgres: + dependencies: + '@prisma-next/adapter-postgres': + specifier: workspace:* + version: link:../../packages/3-targets/6-adapters/postgres + '@prisma-next/contract': + specifier: workspace:* + version: link:../../packages/1-framework/0-foundation/contract + '@prisma-next/driver-postgres': + specifier: workspace:* + version: link:../../packages/3-targets/7-drivers/postgres + '@prisma-next/extension-pgvector': + specifier: workspace:* + version: link:../../packages/3-extensions/pgvector + '@prisma-next/family-sql': + specifier: workspace:* + version: link:../../packages/2-sql/9-family + '@prisma-next/middleware-telemetry': + specifier: workspace:* + version: link:../../packages/3-extensions/middleware-telemetry + '@prisma-next/postgres': + specifier: workspace:* + version: link:../../packages/3-extensions/postgres + '@prisma-next/sql-contract': + specifier: workspace:* + version: link:../../packages/2-sql/1-core/contract + '@prisma-next/sql-contract-psl': + specifier: workspace:* + version: link:../../packages/2-sql/2-authoring/contract-psl + '@prisma-next/sql-orm-client': + specifier: workspace:* + version: link:../../packages/3-extensions/sql-orm-client + '@prisma-next/sql-runtime': + specifier: workspace:* + version: link:../../packages/2-sql/5-runtime + '@prisma-next/target-postgres': + specifier: workspace:* + version: link:../../packages/3-targets/3-targets/postgres + arktype: + specifier: ^2.1.29 + version: 2.1.29 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + next: + specifier: ^16.1.7 + version: 16.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + pg: + specifier: 'catalog:' + version: 8.16.3 + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + devDependencies: + '@prisma-next/cli': + specifier: workspace:* + version: link:../../packages/1-framework/3-tooling/cli + '@prisma-next/emitter': + specifier: workspace:* + version: link:../../packages/1-framework/3-tooling/emitter + '@prisma-next/sql-contract-emitter': + specifier: workspace:* + version: link:../../packages/2-sql/3-tooling/emitter + '@prisma-next/test-utils': + specifier: workspace:* + version: link:../../test/utils + '@prisma-next/tsconfig': + specifier: workspace:* + version: link:../../packages/0-config/tsconfig + '@types/node': + specifier: 'catalog:' + version: 24.10.4 + '@types/pg': + specifier: 'catalog:' + version: 8.16.0 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + tsx: + specifier: ^4.19.2 + version: 4.20.6 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.0.17(@types/node@24.10.4)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.1) + packages/0-config/tsconfig: {} packages/0-config/tsdown: @@ -4127,18 +4221,33 @@ packages: '@next/env@15.5.15': resolution: {integrity: sha512-vcmyu5/MyFzN7CdqRHO3uHO44p/QPCZkuTUXroeUmhNP8bL5PHFEhik22JUazt+CDDoD6EpBYRCaS2pISL+/hg==} + '@next/env@16.2.4': + resolution: {integrity: sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==} + '@next/swc-darwin-arm64@15.5.15': resolution: {integrity: sha512-6PvFO2Tzt10GFK2Ro9tAVEtacMqRmTarYMFKAnV2vYMdwWc73xzmDQyAV7SwEdMhzmiRoo7+m88DuiXlJlGeaw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] + '@next/swc-darwin-arm64@16.2.4': + resolution: {integrity: sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + '@next/swc-darwin-x64@15.5.15': resolution: {integrity: sha512-G+YNV+z6FDZTp/+IdGyIMFqalBTaQSnvAA+X/hrt+eaTRFSznRMz9K7rTmzvM6tDmKegNtyzgufZW0HwVzEqaQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] + '@next/swc-darwin-x64@16.2.4': + resolution: {integrity: sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + '@next/swc-linux-arm64-gnu@15.5.15': resolution: {integrity: sha512-eVkrMcVIBqGfXB+QUC7jjZ94Z6uX/dNStbQFabewAnk13Uy18Igd1YZ/GtPRzdhtm7QwC0e6o7zOQecul4iC1w==} engines: {node: '>= 10'} @@ -4146,6 +4255,13 @@ packages: os: [linux] libc: [glibc] + '@next/swc-linux-arm64-gnu@16.2.4': + resolution: {integrity: sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@next/swc-linux-arm64-musl@15.5.15': resolution: {integrity: sha512-RwSHKMQ7InLy5GfkY2/n5PcFycKA08qI1VST78n09nN36nUPqCvGSMiLXlfUmzmpQpF6XeBYP2KRWHi0UW3uNg==} engines: {node: '>= 10'} @@ -4153,6 +4269,13 @@ packages: os: [linux] libc: [musl] + '@next/swc-linux-arm64-musl@16.2.4': + resolution: {integrity: sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@next/swc-linux-x64-gnu@15.5.15': resolution: {integrity: sha512-nplqvY86LakS+eeiuWsNWvfmK8pFcOEW7ZtVRt4QH70lL+0x6LG/m1OpJ/tvrbwjmR8HH9/fH2jzW1GlL03TIg==} engines: {node: '>= 10'} @@ -4160,6 +4283,13 @@ packages: os: [linux] libc: [glibc] + '@next/swc-linux-x64-gnu@16.2.4': + resolution: {integrity: sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@next/swc-linux-x64-musl@15.5.15': resolution: {integrity: sha512-eAgl9NKQ84/sww0v81DQINl/vL2IBxD7sMybd0cWRw6wqgouVI53brVRBrggqBRP/NWeIAE1dm5cbKYoiMlqDQ==} engines: {node: '>= 10'} @@ -4167,18 +4297,37 @@ packages: os: [linux] libc: [musl] + '@next/swc-linux-x64-musl@16.2.4': + resolution: {integrity: sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + '@next/swc-win32-arm64-msvc@15.5.15': resolution: {integrity: sha512-GJVZC86lzSquh0MtvZT+L7G8+jMnJcldloOjA8Kf3wXvBrvb6OGe2MzPuALxFshSm/IpwUtD2mIoof39ymf52A==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] + '@next/swc-win32-arm64-msvc@16.2.4': + resolution: {integrity: sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + '@next/swc-win32-x64-msvc@15.5.15': resolution: {integrity: sha512-nFucjVdwlFqxh/JG3hWSJ4p8+YJV7Ii8aPDuBQULB6DzUF4UNZETXLfEUk+oI2zEznWWULPt7MeuTE6xtK1HSA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] + '@next/swc-win32-x64-msvc@16.2.4': + resolution: {integrity: sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@noble/hashes@2.0.1': resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} @@ -5310,6 +5459,11 @@ packages: bare-url@2.4.0: resolution: {integrity: sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==} + baseline-browser-mapping@2.10.20: + resolution: {integrity: sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==} + engines: {node: '>=6.0.0'} + hasBin: true + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} @@ -6121,6 +6275,27 @@ packages: sass: optional: true + next@16.2.4: + resolution: {integrity: sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} @@ -7455,30 +7630,56 @@ snapshots: '@next/env@15.5.15': {} + '@next/env@16.2.4': {} + '@next/swc-darwin-arm64@15.5.15': optional: true + '@next/swc-darwin-arm64@16.2.4': + optional: true + '@next/swc-darwin-x64@15.5.15': optional: true + '@next/swc-darwin-x64@16.2.4': + optional: true + '@next/swc-linux-arm64-gnu@15.5.15': optional: true + '@next/swc-linux-arm64-gnu@16.2.4': + optional: true + '@next/swc-linux-arm64-musl@15.5.15': optional: true + '@next/swc-linux-arm64-musl@16.2.4': + optional: true + '@next/swc-linux-x64-gnu@15.5.15': optional: true + '@next/swc-linux-x64-gnu@16.2.4': + optional: true + '@next/swc-linux-x64-musl@15.5.15': optional: true + '@next/swc-linux-x64-musl@16.2.4': + optional: true + '@next/swc-win32-arm64-msvc@15.5.15': optional: true + '@next/swc-win32-arm64-msvc@16.2.4': + optional: true + '@next/swc-win32-x64-msvc@15.5.15': optional: true + '@next/swc-win32-x64-msvc@16.2.4': + optional: true + '@noble/hashes@2.0.1': {} '@oxc-project/types@0.103.0': {} @@ -8423,6 +8624,8 @@ snapshots: dependencies: bare-path: 3.0.0 + baseline-browser-mapping@2.10.20: {} + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 @@ -9241,6 +9444,30 @@ snapshots: - '@babel/core' - babel-plugin-macros + next@16.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@next/env': 16.2.4 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.20 + caniuse-lite: 1.0.30001787 + postcss: 8.4.31 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + styled-jsx: 5.1.6(react@19.2.4) + optionalDependencies: + '@next/swc-darwin-arm64': 16.2.4 + '@next/swc-darwin-x64': 16.2.4 + '@next/swc-linux-arm64-gnu': 16.2.4 + '@next/swc-linux-arm64-musl': 16.2.4 + '@next/swc-linux-x64-gnu': 16.2.4 + '@next/swc-linux-x64-musl': 16.2.4 + '@next/swc-win32-arm64-msvc': 16.2.4 + '@next/swc-win32-x64-msvc': 16.2.4 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + node-fetch-native@1.6.7: {} nypm@0.6.2: From 503f3624ec228975a0ef6c32bddd967f20398cc2 Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Wed, 22 Apr 2026 14:04:12 +0200 Subject: [PATCH 3/8] examples(rsc-poc-postgres): 5 parallel Server Components + action + /diag Adds the core observation surface: five parallel async Server Components rendering on /, each wrapped in its own Suspense boundary so React kicks them off concurrently. Plus one smoke-level Server Action and a machine-readable /diag endpoint for tests and k6. Components (one per ORM / DSL path worth probing): - TopUsers: ORM orderBy().take().all() - baseline - PostsWithAuthors: ORM include() - multi-query dispatch - RecentPostsRaw: SQL DSL + runtime.execute() - goes straight through verifyPlanIfNeeded() without acquireRuntimeScope() - UserKindBreakdown: ORM groupBy().having().aggregate() - SimilarPostsSample: pgvector similarity via ORM CreatePostForm (client) + createPostAction (server action) - smoke-level mutation. revalidatePath('/') refreshes the reads after insert. /diag JSON route - reads snapshotAll() from the diag registry. Read after any page render completes so it's always current, unlike which races sibling Suspense boundaries. Two notable corrections from live testing: 1. Dropped lints()/budgets() from the middleware chain. Both flag ordinary queries the PoC issues (unbounded aggregates on small seed data) as errors, which distracts from what we're measuring. 2. Switched connection-release counting from wrapping client.release to listening on the pool's 'release' event. pg-pool reassigns client.release inside _acquireClient on *every* checkout (see _releaseOnce at pg-pool:306), so any wrapper gets clobbered on the second acquire of a pooled client - producing a classic 'acquires keep growing, releases stuck' anomaly. The 'release' event is emitted unconditionally and is the supported observation point. Manual end-to-end run confirms H2: cold-start page load shows 5 marker reads for 5 parallel components, then stable. Acquires/releases balance under 10 parallel requests, no errors. Refs: TML-2164, projects/rsc-concurrency-safety/plan.md --- examples/rsc-poc-postgres/README.md | 55 +++++++- examples/rsc-poc-postgres/app/actions.ts | 82 ++++++++++++ examples/rsc-poc-postgres/app/diag/route.ts | 67 ++++++++++ examples/rsc-poc-postgres/app/page.tsx | 122 ++++++++++++------ .../src/components/create-post-form.tsx | 57 ++++++++ .../src/components/diag-panel.tsx | 35 +++-- examples/rsc-poc-postgres/src/lib/db.ts | 17 +-- examples/rsc-poc-postgres/src/lib/pool.ts | 63 +++++---- .../server-components/posts-with-authors.tsx | 55 ++++++++ .../server-components/recent-posts-raw.tsx | 58 +++++++++ .../similar-posts-sample.tsx | 89 +++++++++++++ .../src/server-components/top-users.tsx | 49 +++++++ .../server-components/user-kind-breakdown.tsx | 54 ++++++++ 13 files changed, 710 insertions(+), 93 deletions(-) create mode 100644 examples/rsc-poc-postgres/app/actions.ts create mode 100644 examples/rsc-poc-postgres/app/diag/route.ts create mode 100644 examples/rsc-poc-postgres/src/components/create-post-form.tsx create mode 100644 examples/rsc-poc-postgres/src/server-components/posts-with-authors.tsx create mode 100644 examples/rsc-poc-postgres/src/server-components/recent-posts-raw.tsx create mode 100644 examples/rsc-poc-postgres/src/server-components/similar-posts-sample.tsx create mode 100644 examples/rsc-poc-postgres/src/server-components/top-users.tsx create mode 100644 examples/rsc-poc-postgres/src/server-components/user-kind-breakdown.tsx diff --git a/examples/rsc-poc-postgres/README.md b/examples/rsc-poc-postgres/README.md index 7f90064b48..07a8445c6d 100644 --- a/examples/rsc-poc-postgres/README.md +++ b/examples/rsc-poc-postgres/README.md @@ -34,9 +34,10 @@ have none of these hazards by construction, so comparing the two ## Status -**Scaffold only.** The five parallel Server Components, `/stress/always` -route, Server Action, k6 scripts, and integration test land in subsequent -PRs. See the project plan for the work breakdown. +Five parallel Server Components and one Server Action are implemented and +verified end-to-end against a local Postgres. The `/stress/always` route, +k6 scripts, and the H3 integration test land in subsequent PRs. See the +project plan for the work breakdown. ## Prerequisites @@ -74,8 +75,54 @@ of the page for marker-read and connection-acquire counters. |-------------------|------------------------------------------------------|---------------| | `/` | Five parallel Server Components (varied shapes). | `onFirstUse` | | `/stress/always` | Same page, pinned to `always` mode to reproduce H3. | `always` | +| `/diag` | JSON snapshot of in-process counters. | — | -Additional routes arrive in later PRs; this table is the planned surface. +`/stress/always` arrives in a later PR; the other two are live. + +## The five Server Components + +Rendered in parallel on `/`, each wrapped in its own `` so one +slow component doesn't block the others: + +1. **``** — ORM `orderBy(...).take(10).all()`. Baseline ORM + read path. +2. **``** — ORM `include('user', ...)`. Exercises the + multi-query include dispatch path in `sql-orm-client`. +3. **``** — SQL DSL via `db.sql.post...build()` + + `db.runtime().execute(plan)`. Only path that goes through + `runtime.execute()` rather than `acquireRuntimeScope()` — the most + direct way to exercise `verifyPlanIfNeeded()`. +4. **``** — ORM `groupBy().having().aggregate()`. + Aggregate dispatch path. +5. **``** — pgvector similarity search via ORM. + Exercises an extension-contributed operator (`cosineDistance`) on the + shared runtime. + +Plus **``** (client component) + `createPostAction` +(Server Action) — one smoke-level mutation to confirm reads and writes +can coexist on the shared runtime. Not exercised by k6; test by hand from +the browser. + +## Observed behavior (initial manual run) + +First request after process start (pool default `max: 10`): + +``` +markerReads: 5, connectionAcquires: 11, connectionReleases: 11 +``` + +Five marker reads for five parallel components on cold start **confirms +hypothesis H2** — each of the five concurrent components raced through +`verifyPlanIfNeeded()` before any of them flipped `verified` to true. +Subsequent requests show `markerReads: 5` remaining constant, as expected. + +Under 10 parallel page requests, `connectionAcquires` and +`connectionReleases` remain balanced; pool grows to its `max` but no +requests wait. No runtime errors logged. + +These are observations from a single-session manual run, not a formal +benchmark. The k6 scripts in a later PR replace this with reproducible +measurements. ## Stress scripts diff --git a/examples/rsc-poc-postgres/app/actions.ts b/examples/rsc-poc-postgres/app/actions.ts new file mode 100644 index 0000000000..c4d60fd223 --- /dev/null +++ b/examples/rsc-poc-postgres/app/actions.ts @@ -0,0 +1,82 @@ +'use server'; + +import { randomUUID } from 'node:crypto'; +import type { Char } from '@prisma-next/adapter-postgres/codec-types'; +import { revalidatePath } from 'next/cache'; +import { getDb } from '../src/lib/db'; + +/** + * Server Action #1 — create a post. + * + * The ticket's scope is read-focused, but we agreed one smoke-level Server + * Action belongs in the PoC to prove mutations-alongside-concurrent-reads + * don't explode. This is that smoke action. + * + * Path exercised: + * + * - Resolves the process-scoped runtime singleton (same one the page's five + * Server Components share). + * - Goes through `acquireRuntimeScope()` → `runtime.connection()` → a + * transaction-wrapped write via `withMutationScope()` in + * `sql-orm-client`. The transaction's lifetime pins one pool connection + * for the duration of the insert. + * - On success, calls `revalidatePath('/')` so the subsequent render picks + * up the new row. + * + * Intentionally NOT exercised: the k6 stress scripts don't invoke this + * action. Server Actions in Next.js are serialized per request, and we + * care about read-side concurrency here. The action is reachable from the + * `` client component on `/` for manual smoke testing. + * + * Pre-conditions: at least one user must exist (the seed creates two). + * With no users, the action returns an error state rather than throwing — + * the form surfaces it to the user. + */ + +export interface CreatePostState { + readonly status: 'idle' | 'ok' | 'error'; + readonly message?: string; +} + +export async function createPostAction( + _prev: CreatePostState, + formData: FormData, +): Promise { + const title = formData.get('title'); + if (typeof title !== 'string' || title.trim().length === 0) { + return { status: 'error', message: 'Title is required.' }; + } + + const db = getDb(); + + const user = await db.orm.User.select('id') + .orderBy((u) => u.createdAt.asc()) + .take(1) + .all(); + const author = user[0]; + if (!author) { + return { + status: 'error', + message: 'No users in the database — run `pnpm seed` first.', + }; + } + + try { + await db.orm.Post.create({ + // `Char<36>` is a nominal branded type in the generated contract; the + // runtime value is a plain UUID string. Matches the cast pattern used + // in `prisma-next-demo/src/orm-client/create-user.ts`. + id: randomUUID() as Char<36>, + title: title.trim(), + userId: author.id, + createdAt: new Date().toISOString(), + embedding: null, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { status: 'error', message }; + } + + revalidatePath('/'); + return { status: 'ok', message: `Created post "${title.trim()}".` }; +} diff --git a/examples/rsc-poc-postgres/app/diag/route.ts b/examples/rsc-poc-postgres/app/diag/route.ts new file mode 100644 index 0000000000..ecb01c0e6a --- /dev/null +++ b/examples/rsc-poc-postgres/app/diag/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from 'next/server'; +import type { VerifyMode } from '../../src/lib/db'; +import { getPool } from '../../src/lib/db'; +import { snapshotAll } from '../../src/lib/diag'; + +/** + * `/diag` — JSON snapshot of in-process diagnostic counters. + * + * Unlike the `` Server Component (which races sibling Suspense + * boundaries on the home page and may report stale numbers within a single + * render), this endpoint is read *after* any page render is complete, so its + * values are always current relative to the request that precedes it. + * + * Used by: + * + * - k6 stress scripts — called after a scenario finishes to record final + * counter values for the findings write-up. + * - The H3 integration test — asserts + * `markerReads === queryCount` under `always` mode. + * - Manual inspection — `curl http://localhost:3000/diag | jq`. + * + * Counters are cumulative since process start. To compare two points in + * time, read `/diag`, do some work, read `/diag` again, and subtract. + */ +export const dynamic = 'force-dynamic'; + +interface DiagPayload { + readonly timestampMs: number; + readonly snapshots: ReadonlyArray<{ + readonly verifyMode: VerifyMode; + readonly markerReads: number; + readonly connectionAcquires: number; + readonly connectionReleases: number; + readonly pool: { + readonly totalCount: number; + readonly idleCount: number; + readonly waitingCount: number; + } | null; + }>; +} + +export function GET(): Response { + const snapshots = snapshotAll().map((snap) => { + const pool = getPool({ verifyMode: snap.verifyMode }); + return { + verifyMode: snap.verifyMode, + markerReads: snap.markerReads, + connectionAcquires: snap.connectionAcquires, + connectionReleases: snap.connectionReleases, + pool: + pool === undefined + ? null + : { + totalCount: pool.totalCount, + idleCount: pool.idleCount, + waitingCount: pool.waitingCount, + }, + }; + }); + + const payload: DiagPayload = { + timestampMs: Date.now(), + snapshots, + }; + + return NextResponse.json(payload); +} diff --git a/examples/rsc-poc-postgres/app/page.tsx b/examples/rsc-poc-postgres/app/page.tsx index 3e22c57d07..1c4f6bf6f0 100644 --- a/examples/rsc-poc-postgres/app/page.tsx +++ b/examples/rsc-poc-postgres/app/page.tsx @@ -1,61 +1,99 @@ +import { Suspense } from 'react'; +import { CreatePostForm } from '../src/components/create-post-form'; import { DiagPanel } from '../src/components/diag-panel'; -import { getDb } from '../src/lib/db'; +import type { VerifyMode } from '../src/lib/db'; +import { PostsWithAuthors } from '../src/server-components/posts-with-authors'; +import { RecentPostsRaw } from '../src/server-components/recent-posts-raw'; +import { SimilarPostsSample } from '../src/server-components/similar-posts-sample'; +import { TopUsers } from '../src/server-components/top-users'; +import { UserKindBreakdown } from '../src/server-components/user-kind-breakdown'; export const dynamic = 'force-dynamic'; +const VERIFY_MODE: VerifyMode = 'onFirstUse'; + /** - * Placeholder home page — proves the harness boots and that the Prisma Next - * runtime singleton, `InstrumentedPool`, and `` hang together. + * Home page — five parallel Server Components querying through one shared + * Prisma Next runtime, plus one Server Action. + * + * ## Why this layout + * + * Each of the five `` elements below is an `async` Server + * Component. React Server Components kicks off each child's render as it + * evaluates this JSX tree, so the five queries start concurrently on Node's + * event loop. They share the single runtime returned by `getDb({ verifyMode: + * 'onFirstUse' })`, which is the exact configuration the PoC is designed to + * observe: + * + * - Hypothesis H1 (Collection cache race): five concurrent first-accesses + * to different models (User, Post) exercise the ORM Proxy's lazy cache. + * Expected: no observable effect — the `get` trap is synchronous. + * + * - Hypothesis H2 (redundant marker reads on cold start): because + * `verify.mode === 'onFirstUse'`, each of the five components' first + * query on a cold runtime can race through `verifyPlanIfNeeded()` + * before any of them flips `verified` to true. Expected: up to five + * marker reads visible in `` on the first page load after + * process start. * - * The five parallel Server Components (H1–H4 scenarios) land in step 3 of - * the project plan; this scaffold is deliberately boring. + * - Hypothesis H4 (pool pressure): five concurrent components each borrow + * a pool connection for the duration of their render. With the default + * `poolMax = 10` there's plenty of headroom; the `pool-pressure` k6 + * scenario forces contention with a small pool. + * + * Each component is wrapped in `` so a slow one doesn't block + * the others from streaming their HTML. This also makes the parallelism + * observable in the browser waterfall. + * + * ## What this page is NOT + * + * Not a general demo app. No styling beyond the PoC minimum, no fancy + * data shapes, no error boundaries beyond React defaults. The goal is + * to make the concurrency behavior legible, not to build a showcase. */ -export default async function Home() { - const db = getDb(); - const users = await db.orm.User.take(1).all(); - const sample = users[0]; - +export default function Home() { return ( <>

RSC Concurrency PoC — Postgres

- Scaffold only. The five parallel Server Components arrive in the next PR; see{' '} - projects/rsc-concurrency-safety/plan.md for the breakdown. + Five parallel Server Components sharing one Prisma Next runtime. Verify mode:{' '} + {VERIFY_MODE}. Switch to /stress/always (H3).

-
-

Smoke check

- {sample ? ( -

- Fetched user {sample.id} ({sample.email}) via the ORM client in a Server - Component. -

- ) : ( -

- No users yet — run pnpm db:init and pnpm seed. -

- )} -
- -
-

Next up

-
    -
  • 5 parallel Server Components (ORM, SQL DSL, include, aggregate, pgvector)
  • -
  • - /stress/always — reproduces hypothesis H3 -
  • -
  • One Server Action (smoke-level)
  • -
  • k6 scripts: baseline, spike, pool-pressure
  • -
  • - Integration test asserting marker reads == query count in{' '} - always mode -
  • -
-
+ }> + + + + }> + + + + }> + + + + }> + + + + }> + + + +
- + ); } + +function LoadingCard({ title }: { readonly title: string }) { + return ( +
+

{title}

+

Loading…

+
+ ); +} diff --git a/examples/rsc-poc-postgres/src/components/create-post-form.tsx b/examples/rsc-poc-postgres/src/components/create-post-form.tsx new file mode 100644 index 0000000000..a6f4055f3e --- /dev/null +++ b/examples/rsc-poc-postgres/src/components/create-post-form.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { useActionState } from 'react'; +import { type CreatePostState, createPostAction } from '../../app/actions'; + +/** + * Client-side form for the `createPostAction` Server Action. + * + * This is the one interactive surface in the PoC. It exists to demonstrate + * (by hand, not under k6) that a Server Action mutation can run alongside + * the page's five parallel Server Component reads without the shared + * runtime blowing up. + * + * Uses `useActionState` to surface the action's result inline. The action + * itself calls `revalidatePath('/')` on success, so the updated row shows + * up in `` and `` on the next render. + */ + +const INITIAL_STATE: CreatePostState = { status: 'idle' }; + +export function CreatePostForm() { + const [state, formAction, pending] = useActionState(createPostAction, INITIAL_STATE); + + return ( +
+

Create a post (Server Action)

+

+ createPostAction(title) → revalidatePath('/') +

+
+ + +
+ {state.status === 'ok' && ( +

+ ok + {state.message} +

+ )} + {state.status === 'error' && ( +

+ error + {state.message} +

+ )} +
+ ); +} diff --git a/examples/rsc-poc-postgres/src/components/diag-panel.tsx b/examples/rsc-poc-postgres/src/components/diag-panel.tsx index 32365ba980..42f2b3f476 100644 --- a/examples/rsc-poc-postgres/src/components/diag-panel.tsx +++ b/examples/rsc-poc-postgres/src/components/diag-panel.tsx @@ -6,16 +6,28 @@ import { snapshot } from '../lib/diag'; * Dev-only diagnostics panel, rendered at the bottom of a Server Component * page to surface the counters that back hypotheses H2–H4. * - * This is a Server Component itself — it reads the in-process diagnostic - * snapshot at render time. Because the snapshot is updated by - * `InstrumentedPool` as the page's other Server Components execute their - * queries in parallel, the values reflect the state **at the moment this - * component is rendered**, which in RSC may be partially or fully after the - * parallel siblings have finished. + * ## Staleness caveat * - * The panel does not cause its own query, so it won't perturb the counters - * it's reporting. Numbers are cumulative since process start (not - * per-request); a page reload shows monotonically increasing values. + * This is a Server Component that reads the in-process diagnostic snapshot + * at render time. React renders siblings concurrently but does **not** + * guarantee an ordering among siblings wrapped in separate `` + * boundaries. In practice this means `` usually resolves + * *before* its Suspense-wrapped siblings (none of its rendering is async), + * so the numbers it prints on the **first** load after process start + * reflect "what had finished before the panel was scheduled" — often zero + * or very few queries. + * + * Workaround: reload the page. Counters are cumulative since process + * start, so the second page render reads the post-work snapshot from the + * first render and the numbers settle into their intended meaning. + * + * For values that are always current (e.g. for the H3 integration test or + * k6 post-run inspection), prefer the `/diag` JSON route handler — it's + * read **after** any page render completes and has no ordering + * relationship to sibling Suspense boundaries. + * + * The panel does not issue its own query, so reading it doesn't perturb + * the counters it reports. */ export interface DiagPanelProps { readonly verifyMode: VerifyMode; @@ -58,7 +70,10 @@ export function DiagPanel({ verifyMode, poolMax }: DiagPanelProps) { {totalCount} total / {idleCount} idle / {waitingCount} waiting - cumulative since process start + + cumulative since process start · reload for accurate snapshot · see{' '} + /diag + ); diff --git a/examples/rsc-poc-postgres/src/lib/db.ts b/examples/rsc-poc-postgres/src/lib/db.ts index 40cfcb2b5c..98e7ac8424 100644 --- a/examples/rsc-poc-postgres/src/lib/db.ts +++ b/examples/rsc-poc-postgres/src/lib/db.ts @@ -35,7 +35,6 @@ import { createTelemetryMiddleware } from '@prisma-next/middleware-telemetry'; import type { PostgresClient } from '@prisma-next/postgres/runtime'; import postgres from '@prisma-next/postgres/runtime'; import type { RuntimeVerifyOptions } from '@prisma-next/sql-runtime'; -import { budgets, lints } from '@prisma-next/sql-runtime'; import type { Contract } from '../prisma/contract.d'; import contractJson from '../prisma/contract.json' with { type: 'json' }; import { InstrumentedPool } from './pool'; @@ -111,16 +110,12 @@ function createEntry(verifyMode: VerifyMode, poolMax: number): DbEntry { pg: pool, extensions: [pgvector], verify: { mode: verifyMode, requireMarker: false }, - middleware: [ - createTelemetryMiddleware(), - lints(), - budgets({ - maxRows: 10_000, - defaultTableRows: 10_000, - tableRows: { user: 10_000, post: 10_000 }, - maxLatencyMs: 5_000, - }), - ], + // Only telemetry. The demo app adds `lints()` and `budgets(...)`, but + // those flag ordinary queries the PoC issues (e.g. unbounded aggregates + // on small seed data) as errors, which distracts from what we're + // actually measuring. The PoC cares about runtime/pool behavior under + // RSC concurrency, not query-shape ergonomics. + middleware: [createTelemetryMiddleware()], }); return { client, pool, verifyMode, poolMax }; diff --git a/examples/rsc-poc-postgres/src/lib/pool.ts b/examples/rsc-poc-postgres/src/lib/pool.ts index a41bd67eef..5fbbce3ea8 100644 --- a/examples/rsc-poc-postgres/src/lib/pool.ts +++ b/examples/rsc-poc-postgres/src/lib/pool.ts @@ -4,14 +4,22 @@ * Counts: * - Connection acquires (`pool.connect()`) — every time the driver borrows a * client from the pool, whether for `execute()` or for `query()`. - * - Connection releases (`client.release()`) — so we can verify acquires and - * releases balance under load (H4). + * - Connection releases — observed via the pool's `'release'` event, which + * `pg-pool` emits from `_release()` on every checkin. We intentionally do + * NOT wrap `client.release`: `pg-pool` reassigns `client.release` inside + * `_acquireClient()` on *every* checkout (see `_releaseOnce`), so any + * wrapper we install gets clobbered on the second acquire of a pooled + * client, producing the classic "acquires keep growing, releases stuck" + * anomaly. The `'release'` event is emitted unconditionally and is the + * supported observation point. * - Marker reads — identified by SQL text containing `prisma_contract.marker`, * the stable fragment emitted by `PostgresAdapterImpl.readMarkerStatement()`. * The runtime's `verifyPlanIfNeeded()` issues the marker read via * `driver.query()`, which in `PostgresPoolDriverImpl` does * `pool.connect()` → `client.query(sql, params)` → `client.release()`. - * So we detect it at the client level. + * So we detect it at the client level by patching `client.query` (which, + * unlike `release`, is NOT reassigned on every checkout). An idempotent + * guard prevents double-wrapping when the same pooled client is reused. * * We subclass `pg.Pool` (rather than wrapping by composition) because the * bundled `@prisma-next/postgres` runtime uses `instanceof PgPool` in @@ -32,7 +40,9 @@ export interface InstrumentedPoolOptions extends PoolConfig { readonly verifyMode: VerifyMode; } -const INSTRUMENTED_MARKER = Symbol.for('prisma-next.rsc-poc-postgres.client.instrumented'); +const QUERY_INSTRUMENTED_MARKER = Symbol.for( + 'prisma-next.rsc-poc-postgres.client.query-instrumented', +); /** * Extracts the SQL text from the shapes `pg` accepts for `query()`. Returns @@ -55,19 +65,20 @@ function extractSql(queryTextOrConfig: unknown): string | null { } /** - * Instruments a `PoolClient` in place so that `query()` records marker reads - * and `release()` bumps the release counter. Idempotent: multiple calls on the - * same client are no-ops after the first. + * Patches `client.query` in place so that executed SQL matching the marker + * fragment bumps the marker-read counter. Idempotent via a symbol flag so + * re-acquires of the same pooled client don't double-wrap. + * + * We do NOT patch `client.release` here — see the module docstring for why. */ function instrumentPoolClient(client: PoolClient, verifyMode: VerifyMode): PoolClient { const flag = client as unknown as Record; - if (flag[INSTRUMENTED_MARKER]) { + if (flag[QUERY_INSTRUMENTED_MARKER]) { return client; } - flag[INSTRUMENTED_MARKER] = true; + flag[QUERY_INSTRUMENTED_MARKER] = true; const originalQuery = client.query.bind(client) as (...a: unknown[]) => unknown; - const originalRelease = client.release.bind(client) as (err?: Error | boolean) => void; const patchedQuery = (...args: unknown[]): unknown => { const sql = extractSql(args[0]); @@ -77,21 +88,12 @@ function instrumentPoolClient(client: PoolClient, verifyMode: VerifyMode): PoolC return originalQuery(...args); }; - const patchedRelease = (err?: Error | boolean): void => { - recordConnectionRelease(verifyMode); - originalRelease(err); - }; - - // pg's PoolClient types use overloaded signatures for query()/release() that - // are impractical to reproduce here without losing clarity. Assigning the - // patched functions back keeps the duck-typed driver call sites happy while + // pg's PoolClient types use overloaded signatures for query() that are + // impractical to reproduce here without losing clarity. Assigning the + // patched function back keeps the duck-typed driver call sites happy while // preserving runtime behavior. - const mutable = client as unknown as { - query: typeof patchedQuery; - release: typeof patchedRelease; - }; + const mutable = client as unknown as { query: typeof patchedQuery }; mutable.query = patchedQuery; - mutable.release = patchedRelease; return client; } @@ -100,9 +102,12 @@ function instrumentPoolClient(client: PoolClient, verifyMode: VerifyMode): PoolC * `pg.Pool` subclass with diagnostic counters. Keeps `instanceof PgPool` true * so it satisfies `resolvePostgresBinding()`'s instance check. * - * We only override `connect()` — the driver doesn't use `pool.query()` for - * anything we care about (marker reads go through an acquired client, not - * `pool.query`). + * Observations: + * - `connect()` is overridden to count acquires and instrument the returned + * client's `query` method. + * - Releases are counted via the pool's `'release'` event (emitted by + * `pg-pool`'s internal `_release()` on every checkin), so we stay robust + * against `pg-pool` reassigning `client.release` on each acquire. */ export class InstrumentedPool extends PgPool { readonly #verifyMode: VerifyMode; @@ -111,6 +116,12 @@ export class InstrumentedPool extends PgPool { const { verifyMode, ...poolConfig } = options; super(poolConfig); this.#verifyMode = verifyMode; + + // `pg-pool` emits `'release'` from `_release()` with signature + // `(err, client)`. We only care that it fired; the arguments are unused. + this.on('release', () => { + recordConnectionRelease(verifyMode); + }); } override connect(): Promise { diff --git a/examples/rsc-poc-postgres/src/server-components/posts-with-authors.tsx b/examples/rsc-poc-postgres/src/server-components/posts-with-authors.tsx new file mode 100644 index 0000000000..e7891b7002 --- /dev/null +++ b/examples/rsc-poc-postgres/src/server-components/posts-with-authors.tsx @@ -0,0 +1,55 @@ +import type { VerifyMode } from '../lib/db'; +import { getDb } from '../lib/db'; + +/** + * Server Component #2 / 5 — ORM with `include()`. + * + * Exercises the multi-query include dispatch path in `sql-orm-client`: + * `dispatchWithMultiQueryIncludes()` acquires one runtime scope and issues + * the parent query plus one query per include. All of that happens inside + * a single `acquireRuntimeScope()` call, so from the pool's perspective + * this component holds one connection for the duration of its render. + * + * This is the most likely place for a connection-starvation symptom under + * pool pressure: a slow include will pin a connection, and five of these + * rendering concurrently with a `max: 5` pool leaves zero headroom for + * anything else. + */ +export interface PostsWithAuthorsProps { + readonly verifyMode: VerifyMode; + readonly limit?: number; +} + +export async function PostsWithAuthors({ verifyMode, limit = 10 }: PostsWithAuthorsProps) { + const db = getDb({ verifyMode }); + const posts = await db.orm.Post.select('id', 'title', 'userId', 'createdAt') + .include('user', (user) => user.select('id', 'email', 'kind')) + .orderBy([(post) => post.createdAt.desc(), (post) => post.id.asc()]) + .take(limit) + .all(); + + return ( +
+

Posts with authors

+

+ db.orm.Post.include('user', ...).take({limit}).all() +

+ {posts.length === 0 ? ( +

+ No posts yet. Run pnpm seed. +

+ ) : ( +
    + {posts.map((post) => ( +
  • + {post.title} + + {post.user.kind} + {post.user.email} +
  • + ))} +
+ )} +
+ ); +} diff --git a/examples/rsc-poc-postgres/src/server-components/recent-posts-raw.tsx b/examples/rsc-poc-postgres/src/server-components/recent-posts-raw.tsx new file mode 100644 index 0000000000..7a7907bd40 --- /dev/null +++ b/examples/rsc-poc-postgres/src/server-components/recent-posts-raw.tsx @@ -0,0 +1,58 @@ +import type { VerifyMode } from '../lib/db'; +import { getDb } from '../lib/db'; + +/** + * Server Component #3 / 5 — SQL DSL path. + * + * Exercises the `db.sql` query builder plus direct `runtime.execute(plan)`. + * Unlike the ORM components, this path does **not** go through + * `acquireRuntimeScope()` — it executes directly against the runtime's + * shared `execute()` method, which in `@prisma-next/postgres` delegates to + * the driver's pool-backed queryable. Each execution still acquires and + * releases a pool connection for the query's lifetime, but the runtime + * state transitions (verification, telemetry) happen on the shared + * instance rather than on a borrowed connection scope. + * + * This is the code path where H2 (redundant marker reads on cold start) + * and H3 (skipped verification in `always` mode) are most directly + * observable: every `execute()` consults `verifyPlanIfNeeded()` before + * touching the driver. + */ +export interface RecentPostsRawProps { + readonly verifyMode: VerifyMode; + readonly limit?: number; +} + +export async function RecentPostsRaw({ verifyMode, limit = 10 }: RecentPostsRawProps) { + const db = getDb({ verifyMode }); + const plan = db.sql.post + .select('id', 'title', 'userId', 'createdAt') + .orderBy('createdAt', { direction: 'desc' }) + .limit(limit) + .build(); + const posts = await db.runtime().execute(plan); + + return ( +
+

Recent posts (SQL DSL)

+

+ db.sql.post.select(...).orderBy(...).limit({limit}).build() +

+ {posts.length === 0 ? ( +

+ No posts yet. Run pnpm seed. +

+ ) : ( +
    + {posts.map((post) => ( +
  • + {post.title} + — user + {post.userId} +
  • + ))} +
+ )} +
+ ); +} diff --git a/examples/rsc-poc-postgres/src/server-components/similar-posts-sample.tsx b/examples/rsc-poc-postgres/src/server-components/similar-posts-sample.tsx new file mode 100644 index 0000000000..559cdaeefc --- /dev/null +++ b/examples/rsc-poc-postgres/src/server-components/similar-posts-sample.tsx @@ -0,0 +1,89 @@ +import type { Char } from '@prisma-next/adapter-postgres/codec-types'; +import type { ModelAccessor } from '@prisma-next/sql-orm-client'; +import type { VerifyMode } from '../lib/db'; +import { getDb } from '../lib/db'; +import type { Contract } from '../prisma/contract.d'; + +/** + * Server Component #5 / 5 — pgvector similarity search via the ORM. + * + * Exercises the extension-contributed operator path: `pgvector` registers + * the `cosineDistance` codec operator, and `sql-orm-client` surfaces it on + * the `embedding` field's `ModelAccessor`. Under the hood this emits a + * vector operator in the generated SQL that only works because the + * extension pack is loaded in the runtime (see `src/lib/db.ts`). + * + * Included in the five-component mix specifically to confirm that + * extension-contributed operators compose safely with the shared runtime + * under concurrent rendering. Extensions mutate the execution context at + * construction time; by the time a request is served, that context is + * frozen, so this component is expected to behave identically to the + * other ORM paths. The PoC verifies rather than assumes. + * + * Strategy: pick the first post that has an embedding as a "query vector" + * and return the top-N most similar posts excluding itself. If the seed + * hasn't been run, or no post has an embedding, renders an empty state. + */ +export interface SimilarPostsSampleProps { + readonly verifyMode: VerifyMode; + readonly limit?: number; +} + +export async function SimilarPostsSample({ verifyMode, limit = 5 }: SimilarPostsSampleProps) { + const db = getDb({ verifyMode }); + + const seed = await db.orm.Post.select('id', 'title', 'embedding') + .orderBy((post) => post.createdAt.asc()) + .take(1) + .all(); + + const queryPost = seed[0]; + const queryEmbedding = queryPost?.embedding; + + if (!queryPost || !queryEmbedding) { + return ( +
+

Similar posts (pgvector)

+

+ db.orm.Post.orderBy(cosineDistance).take({limit}).all() +

+

+ No seed post with an embedding was found. Run pnpm seed. +

+
+ ); + } + + const cosineDistanceFrom = (post: ModelAccessor) => + post.embedding.cosineDistance(queryEmbedding); + + const similar = await db.orm.Post.where((post) => post.id.neq(queryPost.id as Char<36>)) + .where((post) => cosineDistanceFrom(post).lt(1)) + .orderBy((post) => cosineDistanceFrom(post).asc()) + .select('id', 'title', 'userId') + .take(limit) + .all(); + + return ( +
+

Similar posts (pgvector)

+

+ db.orm.Post.orderBy(cosineDistance).take({limit}).all() +

+

+ Query: {queryPost.title} +

+ {similar.length === 0 ? ( +

No similar posts within distance < 1.

+ ) : ( +
    + {similar.map((post) => ( +
  • + {post.title} +
  • + ))} +
+ )} +
+ ); +} diff --git a/examples/rsc-poc-postgres/src/server-components/top-users.tsx b/examples/rsc-poc-postgres/src/server-components/top-users.tsx new file mode 100644 index 0000000000..e3a0181d0c --- /dev/null +++ b/examples/rsc-poc-postgres/src/server-components/top-users.tsx @@ -0,0 +1,49 @@ +import type { VerifyMode } from '../lib/db'; +import { getDb } from '../lib/db'; + +/** + * Server Component #1 / 5 — plain ORM read. + * + * Exercises the simplest ORM code path: `Collection.orderBy().take().all()`. + * No includes, no aggregates, no extensions — this is the baseline shape + * other components are measured against. + * + * Under concurrent rendering, each instance of this component acquires its + * own connection via `acquireRuntimeScope()` → `runtime.connection()` → + * `pool.connect()`. The `InstrumentedPool` records each acquire. + */ +export interface TopUsersProps { + readonly verifyMode: VerifyMode; + readonly limit?: number; +} + +export async function TopUsers({ verifyMode, limit = 10 }: TopUsersProps) { + const db = getDb({ verifyMode }); + const users = await db.orm.User.orderBy((user) => user.createdAt.desc()) + .select('id', 'email', 'kind', 'createdAt') + .take(limit) + .all(); + + return ( +
+

Top users

+

+ db.orm.User.orderBy(...).take({limit}).all() +

+ {users.length === 0 ? ( +

+ No users yet. Run pnpm seed. +

+ ) : ( +
    + {users.map((user) => ( +
  • + {user.kind} + {user.email} +
  • + ))} +
+ )} +
+ ); +} diff --git a/examples/rsc-poc-postgres/src/server-components/user-kind-breakdown.tsx b/examples/rsc-poc-postgres/src/server-components/user-kind-breakdown.tsx new file mode 100644 index 0000000000..ae2fb20003 --- /dev/null +++ b/examples/rsc-poc-postgres/src/server-components/user-kind-breakdown.tsx @@ -0,0 +1,54 @@ +import type { VerifyMode } from '../lib/db'; +import { getDb } from '../lib/db'; + +/** + * Server Component #4 / 5 — ORM `groupBy().having().aggregate()`. + * + * Exercises the aggregate dispatch path in `sql-orm-client`. Like the other + * ORM components, this acquires a runtime scope for the duration of the + * call; unlike the plain `all()` path, it emits a GROUP BY plan rather than + * a SELECT, so it hits a slightly different compilation branch in + * `query-plan-aggregate.ts`. + * + * Included in the five-component mix to make sure aggregate plans behave + * correctly alongside simple reads under concurrent rendering — there is + * no shared mutable state specific to aggregates, but exercising the path + * is cheap insurance. + */ +export interface UserKindBreakdownProps { + readonly verifyMode: VerifyMode; + readonly minUsers?: number; +} + +export async function UserKindBreakdown({ verifyMode, minUsers = 1 }: UserKindBreakdownProps) { + const db = getDb({ verifyMode }); + const grouped = await db.orm.User.groupBy('kind') + .having((having) => having.count().gte(minUsers)) + .aggregate((aggregate) => ({ + totalUsers: aggregate.count(), + })); + + const rows = [...grouped].sort((left, right) => left.kind.localeCompare(right.kind)); + + return ( +
+

User kind breakdown

+

+ db.orm.User.groupBy('kind').having(count ≥ {minUsers}).aggregate(...) +

+ {rows.length === 0 ? ( +

No groups meet the threshold.

+ ) : ( +
    + {rows.map((row) => ( +
  • + {row.kind} + {row.totalUsers} + users +
  • + ))} +
+ )} +
+ ); +} From 5b0d214258e8d8aa57db00526f894ed52ce7975b Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Wed, 22 Apr 2026 14:29:11 +0200 Subject: [PATCH 4/8] examples(rsc-poc-postgres): /stress routes + k6 scripts + revise H3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the observation surface for H3 and H4: two stress routes (/stress/always pinned to verify=always, /stress/pool-pressure pinned to poolMax=5) and a single k6 script supporting three scenarios via SCENARIO=baseline|spike|pool_pressure. Both routes delegate to a shared body that renders the same five components + form + diag panel. Routes differ only by (verifyMode, poolMax) passed to getDb(), which keys the db singleton registry, so no two routes share a runtime or a pool. H3 revised in the project plan. The original claim (verify=always skips verification under concurrency because one query flips verified between a peer's reset and its own check) doesn't survive re-reading verifyPlanIfNeeded(): lines (reset verified=false) and (if verified return) are synchronous neighbors with no await between them, so in always mode the early-return is unreachable regardless of peer behavior. The /stress/always route and its forthcoming integration test become invariant confirmations ('markerReads === queryCount') rather than race reproducers. Plan updated with the reasoning. k6 script design: one file, three scenarios switched by env var, so package.json entries stay clean. setup()/teardown() capture /diag snapshots before and after each run and log deltas. No in-script asserts — invariants live in the vitest integration test (step 5). Running the k6 scripts surfaced one instrumentation bug: InstrumentedPool was counting acquires *before* super.connect() resolved. Under spike load the pg connectionTimeoutMillis rejected ~1,100 connects, inflating the acquire counter and permanently desynchronizing acquires vs releases. Fixed by counting only on successful resolve. The bug was in the PoC's instrumentation, not in Prisma Next or pg-pool — but it's the kind of thing only live load exposes. Propagated poolMax through the five Server Components as a pass-through prop. Needed explicit 'number | undefined' on DbOptions (and downstream) to satisfy exactOptionalPropertyTypes without conditional-spread boilerplate at every call site. README updated with the numbers from an initial run of each scenario. Refs: TML-2164, projects/rsc-concurrency-safety/plan.md --- examples/rsc-poc-postgres/README.md | 67 +++++- examples/rsc-poc-postgres/app/page.tsx | 108 ++------- .../app/stress/always/page.tsx | 52 +++++ .../app/stress/pool-pressure/page.tsx | 63 +++++ .../rsc-poc-postgres/scripts/stress.k6.js | 218 ++++++++++++++++++ .../src/components/diag-panel.tsx | 4 +- .../src/components/parallel-reads-page.tsx | 105 +++++++++ examples/rsc-poc-postgres/src/lib/db.ts | 10 +- examples/rsc-poc-postgres/src/lib/pool.ts | 19 +- .../server-components/posts-with-authors.tsx | 5 +- .../server-components/recent-posts-raw.tsx | 5 +- .../similar-posts-sample.tsx | 9 +- .../src/server-components/top-users.tsx | 5 +- .../server-components/user-kind-breakdown.tsx | 9 +- projects/rsc-concurrency-safety/plan.md | 109 ++++++--- 15 files changed, 640 insertions(+), 148 deletions(-) create mode 100644 examples/rsc-poc-postgres/app/stress/always/page.tsx create mode 100644 examples/rsc-poc-postgres/app/stress/pool-pressure/page.tsx create mode 100644 examples/rsc-poc-postgres/scripts/stress.k6.js create mode 100644 examples/rsc-poc-postgres/src/components/parallel-reads-page.tsx diff --git a/examples/rsc-poc-postgres/README.md b/examples/rsc-poc-postgres/README.md index 07a8445c6d..1dacf7fcf7 100644 --- a/examples/rsc-poc-postgres/README.md +++ b/examples/rsc-poc-postgres/README.md @@ -103,9 +103,12 @@ Plus **``** (client component) + `createPostAction` can coexist on the shared runtime. Not exercised by k6; test by hand from the browser. -## Observed behavior (initial manual run) +## Observed behavior -First request after process start (pool default `max: 10`): +Numbers below come from single runs on a local pgvector/pg17 container; +they're illustrative, not benchmarks. + +### Cold start (any route, first request) ``` markerReads: 5, connectionAcquires: 11, connectionReleases: 11 @@ -114,15 +117,61 @@ markerReads: 5, connectionAcquires: 11, connectionReleases: 11 Five marker reads for five parallel components on cold start **confirms hypothesis H2** — each of the five concurrent components raced through `verifyPlanIfNeeded()` before any of them flipped `verified` to true. -Subsequent requests show `markerReads: 5` remaining constant, as expected. +Subsequent requests show `markerReads: 5` remaining constant (the +`onFirstUse` contract holds after first flip). + +### Baseline — `/` @ 10 VUs × 30s (`onFirstUse`, `poolMax: 10`) + +``` +iterations: 8,850 over 30s (~295 req/s) +markerReads Δ: 0 # runtime already warm +acquires Δ: 53,100 # 6 per request × 8,850 +releases Δ: 53,100 # balanced +pool final: 10 total / 10 idle / 0 waiting +errors: 0 +``` + +### Spike — `/stress/always` @ 50 VUs × 30s (`always`, `poolMax: 10`) + +``` +iterations: ~240 over 30s (~8 req/s) +markerReads Δ: 266 # every execute verifies, as always-mode promises +acquires Δ: 447 +releases Δ: 447 # balanced +pool final: 10 total / 10 idle / 0 waiting +pg timeouts: thousands # expected under this pressure on poolMax=10 +``` + +Throughput collapses relative to baseline because every query carries an +extra marker-read round-trip in `always` mode — this is the whole point +of `always`. The invariant `acquiresΔ == releasesΔ` **holds** under the +predicted race window for H3; the integration test in the next PR pins +it more precisely. + +### Pool pressure — `/stress/pool-pressure` ramp 1→100 VUs × 50s (`onFirstUse`, `poolMax: 5`) + +``` +iterations: 15,571 over 50s (~311 req/s) +markerReads Δ: 5 # cold start for this route's own runtime +acquires Δ: 93,431 +releases Δ: 93,431 # still balanced at 100 VUs on poolMax: 5 +pg timeouts: 0 # with fast queries, queue drains in time +``` + +With the PoC's tiny seed dataset, queries return fast enough that a +5-slot pool sustains 100 VUs without timeouts. Larger payloads, higher +query latency, or higher RSC per-page concurrency would change the +picture — this is the sizing observation H4 is about, not a safety bug. -Under 10 parallel page requests, `connectionAcquires` and -`connectionReleases` remain balanced; pool grows to its `max` but no -requests wait. No runtime errors logged. +### One counter bug found by running this -These are observations from a single-session manual run, not a formal -benchmark. The k6 scripts in a later PR replace this with reproducible -measurements. +An earlier revision counted pool acquires *before* `super.connect()` +resolved. Under the spike scenario, `pg`'s `connectionTimeoutMillis` +rejected ~1,100 connects, so acquires outran releases by that delta. +Fixed by counting only on successful resolve. The `'release'` event +fires unconditionally from `pg-pool`'s `_release()` regardless of +what's happening inside `client.release()`, so that path was already +robust. See the comment block in `src/lib/pool.ts` for the gory details. ## Stress scripts diff --git a/examples/rsc-poc-postgres/app/page.tsx b/examples/rsc-poc-postgres/app/page.tsx index 1c4f6bf6f0..38a71e49e0 100644 --- a/examples/rsc-poc-postgres/app/page.tsx +++ b/examples/rsc-poc-postgres/app/page.tsx @@ -1,99 +1,31 @@ -import { Suspense } from 'react'; -import { CreatePostForm } from '../src/components/create-post-form'; -import { DiagPanel } from '../src/components/diag-panel'; -import type { VerifyMode } from '../src/lib/db'; -import { PostsWithAuthors } from '../src/server-components/posts-with-authors'; -import { RecentPostsRaw } from '../src/server-components/recent-posts-raw'; -import { SimilarPostsSample } from '../src/server-components/similar-posts-sample'; -import { TopUsers } from '../src/server-components/top-users'; -import { UserKindBreakdown } from '../src/server-components/user-kind-breakdown'; +import { ParallelReadsPage } from '../src/components/parallel-reads-page'; export const dynamic = 'force-dynamic'; -const VERIFY_MODE: VerifyMode = 'onFirstUse'; - /** - * Home page — five parallel Server Components querying through one shared - * Prisma Next runtime, plus one Server Action. - * - * ## Why this layout - * - * Each of the five `` elements below is an `async` Server - * Component. React Server Components kicks off each child's render as it - * evaluates this JSX tree, so the five queries start concurrently on Node's - * event loop. They share the single runtime returned by `getDb({ verifyMode: - * 'onFirstUse' })`, which is the exact configuration the PoC is designed to - * observe: - * - * - Hypothesis H1 (Collection cache race): five concurrent first-accesses - * to different models (User, Post) exercise the ORM Proxy's lazy cache. - * Expected: no observable effect — the `get` trap is synchronous. - * - * - Hypothesis H2 (redundant marker reads on cold start): because - * `verify.mode === 'onFirstUse'`, each of the five components' first - * query on a cold runtime can race through `verifyPlanIfNeeded()` - * before any of them flips `verified` to true. Expected: up to five - * marker reads visible in `` on the first page load after - * process start. + * Home route — the default, `onFirstUse` verify mode, default pool size. * - * - Hypothesis H4 (pool pressure): five concurrent components each borrow - * a pool connection for the duration of their render. With the default - * `poolMax = 10` there's plenty of headroom; the `pool-pressure` k6 - * scenario forces contention with a small pool. + * This is the route that demonstrates hypothesis H2: on cold start, five + * parallel Server Components race through `verifyPlanIfNeeded()` and each + * one issues its own marker read before any of them flips `verified` to + * true. Reload the page or read `/diag` after it settles to observe the + * 5 redundant marker reads. * - * Each component is wrapped in `` so a slow one doesn't block - * the others from streaming their HTML. This also makes the parallelism - * observable in the browser waterfall. - * - * ## What this page is NOT - * - * Not a general demo app. No styling beyond the PoC minimum, no fancy - * data shapes, no error boundaries beyond React defaults. The goal is - * to make the concurrency behavior legible, not to build a showcase. + * See `src/components/parallel-reads-page.tsx` for the shared body used + * by `/`, `/stress/always`, and `/stress/pool-pressure`. */ export default function Home() { return ( - <> -

RSC Concurrency PoC — Postgres

-

- Five parallel Server Components sharing one Prisma Next runtime. Verify mode:{' '} - {VERIFY_MODE}. Switch to /stress/always (H3). -

- -
- }> - - - - }> - - - - }> - - - - }> - - - - }> - - - - -
- - - - ); -} - -function LoadingCard({ title }: { readonly title: string }) { - return ( -
-

{title}

-

Loading…

-
+ + Five parallel Server Components sharing one Prisma Next runtime. Verify mode:{' '} + onFirstUse. This is the route that demonstrates hypothesis{' '} + H2 — redundant marker reads on cold start. + + } + /> ); } diff --git a/examples/rsc-poc-postgres/app/stress/always/page.tsx b/examples/rsc-poc-postgres/app/stress/always/page.tsx new file mode 100644 index 0000000000..8749569feb --- /dev/null +++ b/examples/rsc-poc-postgres/app/stress/always/page.tsx @@ -0,0 +1,52 @@ +import { ParallelReadsPage } from '../../../src/components/parallel-reads-page'; + +export const dynamic = 'force-dynamic'; + +/** + * `/stress/always` — same page body as `/`, but pinned to + * `verify.mode === 'always'`. + * + * Purpose: confirm the revised H3 invariant under concurrency. In `always` + * mode, `verifyPlanIfNeeded()` unconditionally sets `this.verified = false` + * at entry and immediately checks it on the same synchronous tick, so the + * early-return is unreachable and every execution must issue its own marker + * read. Under K concurrent queries the expected observation is: + * + * markerReads === queryCount (one marker read per execute) + * + * This is the invariant the k6 `spike` scenario stresses and the + * integration test pins. + * + * ## Why a separate runtime from `/` + * + * `getDb({ verifyMode: 'always' })` returns a distinct singleton from + * `getDb({ verifyMode: 'onFirstUse' })` — the registry in `lib/db` keys by + * `(verifyMode, poolMax)`. This isolation matters: if the two routes + * shared a runtime, `onFirstUse` traffic would leave `verified = true` + * and the first `always` request after it would still do its verify + * (reset flips false) but the per-route semantics would be muddled in the + * findings write-up. Keeping them separate preserves apples-to-apples. + * + * ## Why no routes-wide layout + * + * Each stress route renders the same `` body; the only + * differences are the props. We don't introduce a Next.js layout because + * the route group itself (`app/stress/`) has no shared UI chrome beyond + * the already-shared root layout. + */ +export default function StressAlwaysPage() { + return ( + + Same five parallel Server Components as /, but the shared runtime is pinned + to verify.mode === 'always'. Expected invariant:{' '} + markerReads === queryCount. This is the route that probes hypothesis{' '} + H3 (revised — no correctness bug predicted). + + } + /> + ); +} diff --git a/examples/rsc-poc-postgres/app/stress/pool-pressure/page.tsx b/examples/rsc-poc-postgres/app/stress/pool-pressure/page.tsx new file mode 100644 index 0000000000..d2220ae279 --- /dev/null +++ b/examples/rsc-poc-postgres/app/stress/pool-pressure/page.tsx @@ -0,0 +1,63 @@ +import { ParallelReadsPage } from '../../../src/components/parallel-reads-page'; + +export const dynamic = 'force-dynamic'; + +/** + * `/stress/pool-pressure` — same page body as `/`, but with a deliberately + * small pg pool (`max: 5`). + * + * Purpose: characterize hypothesis H4 — what happens when the number of + * concurrent connection borrowers (5 Server Components × N concurrent + * requests) meets or exceeds pool capacity. Each of the five components + * borrows a connection for the duration of its render, so a single page + * render already saturates a 5-slot pool. A second concurrent request + * has zero headroom and must wait. + * + * Expected observations under load (measured by the k6 `pool_pressure` + * scenario): + * + * - `pool.waitingCount` becomes nonzero as soon as concurrent requests + * exceed `ceil(pool.max / components_per_page)` — roughly 1 concurrent + * request at `max: 5`. + * - p50/p95 latency grows with the queue depth. + * - `connectionTimeoutMillis` (5s in `lib/db`) bounds the worst case; at + * high enough contention, requests fail with the pg `timeout exceeded` + * error rather than hanging forever. + * + * This is a **sizing/liveness** observation, not a safety bug. The PoC's + * stop condition doesn't require us to fix pool sizing — that's May + * (pool-sizing guidance as an explicit non-goal per the plan). + * + * ## Why a separate runtime + * + * Registry keys by `(verifyMode, poolMax)`, so this route's `poolMax: 5` + * singleton is distinct from the default `poolMax: 10` used by `/` and + * `/stress/always`. They never share a pg pool; counters remain + * apples-to-apples when the findings doc compares them. + * + * ## Why `onFirstUse` here (not `always`) + * + * The `poolMax` dimension is orthogonal to the verify-mode dimension. We + * pin verify to `onFirstUse` to match the default production shape; the + * k6 scenario measures pool contention under the same verify semantics + * users will actually run. + */ +const POOL_MAX = 5; + +export default function StressPoolPressurePage() { + return ( + + Same five parallel Server Components as /, but the shared pg pool is pinned + to max: {POOL_MAX}. One render already saturates the pool; concurrent renders + queue for connections. This is the route that probes hypothesis H4 (pool + pressure — sizing concern, not a safety bug). + + } + /> + ); +} diff --git a/examples/rsc-poc-postgres/scripts/stress.k6.js b/examples/rsc-poc-postgres/scripts/stress.k6.js new file mode 100644 index 0000000000..a865a6f09f --- /dev/null +++ b/examples/rsc-poc-postgres/scripts/stress.k6.js @@ -0,0 +1,218 @@ +/** + * k6 stress script for the RSC concurrency PoC. + * + * One script, three scenarios, selected via the `SCENARIO` env var. This + * keeps the `package.json` entries simple (`pnpm stress:baseline`, etc.) + * and lets the scenarios share the same setup/teardown. + * + * Run with (from the example's root): + * + * pnpm stress:baseline + * pnpm stress:spike + * pnpm stress:pool-pressure + * + * Or directly: + * + * k6 run scripts/stress.k6.js -e SCENARIO=baseline + * + * Target defaults to `http://localhost:3000`. Override with `-e BASE_URL=...` + * if you're running Next on a different port. + * + * ## Scenarios + * + * - `baseline` — 10 VUs × 30s against `/`. Measures steady-state behavior + * of the default `onFirstUse` route under moderate concurrent load. + * Establishes the "nothing is broken" baseline for H1/H2 findings. + * + * - `spike` — 50 VUs × 30s against `/stress/always`. Designed to make H3 + * visible (or, per the revised plan, to confirm the invariant + * `markerReads === queryCount`). Pre- and post-run `/diag` snapshots + * are captured so the ratio can be computed. + * + * - `pool_pressure` — ramp 1 → 100 VUs against `/stress/pool-pressure` + * (which pins `poolMax: 5`). Characterizes H4: each page render borrows + * 5 connections, so contention and waiting begin at 2 concurrent + * requests. Captures p95 latency and final pool snapshot. + * + * ## What the script does NOT do + * + * - Does not warm the runtime before measuring. Cold-start marker-read + * behavior (H2) is part of what we want to see; the first VU of each + * scenario observes it. + * + * - Does not assert correctness in-script. Pass/fail conditions live in + * the vitest integration test (step 5 of the plan). This script + * collects evidence; the test pins invariants. + * + * - Does not write machine-readable summary artifacts. k6's default + * end-of-run summary goes to stdout; save it with shell redirection if + * needed. (`k6 run --summary-export=...` is available but optional.) + */ + +import { check, sleep } from 'k6'; +import http from 'k6/http'; +import { Counter, Trend } from 'k6/metrics'; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'; +const SCENARIO = __ENV.SCENARIO || 'baseline'; + +// Custom metrics so the end-of-run summary is legible at a glance. +const pageOkCount = new Counter('rsc_page_ok'); +const pageErrCount = new Counter('rsc_page_err'); +const pageLatency = new Trend('rsc_page_latency_ms', true); + +const SCENARIO_CONFIG = { + baseline: { + path: '/', + executor: 'constant-vus', + vus: 10, + duration: '30s', + description: 'baseline: / @ 10 VUs × 30s (onFirstUse, default pool)', + }, + spike: { + path: '/stress/always', + executor: 'constant-vus', + vus: 50, + duration: '30s', + description: 'spike: /stress/always @ 50 VUs × 30s (verify=always)', + }, + pool_pressure: { + path: '/stress/pool-pressure', + executor: 'ramping-vus', + stages: [ + { duration: '10s', target: 10 }, + { duration: '10s', target: 30 }, + { duration: '10s', target: 60 }, + { duration: '10s', target: 100 }, + { duration: '10s', target: 100 }, + ], + description: 'pool_pressure: /stress/pool-pressure ramping 1 → 100 VUs (poolMax=5)', + }, +}; + +const cfg = SCENARIO_CONFIG[SCENARIO]; +if (!cfg) { + throw new Error( + `Unknown SCENARIO '${SCENARIO}'. Set SCENARIO to one of: ${Object.keys(SCENARIO_CONFIG).join(', ')}`, + ); +} + +export const options = { + scenarios: { + [SCENARIO]: + cfg.executor === 'constant-vus' + ? { + executor: 'constant-vus', + vus: cfg.vus, + duration: cfg.duration, + gracefulStop: '5s', + } + : { + executor: 'ramping-vus', + startVUs: 1, + stages: cfg.stages, + gracefulRampDown: '5s', + gracefulStop: '5s', + }, + }, + thresholds: { + // Soft thresholds — we report but don't fail the run on breach. Failing + // here would make `pool_pressure` always "fail", which is expected + // behavior (we're deliberately over-subscribing the pool). + rsc_page_err: ['count<1000'], + }, + summaryTrendStats: ['min', 'avg', 'med', 'p(95)', 'p(99)', 'max'], +}; + +/** + * setup() runs once before the VU loop. We snapshot `/diag` so the + * teardown phase can report the delta in marker reads, acquires, and + * releases attributable to this scenario. + */ +export function setup() { + console.log(`== ${cfg.description}`); + console.log(`== target: ${BASE_URL}${cfg.path}`); + + const diagRes = http.get(`${BASE_URL}/diag`); + const before = diagRes.status === 200 ? diagRes.json() : null; + + return { + startedAt: new Date().toISOString(), + diagBefore: before, + }; +} + +/** + * Default VU function. Each iteration fires one GET against the scenario's + * target route. We intentionally do not sleep between iterations: the + * point of the scenarios is to generate concurrent pressure, not to model + * realistic think time. + */ +export default function vuIteration() { + const res = http.get(`${BASE_URL}${cfg.path}`, { + tags: { scenario: SCENARIO }, + timeout: '30s', + }); + pageLatency.add(res.timings.duration); + + const ok = check(res, { + 'status is 200': (r) => r.status === 200, + 'body is not empty': (r) => typeof r.body === 'string' && r.body.length > 0, + }); + + if (ok) { + pageOkCount.add(1); + } else { + pageErrCount.add(1); + } + + // No sleep(); generate continuous pressure. + sleep(0); +} + +/** + * teardown() runs once after the VU loop. Reads `/diag` again and logs + * the deltas. These numbers are the core evidence for the findings doc. + */ +export function teardown(data) { + const diagRes = http.get(`${BASE_URL}/diag`); + const after = diagRes.status === 200 ? diagRes.json() : null; + + const before = data.diagBefore; + const snapshotForScenario = (payload) => { + if (!payload || !Array.isArray(payload.snapshots)) return null; + // Each scenario writes to exactly one (verifyMode, poolMax) registry + // entry, but we don't know which without parsing; report all of them + // and let the reader correlate with the scenario description. + return payload.snapshots; + }; + + const beforeSnaps = snapshotForScenario(before) || []; + const afterSnaps = snapshotForScenario(after) || []; + + // Align after-snaps to before-snaps by verifyMode so we can compute + // deltas even when `/diag` grows new entries mid-run (e.g. another VU + // hits a different route). In practice each k6 run hits one route, so + // there's typically one entry. + const beforeByMode = Object.fromEntries(beforeSnaps.map((s) => [s.verifyMode, s])); + const deltas = afterSnaps.map((a) => { + const b = beforeByMode[a.verifyMode] || { + markerReads: 0, + connectionAcquires: 0, + connectionReleases: 0, + }; + return { + verifyMode: a.verifyMode, + markerReadsDelta: a.markerReads - b.markerReads, + acquiresDelta: a.connectionAcquires - b.connectionAcquires, + releasesDelta: a.connectionReleases - b.connectionReleases, + poolFinal: a.pool, + }; + }); + + console.log(''); + console.log(`== ${SCENARIO} /diag deltas (after - before):`); + console.log(JSON.stringify(deltas, null, 2)); + console.log(`== started: ${data.startedAt}`); + console.log(`== finished: ${new Date().toISOString()}`); +} diff --git a/examples/rsc-poc-postgres/src/components/diag-panel.tsx b/examples/rsc-poc-postgres/src/components/diag-panel.tsx index 42f2b3f476..02a67da891 100644 --- a/examples/rsc-poc-postgres/src/components/diag-panel.tsx +++ b/examples/rsc-poc-postgres/src/components/diag-panel.tsx @@ -31,12 +31,12 @@ import { snapshot } from '../lib/diag'; */ export interface DiagPanelProps { readonly verifyMode: VerifyMode; - readonly poolMax?: number; + readonly poolMax?: number | undefined; } export function DiagPanel({ verifyMode, poolMax }: DiagPanelProps) { const snap = snapshot(verifyMode); - const pool = getPool({ verifyMode, ...(poolMax !== undefined ? { poolMax } : {}) }); + const pool = getPool({ verifyMode, poolMax }); const totalCount = pool?.totalCount ?? 0; const idleCount = pool?.idleCount ?? 0; diff --git a/examples/rsc-poc-postgres/src/components/parallel-reads-page.tsx b/examples/rsc-poc-postgres/src/components/parallel-reads-page.tsx new file mode 100644 index 0000000000..17f2c501c8 --- /dev/null +++ b/examples/rsc-poc-postgres/src/components/parallel-reads-page.tsx @@ -0,0 +1,105 @@ +import { Suspense } from 'react'; +import type { VerifyMode } from '../lib/db'; +import { PostsWithAuthors } from '../server-components/posts-with-authors'; +import { RecentPostsRaw } from '../server-components/recent-posts-raw'; +import { SimilarPostsSample } from '../server-components/similar-posts-sample'; +import { TopUsers } from '../server-components/top-users'; +import { UserKindBreakdown } from '../server-components/user-kind-breakdown'; +import { CreatePostForm } from './create-post-form'; +import { DiagPanel } from './diag-panel'; + +/** + * Shared page body for `/`, `/stress/always`, and `/stress/pool-pressure`. + * + * All three routes render the same five parallel Server Components plus the + * Server Action form and the diagnostics panel; what differs is the + * `(verifyMode, poolMax)` pair they pass to `getDb(...)`. Each unique pair + * gets its own runtime singleton in the `lib/db` registry, so the three + * routes never share a runtime and never contaminate each other's counters. + * + * Layout rules: + * - Each Server Component is wrapped in its own `` so React + * schedules them concurrently and a slow one doesn't block the others. + * - The Server Action form and the diag panel live outside the grid. The + * form is a client component; the diag panel is a Server Component + * whose staleness caveats are documented on the component itself. + * + * Props are passed through to every Server Component untouched — no + * branching on route in this file, no target-specific knobs (consistent + * with the repo's "no target branches, use adapters" rule). + */ +export interface ParallelReadsPageProps { + /** + * Verification mode for the shared runtime. `/` uses `onFirstUse` (default + * of `@prisma-next/postgres`); `/stress/always` uses `always`. + */ + readonly verifyMode: VerifyMode; + /** + * Max pg pool size. `/stress/pool-pressure` pins this to a small value + * (e.g. 5) to exercise hypothesis H4; the other routes use the default. + */ + readonly poolMax?: number; + /** + * Short human-readable label describing what this route is for. Rendered + * at the top of the page so the browser tab makes sense when multiple + * are open side-by-side during manual testing. + */ + readonly heading: string; + /** + * One-line explanation of the route's purpose. Rendered under the heading. + */ + readonly subtitle: React.ReactNode; +} + +export function ParallelReadsPage({ + verifyMode, + poolMax, + heading, + subtitle, +}: ParallelReadsPageProps) { + return ( + <> +

{heading}

+

{subtitle}

+

+ / · /stress/always ·{' '} + /stress/pool-pressure · /diag +

+ +
+ }> + + + + }> + + + + }> + + + + }> + + + + }> + + + + +
+ + + + ); +} + +function LoadingCard({ title }: { readonly title: string }) { + return ( +
+

{title}

+

Loading…

+
+ ); +} diff --git a/examples/rsc-poc-postgres/src/lib/db.ts b/examples/rsc-poc-postgres/src/lib/db.ts index 98e7ac8424..34a89e7c56 100644 --- a/examples/rsc-poc-postgres/src/lib/db.ts +++ b/examples/rsc-poc-postgres/src/lib/db.ts @@ -72,14 +72,20 @@ export interface DbOptions { * `@prisma-next/postgres` default). The `/stress/always` route uses * `'always'` to reproduce hypothesis H3 (skipped-verification under * concurrency). + * + * Explicit `undefined` is accepted (with `exactOptionalPropertyTypes`) so + * pass-through call sites like `getDb({ verifyMode: props.verifyMode })` + * don't need conditional-spread boilerplate. */ - readonly verifyMode?: VerifyMode; + readonly verifyMode?: VerifyMode | undefined; /** * Max pg pool size. The `pool-pressure` stress scenario uses a small value * (e.g. 5) to characterize hypothesis H4 (pool contention under RSC * concurrency). Defaults to 10 to match pg's default. + * + * Explicit `undefined` is accepted; see `verifyMode` above. */ - readonly poolMax?: number; + readonly poolMax?: number | undefined; } function registryKey(verifyMode: VerifyMode, poolMax: number): string { diff --git a/examples/rsc-poc-postgres/src/lib/pool.ts b/examples/rsc-poc-postgres/src/lib/pool.ts index 5fbbce3ea8..5971dcdf31 100644 --- a/examples/rsc-poc-postgres/src/lib/pool.ts +++ b/examples/rsc-poc-postgres/src/lib/pool.ts @@ -103,8 +103,13 @@ function instrumentPoolClient(client: PoolClient, verifyMode: VerifyMode): PoolC * so it satisfies `resolvePostgresBinding()`'s instance check. * * Observations: - * - `connect()` is overridden to count acquires and instrument the returned - * client's `query` method. + * - `connect()` is overridden to count **successful** acquires (after the + * promise resolves) and instrument the returned client's `query` method. + * Counting before `super.connect()` resolved would inflate acquires by + * the number of pool-timeout rejections, breaking the `acquires == + * releases` invariant on any run that exceeds pool capacity — see + * `/stress/spike` under `poolMax: 10` where dozens of connects time out + * with `connectionTimeoutMillis: 5000` and never deliver a client. * - Releases are counted via the pool's `'release'` event (emitted by * `pg-pool`'s internal `_release()` on every checkin), so we stay robust * against `pg-pool` reassigning `client.release` on each acquire. @@ -125,7 +130,13 @@ export class InstrumentedPool extends PgPool { } override connect(): Promise { - recordConnectionAcquire(this.#verifyMode); - return super.connect().then((client) => instrumentPoolClient(client, this.#verifyMode)); + return super.connect().then((client) => { + // Count only after `super.connect()` resolves. If it rejects (pool + // timeout), no client was delivered and no `'release'` will ever + // fire — bumping the counter on entry would desync + // acquires/releases permanently for the remainder of the process. + recordConnectionAcquire(this.#verifyMode); + return instrumentPoolClient(client, this.#verifyMode); + }); } } diff --git a/examples/rsc-poc-postgres/src/server-components/posts-with-authors.tsx b/examples/rsc-poc-postgres/src/server-components/posts-with-authors.tsx index e7891b7002..8c6bde7e68 100644 --- a/examples/rsc-poc-postgres/src/server-components/posts-with-authors.tsx +++ b/examples/rsc-poc-postgres/src/server-components/posts-with-authors.tsx @@ -17,11 +17,12 @@ import { getDb } from '../lib/db'; */ export interface PostsWithAuthorsProps { readonly verifyMode: VerifyMode; + readonly poolMax?: number | undefined; readonly limit?: number; } -export async function PostsWithAuthors({ verifyMode, limit = 10 }: PostsWithAuthorsProps) { - const db = getDb({ verifyMode }); +export async function PostsWithAuthors({ verifyMode, poolMax, limit = 10 }: PostsWithAuthorsProps) { + const db = getDb({ verifyMode, poolMax }); const posts = await db.orm.Post.select('id', 'title', 'userId', 'createdAt') .include('user', (user) => user.select('id', 'email', 'kind')) .orderBy([(post) => post.createdAt.desc(), (post) => post.id.asc()]) diff --git a/examples/rsc-poc-postgres/src/server-components/recent-posts-raw.tsx b/examples/rsc-poc-postgres/src/server-components/recent-posts-raw.tsx index 7a7907bd40..3d25657dde 100644 --- a/examples/rsc-poc-postgres/src/server-components/recent-posts-raw.tsx +++ b/examples/rsc-poc-postgres/src/server-components/recent-posts-raw.tsx @@ -20,11 +20,12 @@ import { getDb } from '../lib/db'; */ export interface RecentPostsRawProps { readonly verifyMode: VerifyMode; + readonly poolMax?: number | undefined; readonly limit?: number; } -export async function RecentPostsRaw({ verifyMode, limit = 10 }: RecentPostsRawProps) { - const db = getDb({ verifyMode }); +export async function RecentPostsRaw({ verifyMode, poolMax, limit = 10 }: RecentPostsRawProps) { + const db = getDb({ verifyMode, poolMax }); const plan = db.sql.post .select('id', 'title', 'userId', 'createdAt') .orderBy('createdAt', { direction: 'desc' }) diff --git a/examples/rsc-poc-postgres/src/server-components/similar-posts-sample.tsx b/examples/rsc-poc-postgres/src/server-components/similar-posts-sample.tsx index 559cdaeefc..04bbd7038c 100644 --- a/examples/rsc-poc-postgres/src/server-components/similar-posts-sample.tsx +++ b/examples/rsc-poc-postgres/src/server-components/similar-posts-sample.tsx @@ -26,11 +26,16 @@ import type { Contract } from '../prisma/contract.d'; */ export interface SimilarPostsSampleProps { readonly verifyMode: VerifyMode; + readonly poolMax?: number | undefined; readonly limit?: number; } -export async function SimilarPostsSample({ verifyMode, limit = 5 }: SimilarPostsSampleProps) { - const db = getDb({ verifyMode }); +export async function SimilarPostsSample({ + verifyMode, + poolMax, + limit = 5, +}: SimilarPostsSampleProps) { + const db = getDb({ verifyMode, poolMax }); const seed = await db.orm.Post.select('id', 'title', 'embedding') .orderBy((post) => post.createdAt.asc()) diff --git a/examples/rsc-poc-postgres/src/server-components/top-users.tsx b/examples/rsc-poc-postgres/src/server-components/top-users.tsx index e3a0181d0c..3ffc6eb6ee 100644 --- a/examples/rsc-poc-postgres/src/server-components/top-users.tsx +++ b/examples/rsc-poc-postgres/src/server-components/top-users.tsx @@ -14,11 +14,12 @@ import { getDb } from '../lib/db'; */ export interface TopUsersProps { readonly verifyMode: VerifyMode; + readonly poolMax?: number | undefined; readonly limit?: number; } -export async function TopUsers({ verifyMode, limit = 10 }: TopUsersProps) { - const db = getDb({ verifyMode }); +export async function TopUsers({ verifyMode, poolMax, limit = 10 }: TopUsersProps) { + const db = getDb({ verifyMode, poolMax }); const users = await db.orm.User.orderBy((user) => user.createdAt.desc()) .select('id', 'email', 'kind', 'createdAt') .take(limit) diff --git a/examples/rsc-poc-postgres/src/server-components/user-kind-breakdown.tsx b/examples/rsc-poc-postgres/src/server-components/user-kind-breakdown.tsx index ae2fb20003..fab9ad5189 100644 --- a/examples/rsc-poc-postgres/src/server-components/user-kind-breakdown.tsx +++ b/examples/rsc-poc-postgres/src/server-components/user-kind-breakdown.tsx @@ -17,11 +17,16 @@ import { getDb } from '../lib/db'; */ export interface UserKindBreakdownProps { readonly verifyMode: VerifyMode; + readonly poolMax?: number | undefined; readonly minUsers?: number; } -export async function UserKindBreakdown({ verifyMode, minUsers = 1 }: UserKindBreakdownProps) { - const db = getDb({ verifyMode }); +export async function UserKindBreakdown({ + verifyMode, + poolMax, + minUsers = 1, +}: UserKindBreakdownProps) { + const db = getDb({ verifyMode, poolMax }); const grouped = await db.orm.User.groupBy('kind') .having((having) => having.count().gte(minUsers)) .aggregate((aggregate) => ({ diff --git a/projects/rsc-concurrency-safety/plan.md b/projects/rsc-concurrency-safety/plan.md index b4d415393d..7c5073475a 100644 --- a/projects/rsc-concurrency-safety/plan.md +++ b/projects/rsc-concurrency-safety/plan.md @@ -50,23 +50,49 @@ telemetry. Results are correct; the wasted roundtrips are a real bug worth fixing (dedupe in-flight verification via a shared promise). Severity: low-to-moderate, not a correctness violation. -### H3 — `verified` / `startupVerified` in `always` mode: **real correctness bug** -In `verify.mode === 'always'`, the method sets `this.verified = false` at -entry, then awaits the marker read, then sets `this.verified = true`. -Interleaving: - -1. Query A: `verified = false` -2. Query B: `verified = false` -3. Query A: marker read → `verified = true` -4. Query B: checks `if (this.verified) return` → **skips its own - verification** - -This violates the `always` contract (every execution must verify). Severity -depends on the semantics users rely on for `always`; at minimum it's a -surprising silent violation. +### H3 — `verified` / `startupVerified` in `always` mode: **likely a non-issue** (revised) + +Original claim: under concurrent execution, one query flips +`this.verified = true` between another query's `this.verified = false` +reset and its own `if (this.verified) return` check, causing the second +query to skip verification. + +Re-reading `verifyPlanIfNeeded` against the proposed interleaving, the +claim doesn't hold: + +```ts +private async verifyPlanIfNeeded(_plan: ExecutionPlan): Promise { + if (this.verify.mode === 'always') { + this.verified = false; // (1) unconditional reset + } + if (this.verified) { // (2) synchronous check on same tick + return; + } + ... + await driver.query(...) // (3) first await + ... + this.verified = true; +} +``` -**Expected outcome:** reproducible under load. Correctness violation, not -just wasted work. +Lines (1) and (2) are synchronous; there is no `await` between them. In +`always` mode every entry *unconditionally* sets `verified = false` and +then immediately checks it, so the early-return at (2) is unreachable in +`always` mode regardless of what any concurrent caller does to the flag. +The flag can be flipped by a peer between (2) and (3) or between (3) +and the final `this.verified = true`, but `always` mode doesn't read it +again on this path — it just proceeds to verify. + +**Revised expected outcome:** `markerReads === queryCount` under +concurrency in `always` mode. No skipped verifications, no correctness +bug. The `/stress/always` route and its integration test become an +**invariant test** (lock in the expected equality) rather than a +regression reproducer. + +If the test surprisingly fails under real load — e.g. the interleaving +produces some other skip I didn't anticipate — update this hypothesis +and the findings doc accordingly. Part of the PoC's value is that +running the code tells us what reading the code can't. ### H4 — Connection pool pressure is a sizing/liveness concern, not a safety bug 5 parallel Server Components × N concurrent requests contend for the pg @@ -97,6 +123,15 @@ different things on purpose. ### 3.1 Two Next.js 16 App Router apps +Note on revised H3 (see §2): the `/stress/always` route and associated +test below were originally designed to **reproduce** a predicted race. +After re-reading the source, the race doesn't hold. The route and test +are kept as-is because they still have value — they lock in the +equality `markerReads === queryCount` as an invariant, and they provide +the direct apples-to-apples comparison to the onFirstUse path (which +*does* exhibit the benign H2 behavior). Treat them as invariant +confirmations, not race reproducers. + Both live under `examples/` so they survive project close-out. ``` @@ -113,7 +148,8 @@ Each app: - 5 parallel Server Components on `/` covering a **mix** of code paths (ORM + SQL DSL + includes + raw reads; see §4). - `/stress/always` route (Postgres only): same page but with the runtime - pinned to `verify.mode === 'always'` to reproduce H3. + pinned to `verify.mode === 'always'` to confirm the revised H3 — + `markerReads === queryCount` under concurrency. - One Server Action (`POST`-style): proves mutations alongside concurrent reads don't explode (ticket says reads; we agreed to add one smoke action). @@ -143,21 +179,21 @@ Scripts emit JSON summaries we can commit as reference output. ### 3.3 One integration test (Postgres) -`examples/rsc-poc-postgres/test/always-mode-race.test.ts` +`examples/rsc-poc-postgres/test/always-mode-invariant.test.ts` -Asserts the one race we can predict up-front (H3): +Asserts the H3 invariant (revised): > When `verify.mode === 'always'` and K concurrent queries share a runtime, > the number of verification marker reads **equals** K. -Implemented by counting marker-read statements via a spy driver (or the -telemetry middleware + a pg-query-capture hook — pick the lighter one -during implementation). If the test passes, we've reproduced the bug; if -it fails, H3 was wrong and we update the findings doc. +Implemented against the running app's `/diag` endpoint so the assertion +runs against the real pool + real runtime + real RSC rendering, not a +mock. If the invariant holds (expected), the test locks it in as a +regression guard. If it fails, the findings doc gets a new surprise to +document and H3 gets revised again. Observational output remains the primary deliverable per §3.1; this test -exists specifically to lock in one predicted invariant so regressions are -caught later. +exists specifically to pin the invariant. ### 3.4 Findings doc (final) @@ -174,12 +210,18 @@ covering: ### 3.5 ADR (conditional) -If H3 reproduces and the fix isn't trivial (e.g. it requires changing the -semantics of `verify.mode === 'always'` or introducing a mutex/ticket around -verification), draft an ADR under -`projects/rsc-concurrency-safety/adr-draft-verification-concurrency.md` and +The revised H3 expects no correctness bug in `always` mode, so the most +likely driver of an ADR is now **H2**: the redundant cold-start marker +reads are wasted work that users will file as a bug. A simple fix +(dedupe in-flight verification via a shared promise) resolves it +cleanly. If the PoC confirms H2 and the team agrees on the fix, draft +an ADR under +`projects/rsc-concurrency-safety/adr-draft-verification-dedupe.md` and migrate to `docs/architecture docs/adrs/` at close-out. +If the PoC turns up something genuinely surprising (e.g. a new race +the source-level re-reading didn't catch), the ADR covers that instead. + ## 4. The 5 Server Components (shape) Postgres app, `/`: @@ -214,7 +256,7 @@ Explicit non-goals so reviewers don't ask: | Risk | Mitigation | |---|---| -| H3 does not reproduce under k6 load | Add a deterministic test with `setImmediate`/manual scheduling to force interleaving; if still absent, update H3. | +| H3 invariant unexpectedly fails (`markerReads !== queryCount`) | Capture the failing counts and revisit whether there's a newly-identified race or an instrumentation bug. Update H3 and the findings doc. | | pg driver's own internal serialization hides the race | Inspect `@prisma-next/driver-postgres` to confirm whether queries are serialized per connection; design stress to use N connections. | | Next.js 16 churn: RSC semantics / caching defaults shift under us during the PoC | Pin a specific Next.js 16 minor; document version in each app's README. | | "Telemetry counting" conflates retries, middleware hooks, and actual marker reads | Count at the driver level (spy), not at middleware level, for the H3 assertion. | @@ -231,9 +273,10 @@ Each bullet is a candidate PR. Branches off `tml-2164-rsc-concurrency-safety-poc Server Component, `globalThis` singleton, dev-only diag panel, READMEs. Reuses `prisma-next-demo`'s contract/schema to avoid re-writing it. 3. **Postgres: 5 Server Components + Server Action** — the actual page. -4. **Postgres: `/stress/always` route + k6 scripts** — reproduce H3 - observationally. -5. **Postgres: integration test for H3** — deterministic assertion. +4. **Postgres: `/stress/always` route + k6 scripts** — confirm the H3 + invariant observationally. +5. **Postgres: integration test for H3 invariant** — assert + `markerReads === queryCount` in `always` mode under concurrency. 6. **Mongo app scaffold** — `examples/rsc-poc-mongo/`, reusing `retail-store`'s contract/seed. 7. **Mongo: 5 Server Components + Server Action + k6 scripts**. From a080096156503338c6577187d34a4cc4d1c0892c Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Wed, 22 Apr 2026 14:47:29 +0200 Subject: [PATCH 5/8] examples(rsc-poc-postgres): integration test pinning H2/H3 invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6 tests in test/always-mode-invariant.test.ts, covering: H2 (onFirstUse cold-start): - Single cold query -> exactly 1 marker read (sanity baseline). - K=5 concurrent cold queries -> 1 to K marker reads on first burst, 0 on subsequent bursts. Upper bound is K, lower bound is 1 - the exact number depends on how quickly the first caller flips verified=true, which is timing-dependent. H3 (always-mode invariant): - For K in {1, 5, 50}: markerReads === K. The K=50 case exercises the above-poolMax case where requests queue. - Repeated bursts (K*BURSTS): cumulative markerReads === K*BURSTS. Balance invariant (all tests): - connectionAcquires === connectionReleases. ## Design decisions Test level is process, not HTTP. The invariant's mechanism lives in RuntimeCoreImpl.verifyPlanIfNeeded(); RSC is incidental. Firing K concurrent awaited queries against a shared runtime is identical on-event-loop to what RSC produces during concurrent rendering, with much less orchestration overhead. HTTP-level coverage already exists via k6 + /diag deltas. The test requires a real Postgres (pgvector-enabled) via DATABASE_URL. @prisma/dev (used by withDevDatabase) is PGlite-backed and rejects concurrent TCP connections with 'Connection terminated unexpectedly', making it unusable for concurrency tests. I tried the scratch-schema isolation approach first; it fights the contract/adapter's hardcoded public. schema references, so the final design drops and recreates public before each test and configures vitest with maxWorkers=1 to serialize naturally. Without DATABASE_URL the whole describe is skipped, so CI's test:examples step passes without needing a Postgres service change. ## Plan updates Revised §3.3 to document the process-over-HTTP choice and the DATABASE_URL requirement. Refs: TML-2164, projects/rsc-concurrency-safety/plan.md --- examples/rsc-poc-postgres/README.md | 49 ++- .../test/always-mode-invariant.test.ts | 317 ++++++++++++++++++ .../test/utils/control-client.ts | 65 ++++ examples/rsc-poc-postgres/vitest.config.ts | 13 + projects/rsc-concurrency-safety/plan.md | 34 +- 5 files changed, 466 insertions(+), 12 deletions(-) create mode 100644 examples/rsc-poc-postgres/test/always-mode-invariant.test.ts create mode 100644 examples/rsc-poc-postgres/test/utils/control-client.ts create mode 100644 examples/rsc-poc-postgres/vitest.config.ts diff --git a/examples/rsc-poc-postgres/README.md b/examples/rsc-poc-postgres/README.md index 1dacf7fcf7..54077a3014 100644 --- a/examples/rsc-poc-postgres/README.md +++ b/examples/rsc-poc-postgres/README.md @@ -73,11 +73,12 @@ of the page for marker-read and connection-acquire counters. | Route | Purpose | Verify mode | |-------------------|------------------------------------------------------|---------------| -| `/` | Five parallel Server Components (varied shapes). | `onFirstUse` | -| `/stress/always` | Same page, pinned to `always` mode to reproduce H3. | `always` | -| `/diag` | JSON snapshot of in-process counters. | — | +| `/` | Five parallel Server Components (varied shapes). | `onFirstUse` | +| `/stress/always` | Same page, pinned to `always` mode to probe H3. | `always` | +| `/stress/pool-pressure` | Same page, pinned to `poolMax: 5` to probe H4. | `onFirstUse` (poolMax=5) | +| `/diag` | JSON snapshot of in-process counters. | — | -`/stress/always` arrives in a later PR; the other two are live. +All four routes are live. ## The five Server Components @@ -173,6 +174,46 @@ fires unconditionally from `pg-pool`'s `_release()` regardless of what's happening inside `client.release()`, so that path was already robust. See the comment block in `src/lib/pool.ts` for the gory details. +## Tests + +```sh +# skips the whole suite — ppg-dev rejects concurrent connections, so the +# H2/H3 invariant tests need a real Postgres +pnpm --filter rsc-poc-postgres test + +# runs the suite against a pgvector-capable Postgres +DATABASE_URL=postgresql://postgres:postgres@localhost:5434/rsc_poc \ + pnpm --filter rsc-poc-postgres test +``` + +Start a local Postgres (pgvector-enabled) once: + +```sh +docker run --rm -d --name rsc-poc-pg -p 5434:5432 \ + -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=rsc_poc \ + pgvector/pgvector:pg17 +``` + +The test suite (`test/always-mode-invariant.test.ts`) pins: + +- **H2** — `onFirstUse` cold-start: K concurrent queries issue **1 to K** + marker reads on first burst, then 0 on subsequent bursts. +- **H3** (revised) — `always` mode: K concurrent queries issue **exactly + K** marker reads, regardless of concurrency. Covered for K ∈ {1, 5, 50} + and across repeated bursts. +- **Balance** — `connectionAcquires === connectionReleases` in all + scenarios. + +Each test drops and recreates `public` + `prisma_contract` schemas before +running, so order-independence is guaranteed but concurrent `pnpm test` +runs against the same `DATABASE_URL` will corrupt each other. Vitest is +configured with `maxWorkers: 1` to serialize naturally. + +Without `DATABASE_URL`, the whole `describe` is skipped (CI-friendly — +no Postgres service needed for `test:examples`). The source-level +reasoning in `projects/rsc-concurrency-safety/plan.md §2` is the primary +argument for H3; this test is the pin. + ## Stress scripts ```sh diff --git a/examples/rsc-poc-postgres/test/always-mode-invariant.test.ts b/examples/rsc-poc-postgres/test/always-mode-invariant.test.ts new file mode 100644 index 0000000000..778e460909 --- /dev/null +++ b/examples/rsc-poc-postgres/test/always-mode-invariant.test.ts @@ -0,0 +1,317 @@ +/** + * Integration tests pinning the H2 and H3 invariants. + * + * These assert the observable concurrency behavior of + * `RuntimeCoreImpl.verifyPlanIfNeeded()` under the exact configuration + * the RSC app stresses: one shared runtime, K parallel queries on the + * Node event loop. + * + * ## What we pin + * + * - **H2 cold-start behavior** (`onFirstUse` / `startup` modes): on the + * first concurrent burst of K queries against a cold runtime, marker + * reads can be up to K — each concurrent caller may race through + * `verifyPlanIfNeeded` before any of them flips `verified = true`. + * After the first burst settles, subsequent queries issue zero + * marker reads. + * + * - **H3 always-mode invariant**: with `verify.mode === 'always'`, + * every query unconditionally verifies, so for K parallel queries the + * marker-read delta equals K regardless of concurrency. The original + * H3 claim (a race skipping verification) doesn't survive a + * source-level re-read of `verifyPlanIfNeeded`; see `plan.md §2` for + * the reasoning. This test locks in the corrected expectation as a + * regression guard. + * + * - **Balance invariant** (both modes): every successful pool acquire + * is matched by exactly one release. Counted by `InstrumentedPool` + * via `pg-pool`'s `'release'` event plus a post-`super.connect()` + * counter bump — see `src/lib/pool.ts` for why those specific + * observation points (prior revisions were lossy). + * + * ## Test level: process, not HTTP + * + * The invariants live in the runtime, not in RSC. We fire queries + * directly against a shared `@prisma-next/postgres` client and skip + * the Next.js layer entirely. On the event loop, this is identical to + * what the `` etc. components produce during concurrent + * rendering, but without the orchestration tax of `next start`, + * ports, and HTTP debugging. + * + * HTTP-level coverage lives in the k6 scripts: `stress:spike` hits + * `/stress/always` and the teardown-time `/diag` delta is the + * end-to-end observation. This file is the deterministic pin. + * + * ## Why this test requires a real Postgres (not ppg-dev) + * + * `@prisma/dev` (ppg-dev, used by `withDevDatabase`) is a PGlite-backed + * single-connection server: it accepts one TCP connection at a time + * and rejects concurrent attempts with "Connection terminated + * unexpectedly". That's fine for `prisma-next-demo`'s sequential + * integration tests, but these H2/H3 tests are specifically about + * **concurrent** connection borrowing — the race window only exists + * when multiple `verifyPlanIfNeeded` calls are actually in flight + * simultaneously. + * + * So we require `DATABASE_URL` to point at a Postgres (with pgvector) + * that accepts multiple concurrent connections. When it's unset, the + * whole suite is `describe.skip`'d with a clear message. Running it + * locally: + * + * # in one shell (from repo root): + * docker run --rm -d --name rsc-poc-pg -p 5434:5432 \ + * -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=rsc_poc \ + * pgvector/pgvector:pg17 + * + * # then: + * DATABASE_URL=postgresql://postgres:postgres@localhost:5434/rsc_poc \ + * pnpm --filter rsc-poc-postgres test + * + * CI skips these tests. The H3 invariant is simple enough that the + * source-level reasoning in `plan.md §2` is the primary argument; this + * test exists to pin it against future regressions and is most useful + * run by a developer against a real database during local dev. + * + * ## Isolation strategy: drop-and-recreate `public` before each test + * + * We **do not** use per-test scratch schemas. That approach fights the + * contract/adapter: the generated DDL hardcodes `"public"."user"` and + * `"public"."post"`, and the pgvector extension gets installed in + * `public` too, so setting `search_path` on the connection URL does + * not actually redirect writes — it only affects unqualified reads. + * + * Instead, each test drops and recreates the `public` schema (and the + * `prisma_contract` schema that `dbInit` writes its marker into), + * then re-applies the contract. This is the same approach the app's + * own `scripts/drop-db.ts` uses. Vitest is configured with + * `maxWorkers: 1` in `vitest.config.ts`, so these mutations serialize + * naturally — no locking needed. + * + * Trade-off: a second `pnpm test` invocation running on the same + * `DATABASE_URL` concurrently would corrupt the shared database. + * Don't do that. If you need isolated concurrent runs, point each at + * its own Postgres instance. + * + * ## Why each test builds its own runtime + * + * - Each test needs to observe cold-start behavior for at least one + * phase of its assertions, so we can't share a warmed-up runtime + * across tests. + * - The app's `src/lib/db.ts` pins runtimes to `globalThis` keyed by + * `(verifyMode, poolMax)`. Tests bypass that registry to avoid + * bleed-through from a previous test's state. + */ + +import pgvector from '@prisma-next/extension-pgvector/runtime'; +import postgres, { type PostgresClient } from '@prisma-next/postgres/runtime'; +import { timeouts } from '@prisma-next/test-utils'; +import { Client } from 'pg'; +import { afterEach, describe, expect, it } from 'vitest'; + +import type { VerifyMode } from '../src/lib/db'; +import { reset, snapshot } from '../src/lib/diag'; +import { InstrumentedPool } from '../src/lib/pool'; +import type { Contract } from '../src/prisma/contract.d'; +import contractJson from '../src/prisma/contract.json' with { type: 'json' }; +import { initTestDatabase } from './utils/control-client'; + +const DATABASE_URL = process.env['DATABASE_URL']; + +interface TestRuntime { + readonly client: PostgresClient; + readonly pool: InstrumentedPool; + readonly verifyMode: VerifyMode; + close(): Promise; +} + +/** + * Drops and recreates the `public` schema and drops the + * `prisma_contract` schema so a subsequent `initTestDatabase` starts + * from a clean slate. Matches `scripts/drop-db.ts` in the app. + */ +async function resetDatabase(baseUrl: string): Promise { + const client = new Client({ connectionString: baseUrl }); + await client.connect(); + try { + await client.query('DROP SCHEMA IF EXISTS public CASCADE'); + await client.query('CREATE SCHEMA public'); + await client.query('DROP SCHEMA IF EXISTS prisma_contract CASCADE'); + } finally { + await client.end(); + } +} + +async function createTestRuntime( + connectionString: string, + verifyMode: VerifyMode, + poolMax = 10, +): Promise { + const pool = new InstrumentedPool({ + connectionString, + max: poolMax, + connectionTimeoutMillis: 5_000, + idleTimeoutMillis: 10_000, + verifyMode, + }); + + const client = postgres({ + contractJson, + pg: pool, + extensions: [pgvector], + verify: { mode: verifyMode, requireMarker: false }, + }); + + await client.connect(); + + return { + client, + pool, + verifyMode, + async close() { + // `postgres()` doesn't expose a top-level close; ending the pool we + // passed in cleans up every pg client the runtime ever borrowed. + await pool.end(); + }, + }; +} + +/** + * Exercises the same ORM path the five Server Components use, so the + * invariant test covers the production shape — not a simplified + * query that might bypass `acquireRuntimeScope()` or + * `verifyPlanIfNeeded()`. + */ +async function runOneOrmQuery(rt: TestRuntime): Promise { + await rt.client.orm.User.take(1).all(); +} + +async function runKParallelQueries(rt: TestRuntime, k: number): Promise { + const tasks: Array> = []; + for (let i = 0; i < k; i++) { + tasks.push(runOneOrmQuery(rt)); + } + await Promise.all(tasks); +} + +async function withFreshRuntime( + verifyMode: VerifyMode, + fn: (rt: TestRuntime) => Promise, + poolMax?: number, +): Promise { + if (!DATABASE_URL) { + throw new Error('withFreshRuntime called without DATABASE_URL — describe.skipIf should guard'); + } + await resetDatabase(DATABASE_URL); + await initTestDatabase({ connection: DATABASE_URL, contract: contractJson }); + const rt = await createTestRuntime(DATABASE_URL, verifyMode, poolMax); + try { + await fn(rt); + } finally { + await rt.close(); + } +} + +describe.skipIf(!DATABASE_URL)( + 'verifyPlanIfNeeded invariants under concurrency', + { timeout: timeouts.spinUpPpgDev }, + () => { + afterEach(() => { + // Counters are `globalThis`-backed; clear between tests so one + // test's acquires don't bleed into another's delta. + reset(); + }); + + describe('H2 — onFirstUse cold-start behavior', () => { + it('K concurrent queries on a cold runtime issue up to K marker reads; 0 thereafter', async () => { + await withFreshRuntime('onFirstUse', async (rt) => { + const K = 5; + + // First burst — this is the race window H2 predicts. + await runKParallelQueries(rt, K); + const coldSnap = snapshot('onFirstUse'); + + // H2: up to K marker reads on the first burst. At least 1 + // (contract verification must happen), at most K (every + // caller raced through the verify check before the first + // one flipped `verified = true`). + expect(coldSnap.markerReads).toBeGreaterThanOrEqual(1); + expect(coldSnap.markerReads).toBeLessThanOrEqual(K); + + // Every acquire from the burst is released. + expect(coldSnap.connectionAcquires).toBe(coldSnap.connectionReleases); + + // Second burst — `verified` is now true, so no new marker + // reads should happen regardless of concurrency. + await runKParallelQueries(rt, K); + const warmSnap = snapshot('onFirstUse'); + + expect(warmSnap.markerReads).toBe(coldSnap.markerReads); + expect(warmSnap.connectionAcquires).toBe(warmSnap.connectionReleases); + expect(warmSnap.connectionAcquires).toBeGreaterThan(coldSnap.connectionAcquires); + }); + }); + + it('a single cold query issues exactly 1 marker read', async () => { + await withFreshRuntime('onFirstUse', async (rt) => { + await runOneOrmQuery(rt); + const snap = snapshot('onFirstUse'); + + // Single-query sanity: with no concurrent peers the race + // window is empty, so exactly one marker read happens. + // Pinning this prevents a regression where the runtime + // would silently start doing 2+ marker reads per query. + expect(snap.markerReads).toBe(1); + expect(snap.connectionAcquires).toBe(snap.connectionReleases); + }); + }); + }); + + describe('H3 — always-mode invariant', () => { + it.each([ + { name: 'K=1 (single query)', k: 1 }, + { name: 'K=5 (matches the RSC page shape)', k: 5 }, + { name: 'K=50 (well past the default pool max)', k: 50 }, + ])('markerReads delta equals queryCount: $name', async ({ k }) => { + await withFreshRuntime('always', async (rt) => { + await runKParallelQueries(rt, k); + const snap = snapshot('always'); + + // H3 (revised): every `execute()` in `always` mode runs + // through `verifyPlanIfNeeded` because lines (verified = + // false) and (if (verified) return) are synchronous + // neighbors — the early-return is unreachable in + // always-mode. Hence markerReads === queryCount, + // regardless of concurrency. + expect(snap.markerReads).toBe(k); + + // Balance invariant: every successful pool.connect() + // resolves to a matching pool release. Desync here would + // indicate either (a) an instrumentation bug (the prior + // revision counted acquires before connect() resolved — + // see src/lib/pool.ts) or (b) a real connection leak in + // the runtime. + expect(snap.connectionAcquires).toBe(snap.connectionReleases); + expect(snap.connectionAcquires).toBeGreaterThanOrEqual(k); + }); + }); + + it('repeated bursts keep issuing one marker read per query', async () => { + await withFreshRuntime('always', async (rt) => { + const K = 5; + const BURSTS = 3; + + for (let i = 0; i < BURSTS; i++) { + await runKParallelQueries(rt, K); + } + + const snap = snapshot('always'); + + // always mode never caches verification, so the invariant + // holds cumulatively too — not just within a single burst. + expect(snap.markerReads).toBe(K * BURSTS); + expect(snap.connectionAcquires).toBe(snap.connectionReleases); + }); + }); + }); + }, +); diff --git a/examples/rsc-poc-postgres/test/utils/control-client.ts b/examples/rsc-poc-postgres/test/utils/control-client.ts new file mode 100644 index 0000000000..86fa2bc288 --- /dev/null +++ b/examples/rsc-poc-postgres/test/utils/control-client.ts @@ -0,0 +1,65 @@ +/** + * Test utilities using the programmatic control client and runtime. + * + * This demonstrates how to use `createControlClient` for test database setup + * and the runtime for data operations, instead of manual SQL and stampMarker. + */ +import postgresAdapter from '@prisma-next/adapter-postgres/control'; +import { type ControlClient, createControlClient } from '@prisma-next/cli/control-api'; +import postgresDriver from '@prisma-next/driver-postgres/control'; +import pgvector from '@prisma-next/extension-pgvector/control'; +import sql from '@prisma-next/family-sql/control'; +import postgres from '@prisma-next/target-postgres/control'; + +export interface TestControlClientOptions { + readonly connection: string; +} + +/** + * Creates a control client configured for the demo app's stack. + * + * The client auto-connects when operations are called because we provide + * a default connection in options. + */ +export function createPrismaNextControlClient(options: TestControlClientOptions): ControlClient { + return createControlClient({ + family: sql, + target: postgres, + adapter: postgresAdapter, + driver: postgresDriver, + extensionPacks: [pgvector], + connection: options.connection, + }); +} + +/** + * Initializes a test database with schema and marker from a contract. + * + * This replaces the manual table creation and stampMarker calls. + * dbInit in 'apply' mode creates all tables/indexes and writes the marker. + * + * @example + * ```typescript + * await withDevDatabase(async ({ connectionString }) => { + * await initTestDatabase({ connection: connectionString, contract }); + * // Database is now ready with schema and marker + * }); + * ``` + */ +export async function initTestDatabase(options: { + readonly connection: string; + readonly contract: unknown; +}): Promise { + const client = createPrismaNextControlClient({ connection: options.connection }); + + try { + const initResult = await client.dbInit({ contract: options.contract, mode: 'apply' }); + if (!initResult.ok) { + throw new Error( + `dbInit failed: ${initResult.failure.summary}\n\n${JSON.stringify(initResult.failure, null, 2)}`, + ); + } + } finally { + await client.close(); + } +} diff --git a/examples/rsc-poc-postgres/vitest.config.ts b/examples/rsc-poc-postgres/vitest.config.ts new file mode 100644 index 0000000000..4936540d6c --- /dev/null +++ b/examples/rsc-poc-postgres/vitest.config.ts @@ -0,0 +1,13 @@ +import { timeouts } from '@prisma-next/test-utils'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + pool: 'threads', + maxWorkers: 1, + isolate: false, + testTimeout: timeouts.spinUpPpgDev, + hookTimeout: timeouts.spinUpPpgDev, + }, +}); diff --git a/projects/rsc-concurrency-safety/plan.md b/projects/rsc-concurrency-safety/plan.md index 7c5073475a..d3cca75664 100644 --- a/projects/rsc-concurrency-safety/plan.md +++ b/projects/rsc-concurrency-safety/plan.md @@ -186,14 +186,32 @@ Asserts the H3 invariant (revised): > When `verify.mode === 'always'` and K concurrent queries share a runtime, > the number of verification marker reads **equals** K. -Implemented against the running app's `/diag` endpoint so the assertion -runs against the real pool + real runtime + real RSC rendering, not a -mock. If the invariant holds (expected), the test locks it in as a -regression guard. If it fails, the findings doc gets a new surprise to -document and H3 gets revised again. - -Observational output remains the primary deliverable per §3.1; this test -exists specifically to pin the invariant. +**Test level: process, not HTTP.** The plan originally called for +asserting against the running app's `/diag` endpoint. On reflection, +the invariant's mechanism lives entirely in +`RuntimeCoreImpl.verifyPlanIfNeeded()` — the RSC layer is incidental. +What the test actually needs to exercise is "N parallel `await`ed +queries sharing one runtime on the Node event loop", which is +identical on-event-loop to what RSC produces. A process-level test +gets that with less machinery (no `next start`, no port management, no +HTTP layer to debug when something fails) and lets us cheaply +parameterize K. + +HTTP-level coverage of the invariant isn't lost: the k6 scripts +already exercise the `/stress/always` path end-to-end and the +teardown-time `/diag` delta is the HTTP-level observation. The +integration test is the deterministic pin; k6 is the empirical +sanity check. + +Implementation: construct an `InstrumentedPool` + `postgres()` client +directly in the test (reusing the app's `src/lib/pool.ts`), fire K +concurrent `execute()`s against the same runtime, then assert +`markerReads === K` and `acquires === releases`. A couple of K values +(e.g. 1, 5, 50) cover both "single query" and "well-past the default +pool" shapes. + +Observational output remains the primary deliverable per §3.1; this +test exists specifically to pin the invariant. ### 3.4 Findings doc (final) From 9ba478c8785fc104453514634af6c2760917947b Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Wed, 22 Apr 2026 15:15:59 +0200 Subject: [PATCH 6/8] =?UTF-8?q?examples(rsc-poc-mongo):=20full=20Mongo-sid?= =?UTF-8?q?e=20PoC=20=E2=80=94=205=20RSC=20+=20action=20+=20k6=20+=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffold, components, stress routes, k6 scripts, and concurrency invariant tests for the Mongo family side of the PoC. Steps 6 and 7 of the project plan combined because the Mongo surface is smaller than the Postgres one (no verifyMode dimension means no /stress/always route and no corresponding k6 scenario). ## Structure Mirrors examples/rsc-poc-postgres but with Mongo-shaped differences: - src/lib/db.ts — globalThis-scoped singleton keyed by poolMax (no verifyMode, since MongoRuntimeImpl has no verification state). - src/lib/diag.ts — counter registry for APM/CMAP events instead of pg-pool events. No markerReads counter — it would always be zero and muddle the contrast with the Postgres side. - No InstrumentedPool subclass. MongoClient isn't designed to be subclassed; we attach listeners for commandStarted/Succeeded/Failed and connectionCheckedOut/In/Created/Closed before client.connect(), wired through to the diag module. Matches the shape of what the Postgres app's instrumentation produces, with different observables. - Five Server Components covering the same conceptual ground as the Postgres five: baseline ORM, ORM with include, query builder path, aggregate pipeline, polymorphism variant. Plus a smoke-level Server Action (create SearchEvent). ## Routes / — default pool (maxPoolSize: 100) /stress/pool-pressure — pinned to maxPoolSize: 5 for H4 observation /diag — JSON snapshot No /stress/always analogue because the Mongo runtime has no verify mode to toggle. This asymmetry is the whole point of running this app alongside the Postgres one. ## Tests 9 invariant tests in test/concurrency-invariants.test.ts, all passing in ~3s against mongodb-memory-server. Unlike the Postgres tests (which must skip when DATABASE_URL is unset because @prisma/dev rejects concurrent connections), these tests run standalone and don't require any external service — CI picks them up as-is. Tests pin: - H5: K concurrent queries issue exactly K commands (no verification multiplier). Covered for K in {1, 5, 50} plus cold-start burst. - Balance: connectionsCheckedOut === connectionsCheckedIn across all scenarios including K=50 on maxPoolSize=5 (contention). - Cumulative: repeated bursts keep per-command accounting linear. ## Observations from manual runs Cold start / baseline (maxPoolSize: 100): iterations 14,003 in 30s (~467 req/s, ~60% faster than Postgres) commands 70,015 = exactly 5 per request (no marker read overhead) checkOuts 70,015 = checkIns 70,015 (balanced) tcpCreated 0 (connections reused) Pool pressure (maxPoolSize: 5, 100 VUs): iterations 17,503 in 50s commands 87,515 = exactly 5 per request 0 failed commands (Mongo multiplexes, doesn't queue like pg) checkOuts and checkIns balanced Contrast to Postgres side: - PG baseline: ~295 req/s, 6 acquires per request (1 marker + 5 data) - Mongo baseline: ~467 req/s, 5 commands per request (no marker) - PG spike (verify=always): 1000s of connection timeouts at 50 VUs - Mongo under similar pressure: zero failures H5 confirmed: the Mongo runtime has no H2/H3 analogue by construction. Refs: TML-2164, projects/rsc-concurrency-safety/plan.md --- examples/rsc-poc-mongo/.env.example | 22 + examples/rsc-poc-mongo/.gitignore | 7 + examples/rsc-poc-mongo/README.md | 297 ++++ examples/rsc-poc-mongo/app/actions.ts | 70 + examples/rsc-poc-mongo/app/diag/route.ts | 74 + examples/rsc-poc-mongo/app/globals.css | 196 +++ examples/rsc-poc-mongo/app/layout.tsx | 18 + examples/rsc-poc-mongo/app/page.tsx | 32 + .../app/stress/pool-pressure/page.tsx | 63 + examples/rsc-poc-mongo/biome.jsonc | 6 + examples/rsc-poc-mongo/next.config.js | 9 + examples/rsc-poc-mongo/package.json | 52 + examples/rsc-poc-mongo/prisma-next.config.ts | 18 + examples/rsc-poc-mongo/prisma/contract.prisma | 151 ++ examples/rsc-poc-mongo/scripts/seed.ts | 248 +++ examples/rsc-poc-mongo/scripts/stress.k6.js | 212 +++ .../src/components/create-event-form.tsx | 58 + .../src/components/diag-panel.tsx | 81 + .../src/components/parallel-reads-page.tsx | 107 ++ examples/rsc-poc-mongo/src/lib/db.ts | 201 +++ examples/rsc-poc-mongo/src/lib/diag.ts | 161 ++ .../rsc-poc-mongo/src/prisma/contract.d.ts | 1080 +++++++++++++ .../rsc-poc-mongo/src/prisma/contract.json | 1401 +++++++++++++++++ .../server-components/event-type-stats.tsx | 77 + .../server-components/orders-with-user.tsx | 60 + .../src/server-components/product-list.tsx | 57 + .../server-components/products-by-search.tsx | 91 ++ .../src/server-components/search-events.tsx | 73 + .../test/concurrency-invariants.test.ts | 321 ++++ examples/rsc-poc-mongo/tsconfig.json | 29 + examples/rsc-poc-mongo/turbo.json | 17 + examples/rsc-poc-mongo/vitest.config.ts | 13 + pnpm-lock.yaml | 85 + 33 files changed, 5387 insertions(+) create mode 100644 examples/rsc-poc-mongo/.env.example create mode 100644 examples/rsc-poc-mongo/.gitignore create mode 100644 examples/rsc-poc-mongo/README.md create mode 100644 examples/rsc-poc-mongo/app/actions.ts create mode 100644 examples/rsc-poc-mongo/app/diag/route.ts create mode 100644 examples/rsc-poc-mongo/app/globals.css create mode 100644 examples/rsc-poc-mongo/app/layout.tsx create mode 100644 examples/rsc-poc-mongo/app/page.tsx create mode 100644 examples/rsc-poc-mongo/app/stress/pool-pressure/page.tsx create mode 100644 examples/rsc-poc-mongo/biome.jsonc create mode 100644 examples/rsc-poc-mongo/next.config.js create mode 100644 examples/rsc-poc-mongo/package.json create mode 100644 examples/rsc-poc-mongo/prisma-next.config.ts create mode 100644 examples/rsc-poc-mongo/prisma/contract.prisma create mode 100644 examples/rsc-poc-mongo/scripts/seed.ts create mode 100644 examples/rsc-poc-mongo/scripts/stress.k6.js create mode 100644 examples/rsc-poc-mongo/src/components/create-event-form.tsx create mode 100644 examples/rsc-poc-mongo/src/components/diag-panel.tsx create mode 100644 examples/rsc-poc-mongo/src/components/parallel-reads-page.tsx create mode 100644 examples/rsc-poc-mongo/src/lib/db.ts create mode 100644 examples/rsc-poc-mongo/src/lib/diag.ts create mode 100644 examples/rsc-poc-mongo/src/prisma/contract.d.ts create mode 100644 examples/rsc-poc-mongo/src/prisma/contract.json create mode 100644 examples/rsc-poc-mongo/src/server-components/event-type-stats.tsx create mode 100644 examples/rsc-poc-mongo/src/server-components/orders-with-user.tsx create mode 100644 examples/rsc-poc-mongo/src/server-components/product-list.tsx create mode 100644 examples/rsc-poc-mongo/src/server-components/products-by-search.tsx create mode 100644 examples/rsc-poc-mongo/src/server-components/search-events.tsx create mode 100644 examples/rsc-poc-mongo/test/concurrency-invariants.test.ts create mode 100644 examples/rsc-poc-mongo/tsconfig.json create mode 100644 examples/rsc-poc-mongo/turbo.json create mode 100644 examples/rsc-poc-mongo/vitest.config.ts diff --git a/examples/rsc-poc-mongo/.env.example b/examples/rsc-poc-mongo/.env.example new file mode 100644 index 0000000000..00317aba17 --- /dev/null +++ b/examples/rsc-poc-mongo/.env.example @@ -0,0 +1,22 @@ +# Copy to .env and set a MongoDB connection string. +# +# For local development, any MongoDB 6+ instance works. The Mongo driver +# uses its own internal connection pool (no external pooler needed). +# Easiest paths: +# +# Option 1 — Docker (replica set not required for the PoC): +# +# docker run --rm -d --name rsc-poc-mongo -p 27018:27017 mongo:7 +# +# Option 2 — mongodb-memory-server via a tiny script (no Docker). +# +# Then: +# +# pnpm emit # generate contract.json + contract.d.ts +# pnpm seed # populate sample data +# pnpm dev # start Next.js +# +# The database name defaults to `rsc-poc-mongo`. Override via MONGODB_DB +# if you want to share a MongoDB instance across multiple projects. +DB_URL=mongodb://localhost:27018 +MONGODB_DB=rsc-poc-mongo diff --git a/examples/rsc-poc-mongo/.gitignore b/examples/rsc-poc-mongo/.gitignore new file mode 100644 index 0000000000..2c0b4101c3 --- /dev/null +++ b/examples/rsc-poc-mongo/.gitignore @@ -0,0 +1,7 @@ +next-env.d.ts +.next +dist +node_modules +*.tsbuildinfo +.env +.env.local diff --git a/examples/rsc-poc-mongo/README.md b/examples/rsc-poc-mongo/README.md new file mode 100644 index 0000000000..7f6df489a8 --- /dev/null +++ b/examples/rsc-poc-mongo/README.md @@ -0,0 +1,297 @@ +# rsc-poc-mongo + +Next.js 16 App Router proof-of-concept for **Prisma Next Mongo runtime +behavior under RSC concurrent rendering**. Paired with `rsc-poc-postgres`; +together they cover VP3 of the WS3 runtime-pipeline milestone (Linear: +[TML-2164][t]). + +See `projects/rsc-concurrency-safety/plan.md` for the full project plan, +including hypotheses H1–H5 and acceptance criteria. + +[t]: https://linear.app/prisma-company/issue/TML-2164/rsc-concurrency-safety-poc + +## What this app exists to probe + +The Postgres app probes hypotheses H1 (ORM Collection cache race), H2 +(redundant cold-start marker reads under `onFirstUse`), H3 (per-query +verification under `always`), and H4 (pool pressure). This Mongo app is +the **baseline** that makes the Postgres-side findings stand out as +SQL-runtime-specific rather than inherent to Prisma Next's architecture. + +Specifically: + +- **`MongoRuntimeImpl` has no verification state.** No `verified` flag, + no `startupVerified`, no marker reads. Nothing for H2 or H3 to + manifest as — that's **hypothesis H5** in the project plan. + +- **`mongoOrm()` eagerly builds all collections at init time.** No + lazy-cache race to worry about, so H1 has no Mongo analogue either. + +- **The Mongo driver's connection pool is fundamentally different** from + pg's. `MongoClient` multiplexes commands over a small number of wire + connections rather than borrowing a connection per query. The pool + pressure observations on this side are not directly comparable to the + Postgres ones, and that contrast is useful in its own right. + +The Mongo app's deliverables are therefore: + +- Five parallel Server Components (same conceptual shape as the + Postgres app) to confirm RSC concurrent rendering works cleanly on + the Mongo side. +- A `/stress/pool-pressure` route for H4-adjacent observations with a + small `maxPoolSize`. +- Diagnostic counters that are **different from the Postgres side by + design** — no `markerReads`, no `verifyMode`. +- A test suite that pins **"no H2/H3 analogue exists"** as an + invariant, so a future change that accidentally introduces + verification on the Mongo runtime would fail the test. + +## Status + +Five parallel Server Components, one Server Action, `/stress/pool-pressure` +route, k6 scripts, and the concurrency invariant test suite are all +implemented and verified end-to-end against both a local MongoDB and +`mongodb-memory-server` in CI. + +## Prerequisites + +- Node.js ≥ 24 (see root `package.json` `engines.node`) +- pnpm (see root `package.json` `packageManager`) +- A MongoDB 6+ instance. The PoC uses plain commands with no + transactions, so a standalone `mongo:7` container is sufficient: + + ```sh + docker run --rm -d --name rsc-poc-mongo -p 27020:27017 mongo:7 + ``` + +- [k6](https://k6.io/) for running the stress scripts (install via + `brew install k6` on macOS). Not needed for `pnpm dev`. + +## Getting started + +```sh +cp .env.example .env # then edit DB_URL if needed +pnpm install # from the repo root +pnpm --filter rsc-poc-mongo emit # generate contract.json + contract.d.ts +pnpm --filter rsc-poc-mongo seed # populate sample data +pnpm --filter rsc-poc-mongo dev # start Next.js on :3000 +``` + +Open http://localhost:3000 and watch the diagnostics panel at the bottom +of the page for command and connection counters. + +## Routes + +| Route | Purpose | Pool | +|--------------------------|---------------------------------------------------------|------------------------| +| `/` | Five parallel Server Components (varied shapes). | `maxPoolSize: 100` | +| `/stress/pool-pressure` | Same page, pinned to `maxPoolSize: 5` to probe H4. | `maxPoolSize: 5` | +| `/diag` | JSON snapshot of in-process counters. | — | + +All three routes are live. There is no `/stress/always` analogue because +`MongoRuntimeImpl` has no `verifyMode` dimension — that's the point of +this app. + +## The five Server Components + +Rendered in parallel on `/`, each wrapped in its own `` so one +slow component doesn't block the others: + +1. **``** — ORM `orderBy(...).take(10).all()`. Baseline + ORM read path, equivalent to the Postgres app's ``. +2. **``** — ORM `include('user').take(5).all()`. + Exercises the multi-query include dispatch. Equivalent to + `` on the Postgres side. +3. **``** — `db.query.from('products').match(...)` + pipeline via `runtime.execute(plan)`. Drops to the query-builder + path, equivalent to ``. +4. **``** — `db.query.from('events').group(...)` + aggregate pipeline. Equivalent to ``. +5. **``** — ORM `events.variant('SearchEvent').all()`. + Exercises the polymorphism discriminator path; no direct analogue + on the Postgres side (polymorphism is modeled differently there). + +Plus **``** (client component) + `createEventAction` +(Server Action) — one smoke-level mutation (inserting a `SearchEvent`) +to confirm reads and writes can coexist on the shared Mongo runtime. +Not exercised by k6; test by hand from the browser. + +## Observed behavior + +Numbers below come from single runs against a local `mongo:7` container; +they're illustrative, not benchmarks. + +### Cold start (`/`, first request) + +``` +commandsStarted: 5, commandsSucceeded: 5, commandsFailed: 0 +connectionsCheckedOut: 5, connectionsCheckedIn: 5 +connectionsCreated: 4, connectionsClosed: 0 +``` + +**Exactly 5 commands** for 5 parallel Server Components — one per +component. No per-command verification round-trip, no marker reads. +**This is what makes the contrast with the Postgres app's H2 behavior +observable**: on Postgres, the same cold-start page showed 5 marker +reads *in addition to* the 5 data queries. On Mongo, there's just the +5 data queries. H5 confirmed. + +### Baseline — `/` @ 10 VUs × 30s (`maxPoolSize: 100`) + +``` +iterations: 14,003 over 30s (~467 req/s) +commandsΔ: 70,015 # exactly 5 × iterations — no multiplier +failedΔ: 0 +checkOutsΔ: 70,015 +checkInsΔ: 70,015 # balanced +tcpCreatedΔ: 0 # pool already warm; connections reused +``` + +Compared to the Postgres baseline (8,850 iters @ ~295 req/s, 53,100 +acquires = 6 per request), the Mongo app is ~60% faster and does fewer +operations per page. The "6 vs 5" difference per request is the marker +read the Postgres runtime issues that the Mongo runtime doesn't. + +### Pool pressure — `/stress/pool-pressure` ramp 1→100 VUs × 50s (`maxPoolSize: 5`) + +``` +iterations: 17,503 over 50s (~350 req/s) +commandsΔ: 87,515 # exactly 5 × iterations +failedΔ: 0 # no waitQueueTimeoutMS breaches +checkOutsΔ: 87,515 +checkInsΔ: 87,515 # still balanced at 100 VUs on maxPoolSize: 5 +tcpCreatedΔ: 1 # pool grew to max during ramp +``` + +With the PoC's tiny dataset, the Mongo driver sustains 100 VUs on a +5-slot pool without timeouts. Commands multiplex over the connections +rather than queueing for distinct ones — a useful contrast to the +Postgres model where each query exclusively holds a pool connection for +its lifetime. + +## Stress scripts + +```sh +pnpm stress:baseline # 10 VUs × 30s against / +pnpm stress:pool-pressure # ramp 1 → 100 VUs with small pool +``` + +There is no `spike` scenario on the Mongo side. On the Postgres side, +`spike` hits `/stress/always` to exercise the `always`-mode invariant. +No such mode exists on the Mongo runtime, so the scenario has nothing +to stress. + +## How the singleton works + +`src/lib/db.ts` pins the Mongo runtime to `globalThis` via a +`Symbol.for(...)` key. Same pattern as the Postgres app: survives +Next.js dev-mode HMR in development; in production it collapses to a +regular module-level singleton per Node process. + +Each unique `poolMax` gets its own entry in the registry, so `/` (pool +default) and `/stress/pool-pressure` (pool 5) never share a runtime or +a MongoClient. + +`getDb()` is async on the Mongo side (unlike the Postgres app's +synchronous `getDb()`) because constructing a MongoClient requires +awaiting `client.connect()` before the runtime can serve requests. +Server Components that call this suspend on the first request and +resolve from the cached entry thereafter. + +## How the diagnostics work + +Unlike the Postgres app, there's no `InstrumentedPool` subclass. The +Mongo driver owns its connection pool inside `MongoClient` and that +class isn't designed to be subclassed. Instead, we attach listeners for +the documented `CMAP` (connection monitoring) and `APM` (command +monitoring) events **before** `client.connect()`: + +- `commandStarted` / `commandSucceeded` / `commandFailed` — one MongoDB + command per event. The Mongo analogue of a pg query. +- `connectionCheckedOut` / `connectionCheckedIn` — a pool connection + borrowed for the duration of a command. +- `connectionCreated` / `connectionClosed` — underlying TCP connections + opened and closed. + +Listeners push counts into `src/lib/diag.ts` (`globalThis`-backed, same +pattern as the Postgres app's diag). The `` Server +Component reads a snapshot at page bottom; `/diag` exposes the same data +as JSON. + +Deliberately **no marker-reads counter** — the Mongo runtime doesn't +issue them, and a counter that's always zero muddles the contrast with +the Postgres side. Omitting it keeps the snapshot shape honest. + +## Tests + +```sh +pnpm --filter rsc-poc-mongo test +``` + +Unlike the Postgres invariant test (which requires `DATABASE_URL` +because `@prisma/dev` rejects concurrent connections), the Mongo tests +run standalone via `mongodb-memory-server`. No external database is +needed; CI runs them as-is. + +The test suite (`test/concurrency-invariants.test.ts`) pins: + +- **H5 (no marker reads)** — K concurrent queries issue **exactly K** + commands through the runtime, with no verification multiplier. + Covered for K ∈ {1, 5, 50} plus a cold-start burst case. +- **Balance invariants** — `connectionsCheckedOut === connectionsCheckedIn` + across single queries, cold bursts, default-pool contention, and + small-pool contention (K=50 on `maxPoolSize: 5`). +- **Cumulative invariants** — repeated bursts keep the per-command + accounting linear: K × BURSTS commands, balanced check-outs/ins. + +If a future change accidentally introduces a verification round-trip on +the Mongo runtime, the "exactly K commands" assertion fails immediately +and makes the regression visible. + +## Layout + +``` +app/ Next.js App Router entrypoints + layout.tsx Root layout + page.tsx Home (five parallel RSC, default pool) + globals.css Minimal dark theme + actions.ts Server Action: createEventAction + diag/route.ts /diag JSON handler + stress/pool-pressure/page.tsx +prisma/ + contract.prisma PSL schema (reused from retail-store) +src/ + components/ + create-event-form.tsx Client: Server Action form + diag-panel.tsx Server Component: counter snapshot + parallel-reads-page.tsx Shared body for / and /stress/pool-pressure + lib/ + db.ts globalThis-scoped Mongo runtime singleton + diag.ts In-process counter registry + prisma/ + contract.json Generated + contract.d.ts Generated + server-components/ + event-type-stats.tsx + orders-with-user.tsx + product-list.tsx + products-by-search.tsx + search-events.tsx +scripts/ + seed.ts Populate sample data + stress.k6.js k6 stress scenarios (baseline, pool-pressure) +test/ + concurrency-invariants.test.ts +prisma-next.config.ts +next.config.js +package.json +tsconfig.json +vitest.config.ts +``` + +## Related + +- Project plan: `projects/rsc-concurrency-safety/plan.md` +- Framework integration analysis §"Hard problem 2": + `docs/reference/framework-integration-analysis.md` +- Companion Postgres app: `examples/rsc-poc-postgres/` diff --git a/examples/rsc-poc-mongo/app/actions.ts b/examples/rsc-poc-mongo/app/actions.ts new file mode 100644 index 0000000000..2fc0b876b2 --- /dev/null +++ b/examples/rsc-poc-mongo/app/actions.ts @@ -0,0 +1,70 @@ +'use server'; + +import { randomUUID } from 'node:crypto'; +import { revalidatePath } from 'next/cache'; +import { getDb } from '../src/lib/db'; + +/** + * Server Action #1 — create a search event. + * + * The ticket's scope is read-focused, but we agreed one smoke-level + * Server Action belongs in the PoC to prove mutations-alongside- + * concurrent-reads don't explode on either family. This is the Mongo + * analogue of the Postgres app's `createPostAction`. + * + * Path exercised: + * + * - Resolves the process-scoped Mongo runtime singleton (same one the + * page's five Server Components share). + * - Issues `db.orm.events.variant('SearchEvent').create(...)`, which + * the Mongo ORM translates into an `insertOne` wire command, + * auto-injecting the `type` discriminator ('search') based on the + * variant. + * - On success, calls `revalidatePath('/')` so the subsequent render + * picks up the new row in `` and + * ``. + * + * Intentionally NOT exercised: the k6 stress scripts don't invoke this + * action. Server Actions in Next.js are serialized per request, and we + * care about read-side concurrency here. The action is reachable from + * the `` client component on `/` for manual smoke + * testing. + * + * No pre-conditions: the action inserts a `SearchEvent` with a + * synthetic `userId` and `sessionId`, so it works even on an empty + * database. Unlike the Postgres action (which needs at least one User + * to satisfy a foreign key), MongoDB has no referential constraints + * to trip over here. + */ + +export interface CreateEventState { + readonly status: 'idle' | 'ok' | 'error'; + readonly message?: string; +} + +export async function createEventAction( + _prev: CreateEventState, + formData: FormData, +): Promise { + const query = formData.get('query'); + if (typeof query !== 'string' || query.trim().length === 0) { + return { status: 'error', message: 'Search query is required.' }; + } + + const db = await getDb(); + + try { + await db.orm.events.variant('SearchEvent').create({ + userId: `rsc-poc-${randomUUID().slice(0, 8)}`, + sessionId: `rsc-poc-session-${randomUUID().slice(0, 8)}`, + timestamp: new Date(), + query: query.trim(), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { status: 'error', message }; + } + + revalidatePath('/'); + return { status: 'ok', message: `Created search event for "${query.trim()}".` }; +} diff --git a/examples/rsc-poc-mongo/app/diag/route.ts b/examples/rsc-poc-mongo/app/diag/route.ts new file mode 100644 index 0000000000..67838d5aa3 --- /dev/null +++ b/examples/rsc-poc-mongo/app/diag/route.ts @@ -0,0 +1,74 @@ +import { NextResponse } from 'next/server'; +import { getClient } from '../../src/lib/db'; +import { snapshotAll } from '../../src/lib/diag'; + +/** + * `/diag` — JSON snapshot of in-process diagnostic counters for the + * Mongo RSC concurrency PoC. + * + * Mongo counterpart to the Postgres app's `/diag`. Unlike the + * `` Server Component (which races sibling Suspense + * boundaries on the home page and may report stale numbers within a + * single render), this endpoint is read *after* any page render is + * complete, so its values are always current relative to the request + * that precedes it. + * + * Used by: + * + * - k6 stress scripts — called after a scenario finishes to record + * final counter values for the findings write-up. + * - Manual inspection — `curl http://localhost:3000/diag | jq`. + * + * Counters are cumulative since process start. To compare two points + * in time, read `/diag`, do some work, read `/diag` again, and + * subtract. + * + * Per-snapshot shape differs from the Postgres app: there's no + * `markerReads` field (the Mongo runtime doesn't verify), no + * `verifyMode` key (there's nothing to toggle). Instead we report + * command counts, connection check-out/in counts, and TCP + * create/close counts — the observables the `MongoClient` emits via + * its CMAP and APM event listeners (wired up in `lib/db.ts`). + */ +export const dynamic = 'force-dynamic'; + +interface DiagPayload { + readonly timestampMs: number; + readonly snapshots: ReadonlyArray<{ + readonly poolMax: number; + readonly commandsStarted: number; + readonly commandsSucceeded: number; + readonly commandsFailed: number; + readonly connectionsCheckedOut: number; + readonly connectionsCheckedIn: number; + readonly connectionsCreated: number; + readonly connectionsClosed: number; + readonly client: 'connected' | 'not-constructed'; + }>; +} + +export function GET(): Response { + const snapshots = snapshotAll().map((snap) => { + const client = getClient({ poolMax: snap.poolMax }); + return { + poolMax: snap.poolMax, + commandsStarted: snap.commandsStarted, + commandsSucceeded: snap.commandsSucceeded, + commandsFailed: snap.commandsFailed, + connectionsCheckedOut: snap.connectionsCheckedOut, + connectionsCheckedIn: snap.connectionsCheckedIn, + connectionsCreated: snap.connectionsCreated, + connectionsClosed: snap.connectionsClosed, + client: (client === undefined ? 'not-constructed' : 'connected') as + | 'connected' + | 'not-constructed', + }; + }); + + const payload: DiagPayload = { + timestampMs: Date.now(), + snapshots, + }; + + return NextResponse.json(payload); +} diff --git a/examples/rsc-poc-mongo/app/globals.css b/examples/rsc-poc-mongo/app/globals.css new file mode 100644 index 0000000000..7bb7eb407c --- /dev/null +++ b/examples/rsc-poc-mongo/app/globals.css @@ -0,0 +1,196 @@ +:root { + --bg: #0b0d10; + --fg: #e6e8eb; + --muted: #8a929b; + --accent: #6ea8fe; + --border: #1f242b; + --card: #11151a; + --success: #4ade80; + --warn: #fbbf24; + --error: #f87171; + --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + --sans: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--fg); + font-family: var(--sans); + font-size: 14px; + line-height: 1.5; +} + +main { + max-width: 1100px; + margin: 0 auto; + padding: 2rem 1.5rem 6rem; +} + +h1 { + font-size: 1.4rem; + font-weight: 600; + margin: 0 0 0.25rem; +} + +h2 { + font-size: 1rem; + font-weight: 600; + margin: 0 0 0.5rem; + color: var(--fg); +} + +p { + margin: 0 0 0.75rem; +} + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +code, +pre { + font-family: var(--mono); + font-size: 0.85em; +} + +pre { + background: var(--card); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.75rem 1rem; + overflow-x: auto; + margin: 0 0 0.75rem; +} + +.muted { + color: var(--muted); +} + +.grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); +} + +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem 1.25rem; +} + +.card h2 { + font-size: 0.9rem; + margin-bottom: 0.5rem; +} + +.card ul { + margin: 0; + padding-left: 1.2rem; +} + +.card li { + margin-bottom: 0.25rem; +} + +.badge { + display: inline-block; + padding: 0.1rem 0.5rem; + border-radius: 999px; + background: var(--border); + color: var(--fg); + font-size: 0.75rem; + font-family: var(--mono); + margin-right: 0.25rem; +} + +.badge.ok { + background: rgba(74, 222, 128, 0.15); + color: var(--success); +} + +.badge.warn { + background: rgba(251, 191, 36, 0.15); + color: var(--warn); +} + +.badge.err { + background: rgba(248, 113, 113, 0.15); + color: var(--error); +} + +.diag-panel { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: var(--card); + border-top: 1px solid var(--border); + padding: 0.75rem 1.5rem; + font-family: var(--mono); + font-size: 0.8rem; + z-index: 100; +} + +.diag-panel .row { + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + align-items: center; +} + +.diag-panel .label { + color: var(--muted); + margin-right: 0.35rem; +} + +.diag-panel .value { + color: var(--fg); +} + +form { + display: flex; + gap: 0.5rem; + align-items: center; + margin: 0 0 1rem; +} + +input[type="text"], +input[type="email"] { + background: var(--card); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.4rem 0.6rem; + font-family: var(--sans); + font-size: 0.9rem; +} + +button { + background: var(--accent); + color: #0b0d10; + border: 0; + border-radius: 6px; + padding: 0.4rem 0.9rem; + font-family: var(--sans); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; +} + +button:hover { + opacity: 0.9; +} diff --git a/examples/rsc-poc-mongo/app/layout.tsx b/examples/rsc-poc-mongo/app/layout.tsx new file mode 100644 index 0000000000..e0ef5080c8 --- /dev/null +++ b/examples/rsc-poc-mongo/app/layout.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from 'react'; +import './globals.css'; + +export const metadata = { + title: 'RSC Concurrency PoC — Mongo', + description: + 'Next.js 16 App Router PoC for Prisma Next runtime behavior under RSC concurrent rendering (Mongo family).', +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + +
{children}
+ + + ); +} diff --git a/examples/rsc-poc-mongo/app/page.tsx b/examples/rsc-poc-mongo/app/page.tsx new file mode 100644 index 0000000000..f7e07fcc80 --- /dev/null +++ b/examples/rsc-poc-mongo/app/page.tsx @@ -0,0 +1,32 @@ +import { ParallelReadsPage } from '../src/components/parallel-reads-page'; + +export const dynamic = 'force-dynamic'; + +/** + * Home route — default `poolMax` (100, the Mongo driver default). + * + * Mongo counterpart to the Postgres app's `/`. There is no `verifyMode` + * dimension here because `MongoRuntimeImpl` has no verification state + * (hypothesis H5 in the project plan) — that's the whole reason the + * Mongo app exists: it's the baseline that makes the Postgres-side + * H2/H3 behavior stand out as SQL-runtime-specific rather than + * inherent to the PoC's architecture. + * + * See `src/components/parallel-reads-page.tsx` for the shared body + * used by `/` and `/stress/pool-pressure`. + */ +export default function Home() { + return ( + + Five parallel Server Components sharing one Prisma Next Mongo runtime. Default driver pool + (maxPoolSize: 100). No verify-mode analogue exists on the Mongo side — the + runtime has no verification state, which is the whole point of running this app alongside + the Postgres one. + + } + /> + ); +} diff --git a/examples/rsc-poc-mongo/app/stress/pool-pressure/page.tsx b/examples/rsc-poc-mongo/app/stress/pool-pressure/page.tsx new file mode 100644 index 0000000000..d09cd17394 --- /dev/null +++ b/examples/rsc-poc-mongo/app/stress/pool-pressure/page.tsx @@ -0,0 +1,63 @@ +import { ParallelReadsPage } from '../../../src/components/parallel-reads-page'; + +export const dynamic = 'force-dynamic'; + +/** + * `/stress/pool-pressure` — same page body as `/`, but with a + * deliberately small Mongo driver pool (`maxPoolSize: 5`). + * + * Mongo counterpart to the Postgres app's `/stress/pool-pressure`. + * Purpose: characterize hypothesis H4 — what happens when the number + * of concurrent command issuers (5 Server Components × N concurrent + * requests) meets or exceeds pool capacity. + * + * Unlike the pg pool, MongoClient's internal pool doesn't require a + * `connect()`-style borrow for every command — the driver multiplexes + * commands over a smaller number of wire connections. But under + * sustained concurrent load, the `waitQueueTimeoutMS` (set to 5s in + * `lib/db.ts`) bounds how long a command will wait for a pooled + * connection before failing. That's the knob this route is set up to + * exercise. + * + * Expected observations under load (measured by the k6 + * `pool_pressure` scenario): + * + * - `connectionsCheckedOut` and `connectionsCheckedIn` stay balanced + * under moderate load. + * - Under high enough load, `commandsFailed` grows as waits exceed + * `waitQueueTimeoutMS`. `commandsStarted` still counts those + * attempts; the failed/succeeded split is the visible symptom. + * - `connectionsCreated` climbs to (and stays bounded by) the pool + * max, then plateaus — Mongo grows its pool up to the configured + * ceiling on demand. + * + * This is a **sizing/liveness** observation, not a safety bug. The + * PoC's stop condition doesn't require fixing pool sizing — that's + * May (pool-sizing guidance is an explicit non-goal per the plan). + * + * ## Why a separate runtime + * + * The `lib/db` registry keys by `poolMax`, so this route's + * `poolMax: 5` singleton is distinct from the default `poolMax: 100` + * used by `/`. They never share a MongoClient; counters remain + * apples-to-apples when the findings doc compares them. + */ +const POOL_MAX = 5; + +export default function StressPoolPressurePage() { + return ( + + Same five parallel Server Components as /, but the shared Mongo driver pool + is pinned to maxPoolSize: {POOL_MAX}. One render already consumes most of the + pool; concurrent renders queue for connections and may fail on{' '} + waitQueueTimeoutMS under sustained load. This is the route that probes + hypothesis H4 (pool pressure — sizing concern, not a safety bug). + + } + /> + ); +} diff --git a/examples/rsc-poc-mongo/biome.jsonc b/examples/rsc-poc-mongo/biome.jsonc new file mode 100644 index 0000000000..5d7b5be3c9 --- /dev/null +++ b/examples/rsc-poc-mongo/biome.jsonc @@ -0,0 +1,6 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", + // Example apps use a standalone biome config rather than extending the root + // config, to keep example-specific settings self-contained. + "extends": "//" +} diff --git a/examples/rsc-poc-mongo/next.config.js b/examples/rsc-poc-mongo/next.config.js new file mode 100644 index 0000000000..a95d40dbf7 --- /dev/null +++ b/examples/rsc-poc-mongo/next.config.js @@ -0,0 +1,9 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + // The Prisma Next Mongo runtime depends on `mongodb`, a Node-native driver + // that doesn't play nicely with Next.js's bundler. Keeping it external + // ensures it's loaded from node_modules at runtime on the server. + serverExternalPackages: ['mongodb', '@prisma-next/mongo-runtime', '@prisma-next/driver-mongo'], +}; + +export default nextConfig; diff --git a/examples/rsc-poc-mongo/package.json b/examples/rsc-poc-mongo/package.json new file mode 100644 index 0000000000..95c15b67e2 --- /dev/null +++ b/examples/rsc-poc-mongo/package.json @@ -0,0 +1,52 @@ +{ + "name": "rsc-poc-mongo", + "private": true, + "type": "module", + "engines": { + "node": ">=24" + }, + "scripts": { + "emit": "prisma-next contract emit", + "emit:check": "pnpm emit && git diff --exit-code src/prisma/contract.json src/prisma/contract.d.ts", + "seed": "tsx scripts/seed.ts", + "dev": "next dev", + "build": "next build", + "start": "next start", + "stress:baseline": "k6 run scripts/stress.k6.js -e SCENARIO=baseline", + "stress:pool-pressure": "k6 run scripts/stress.k6.js -e SCENARIO=pool_pressure", + "test": "vitest run --config vitest.config.ts", + "typecheck": "tsc --project tsconfig.json --noEmit", + "lint": "biome check . --error-on-warnings" + }, + "dependencies": { + "@prisma-next/adapter-mongo": "workspace:*", + "@prisma-next/contract": "workspace:*", + "@prisma-next/driver-mongo": "workspace:*", + "@prisma-next/middleware-telemetry": "workspace:*", + "@prisma-next/mongo-contract": "workspace:*", + "@prisma-next/mongo-orm": "workspace:*", + "@prisma-next/mongo-query-ast": "workspace:*", + "@prisma-next/mongo-query-builder": "workspace:*", + "@prisma-next/mongo-runtime": "workspace:*", + "@prisma-next/mongo-value": "workspace:*", + "mongodb": "catalog:", + "next": "^16.1.7", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@prisma-next/cli": "workspace:*", + "@prisma-next/family-mongo": "workspace:*", + "@prisma-next/mongo-contract-psl": "workspace:*", + "@prisma-next/target-mongo": "workspace:*", + "@prisma-next/test-utils": "workspace:*", + "@prisma-next/tsconfig": "workspace:*", + "@types/node": "catalog:", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "mongodb-memory-server": "catalog:", + "tsx": "^4.19.2", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/examples/rsc-poc-mongo/prisma-next.config.ts b/examples/rsc-poc-mongo/prisma-next.config.ts new file mode 100644 index 0000000000..4ff4cf6010 --- /dev/null +++ b/examples/rsc-poc-mongo/prisma-next.config.ts @@ -0,0 +1,18 @@ +import mongoAdapter from '@prisma-next/adapter-mongo/control'; +import { defineConfig } from '@prisma-next/cli/config-types'; +import mongoDriver from '@prisma-next/driver-mongo/control'; +import { mongoFamilyDescriptor, mongoTargetDescriptor } from '@prisma-next/family-mongo/control'; +import { mongoContract } from '@prisma-next/mongo-contract-psl/provider'; + +export default defineConfig({ + family: mongoFamilyDescriptor, + target: mongoTargetDescriptor, + adapter: mongoAdapter, + driver: mongoDriver, + contract: mongoContract('./prisma/contract.prisma', { + output: 'src/prisma/contract.json', + }), + db: { + connection: process.env['DB_URL'] ?? 'mongodb://localhost:27017/rsc-poc-mongo', + }, +}); diff --git a/examples/rsc-poc-mongo/prisma/contract.prisma b/examples/rsc-poc-mongo/prisma/contract.prisma new file mode 100644 index 0000000000..c03cbe0247 --- /dev/null +++ b/examples/rsc-poc-mongo/prisma/contract.prisma @@ -0,0 +1,151 @@ +type Price { + amount Float + currency String +} + +type Image { + url String +} + +type Address { + streetAndNumber String + city String + postalCode String + country String +} + +type CartItem { + productId String + name String + brand String + amount Int + price Price + image Image +} + +type OrderLineItem { + productId String + name String + brand String + amount Int + price Price + image Image +} + +type StatusEntry { + status String + timestamp DateTime +} + +type InvoiceLineItem { + name String + amount Int + unitPrice Float + lineTotal Float +} + +model Product { + id ObjectId @id @map("_id") + name String + brand String + code String + description String + masterCategory String + subCategory String + articleType String + price Price + image Image + embedding Float[]? + @@map("products") + @@textIndex([name, description], weights: "{\"name\": 10, \"description\": 1}") + @@index([brand, subCategory]) + @@index([code], type: "hashed") +} + +model User { + id ObjectId @id @map("_id") + name String + email String @unique + address Address? + carts Cart[] + orders Order[] + @@map("users") +} + +model Cart { + id ObjectId @id @map("_id") + userId ObjectId + items CartItem[] + user User @relation(fields: [userId], references: [id]) + @@map("carts") + @@unique([userId]) +} + +model Order { + id ObjectId @id @map("_id") + userId ObjectId + items OrderLineItem[] + shippingAddress String + type String + statusHistory StatusEntry[] + user User @relation(fields: [userId], references: [id]) + invoices Invoice[] + @@map("orders") + @@index([userId]) +} + +model Location { + id ObjectId @id @map("_id") + name String + streetAndNumber String + city String + postalCode String + country String + @@map("locations") + @@index([city, country], collationLocale: "en", collationStrength: 2) +} + +model Invoice { + id ObjectId @id @map("_id") + orderId ObjectId + items InvoiceLineItem[] + subtotal Float + tax Float + total Float + issuedAt DateTime + order Order @relation(fields: [orderId], references: [id]) + @@map("invoices") + @@index([orderId]) + @@index([issuedAt(sort: Desc)], sparse: true) +} + +model Event { + id ObjectId @id @map("_id") + userId String + sessionId String + type String + timestamp DateTime + @@map("events") + @@discriminator(type) + @@index([userId, timestamp(sort: Desc)]) + @@index([timestamp], expireAfterSeconds: 7776000) +} + +model ViewProductEvent { + productId String + subCategory String + brand String + exitMethod String? + @@base(Event, "view-product") +} + +model SearchEvent { + query String + @@base(Event, "search") +} + +model AddToCartEvent { + productId String + brand String + @@base(Event, "add-to-cart") +} diff --git a/examples/rsc-poc-mongo/scripts/seed.ts b/examples/rsc-poc-mongo/scripts/seed.ts new file mode 100644 index 0000000000..d4ad94f4da --- /dev/null +++ b/examples/rsc-poc-mongo/scripts/seed.ts @@ -0,0 +1,248 @@ +/** + * Database Seed Script + * + * Populates the Mongo PoC database with sample data spanning all the + * collections the five Server Components read from: products, users, + * orders (with user includes), and events (with polymorphic variants). + * + * Run with: pnpm seed + * + * Prerequisites: + * - DB_URL environment variable set + * - Contract emitted (run `pnpm emit`) + * + * The dataset mirrors `retail-store/src/seed.ts` in shape (same schema, + * same realistic product catalog), trimmed to the fields the PoC's + * Server Components actually render. Intentionally not feature-complete + * (no vector embeddings, no invoices beyond one row) — the goal is just + * enough data that each of the five cards has something to display. + */ +import { existsSync } from 'node:fs'; +import { createMongoAdapter } from '@prisma-next/adapter-mongo'; +import { MongoDriverImpl } from '@prisma-next/driver-mongo'; +import { validateMongoContract } from '@prisma-next/mongo-contract'; +import { mongoOrm } from '@prisma-next/mongo-orm'; +import { createMongoRuntime } from '@prisma-next/mongo-runtime'; +import { MongoClient } from 'mongodb'; +import type { Contract } from '../src/prisma/contract.d'; +import contractJson from '../src/prisma/contract.json' with { type: 'json' }; + +if (existsSync('.env')) { + process.loadEnvFile('.env'); +} + +const productData = [ + { + name: 'Classic Oxford Shirt', + brand: 'Heritage', + code: 'HER-OXF-001', + description: 'Timeless button-down oxford shirt in crisp white cotton', + masterCategory: 'Apparel', + subCategory: 'Topwear', + articleType: 'Shirts', + price: { amount: 79.99, currency: 'USD' }, + }, + { + name: 'Linen Camp Collar Shirt', + brand: 'Heritage', + code: 'HER-LIN-002', + description: 'Relaxed linen camp collar shirt for warm weather', + masterCategory: 'Apparel', + subCategory: 'Topwear', + articleType: 'Shirts', + price: { amount: 89.99, currency: 'USD' }, + }, + { + name: 'Merino Crew Sweater', + brand: 'Heritage', + code: 'HER-MER-003', + description: 'Lightweight merino wool crew neck sweater', + masterCategory: 'Apparel', + subCategory: 'Topwear', + articleType: 'Sweaters', + price: { amount: 119.99, currency: 'USD' }, + }, + { + name: 'Graphic Tee - Mountain', + brand: 'UrbanEdge', + code: 'UE-TEE-010', + description: 'Soft cotton graphic tee with mountain print', + masterCategory: 'Apparel', + subCategory: 'Topwear', + articleType: 'T-Shirts', + price: { amount: 34.99, currency: 'USD' }, + }, + { + name: 'Performance Polo', + brand: 'UrbanEdge', + code: 'UE-POL-011', + description: 'Moisture-wicking performance polo for active days', + masterCategory: 'Apparel', + subCategory: 'Topwear', + articleType: 'Shirts', + price: { amount: 54.99, currency: 'USD' }, + }, + { + name: 'Denim Jacket', + brand: 'UrbanEdge', + code: 'UE-DEN-012', + description: 'Classic medium-wash denim jacket', + masterCategory: 'Apparel', + subCategory: 'Topwear', + articleType: 'Jackets', + price: { amount: 99.99, currency: 'USD' }, + }, + { + name: 'Slim Fit Chinos', + brand: 'UrbanEdge', + code: 'UE-CHI-042', + description: 'Modern slim-fit chinos in navy with stretch comfort', + masterCategory: 'Apparel', + subCategory: 'Bottomwear', + articleType: 'Trousers', + price: { amount: 59.99, currency: 'USD' }, + }, + { + name: 'Leather Crossbody Bag', + brand: 'Craftsman', + code: 'CRA-BAG-017', + description: 'Hand-stitched leather crossbody bag with adjustable strap', + masterCategory: 'Accessories', + subCategory: 'Bags', + articleType: 'Handbags', + price: { amount: 149.99, currency: 'USD' }, + }, +] as const; + +async function main() { + const uri = process.env['DB_URL']; + if (!uri) { + console.error('DB_URL is required. Set it in your environment or .env file.'); + process.exit(1); + } + const dbName = process.env['MONGODB_DB'] ?? 'rsc-poc-mongo'; + + const { contract } = validateMongoContract(contractJson); + const client = new MongoClient(uri); + await client.connect(); + + try { + // Drop so `pnpm seed` is idempotent across runs. + await client.db(dbName).dropDatabase(); + + const driver = MongoDriverImpl.fromDb(client.db(dbName)); + const adapter = createMongoAdapter(); + const runtime = createMongoRuntime({ adapter, driver, contract, targetId: 'mongo' }); + const orm = mongoOrm({ contract, executor: runtime }); + + const products = await orm.products.createAll( + productData.map((p) => ({ + ...p, + image: { url: `/images/products/${p.code.toLowerCase()}.jpg` }, + embedding: null, + })), + ); + console.log(`Created ${products.length} products`); + + const alice = await orm.users.create({ + name: 'Alice Chen', + email: 'alice@example.com', + address: { + streetAndNumber: '123 Main St', + city: 'San Francisco', + postalCode: '94102', + country: 'US', + }, + }); + + const bob = await orm.users.create({ + name: 'Bob Kumar', + email: 'bob@example.com', + address: null, + }); + console.log(`Created users: ${alice.email}, ${bob.email}`); + + const p0 = products[0]; + const p2 = products[7]; + if (!p0 || !p2) throw new Error('Failed to seed products'); + + await orm.orders.create({ + userId: bob._id, + items: [ + { + productId: p2._id, + name: p2.name, + brand: p2.brand, + image: { url: `/images/products/${p2.code.toLowerCase()}.jpg` }, + amount: 1, + price: { amount: 149.99, currency: 'USD' }, + }, + ], + shippingAddress: '456 Oak Ave, Portland, OR 97201', + type: 'home', + statusHistory: [{ status: 'placed', timestamp: new Date('2026-03-01T10:00:00Z') }], + }); + + await orm.orders.create({ + userId: alice._id, + items: [ + { + productId: p0._id, + name: p0.name, + brand: p0.brand, + image: { url: `/images/products/${p0.code.toLowerCase()}.jpg` }, + amount: 2, + price: { amount: 79.99, currency: 'USD' }, + }, + ], + shippingAddress: '123 Main St, San Francisco, CA 94102', + type: 'home', + statusHistory: [{ status: 'placed', timestamp: new Date('2026-03-02T14:30:00Z') }], + }); + console.log('Created 2 orders'); + + await orm.events.variant('ViewProductEvent').create({ + userId: 'alice-session-1', + sessionId: 'sess-001', + timestamp: new Date('2026-03-01T09:00:00Z'), + productId: p0._id, + subCategory: 'Topwear', + brand: 'Heritage', + exitMethod: null, + }); + + await orm.events.variant('AddToCartEvent').create({ + userId: 'alice-session-1', + sessionId: 'sess-001', + timestamp: new Date('2026-03-01T09:05:00Z'), + productId: p0._id, + brand: 'Heritage', + }); + + await orm.events.variant('SearchEvent').create({ + userId: 'bob-session-1', + sessionId: 'sess-002', + timestamp: new Date('2026-03-01T09:30:00Z'), + query: 'leather bag', + }); + + await orm.events.variant('SearchEvent').create({ + userId: 'alice-session-2', + sessionId: 'sess-003', + timestamp: new Date('2026-03-02T10:00:00Z'), + query: 'oxford shirt', + }); + + console.log('Created 4 events (1 view, 1 add-to-cart, 2 searches)'); + console.log('Seed completed successfully!'); + + await runtime.close(); + } finally { + await client.close(); + } +} + +main().catch((err) => { + console.error('Error seeding database:', err); + process.exit(1); +}); diff --git a/examples/rsc-poc-mongo/scripts/stress.k6.js b/examples/rsc-poc-mongo/scripts/stress.k6.js new file mode 100644 index 0000000000..9c1c77ed28 --- /dev/null +++ b/examples/rsc-poc-mongo/scripts/stress.k6.js @@ -0,0 +1,212 @@ +/** + * k6 stress script for the Mongo RSC concurrency PoC. + * + * Mongo counterpart to `examples/rsc-poc-postgres/scripts/stress.k6.js`, + * trimmed to the two scenarios that apply on the Mongo side: + * + * - `baseline` — steady-state concurrent load against `/` with the + * default MongoClient pool. Establishes the "nothing is broken" + * baseline for H5 findings. + * + * - `pool_pressure` — ramping VUs against `/stress/pool-pressure` + * (which pins `maxPoolSize: 5` and `waitQueueTimeoutMS: 5000`). + * Characterizes H4 on the Mongo side: with a small pool and high + * enough concurrency, commands will eventually fail on queue + * timeout rather than queueing indefinitely. + * + * There is no `spike` / `/stress/always` scenario because + * `MongoRuntimeImpl` has no verify-mode dimension — the Postgres-side + * H3 invariant has no Mongo analogue. This is hypothesis H5 in the + * project plan, and the asymmetry is the whole point of running the + * Mongo app alongside the Postgres one. + * + * Run with (from the example's root): + * + * pnpm stress:baseline + * pnpm stress:pool-pressure + * + * Or directly: + * + * k6 run scripts/stress.k6.js -e SCENARIO=baseline + * + * Target defaults to `http://localhost:3000`. Override with + * `-e BASE_URL=...` if you're running Next on a different port. + * + * ## What the script does NOT do + * + * - Does not warm the runtime before measuring. Cold-start behavior + * is part of what's interesting; the first VU of each scenario + * observes it. + * + * - Does not assert correctness in-script. This script collects + * evidence; invariants belong in vitest integration tests. + * + * - Does not write machine-readable summary artifacts. k6's default + * end-of-run summary goes to stdout; redirect with `--summary-export` + * if needed. + */ +import { check, sleep } from 'k6'; +import http from 'k6/http'; +import { Counter, Trend } from 'k6/metrics'; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'; +const SCENARIO = __ENV.SCENARIO || 'baseline'; + +// Custom metrics so the end-of-run summary is legible at a glance. +const pageOkCount = new Counter('rsc_page_ok'); +const pageErrCount = new Counter('rsc_page_err'); +const pageLatency = new Trend('rsc_page_latency_ms', true); + +const SCENARIO_CONFIG = { + baseline: { + path: '/', + executor: 'constant-vus', + vus: 10, + duration: '30s', + description: 'baseline: / @ 10 VUs × 30s (default MongoClient pool)', + }, + pool_pressure: { + path: '/stress/pool-pressure', + executor: 'ramping-vus', + stages: [ + { duration: '10s', target: 10 }, + { duration: '10s', target: 30 }, + { duration: '10s', target: 60 }, + { duration: '10s', target: 100 }, + { duration: '10s', target: 100 }, + ], + description: 'pool_pressure: /stress/pool-pressure ramping 1 → 100 VUs (maxPoolSize=5)', + }, +}; + +const cfg = SCENARIO_CONFIG[SCENARIO]; +if (!cfg) { + throw new Error( + `Unknown SCENARIO '${SCENARIO}'. Set SCENARIO to one of: ${Object.keys(SCENARIO_CONFIG).join(', ')}`, + ); +} + +export const options = { + scenarios: { + [SCENARIO]: + cfg.executor === 'constant-vus' + ? { + executor: 'constant-vus', + vus: cfg.vus, + duration: cfg.duration, + gracefulStop: '5s', + } + : { + executor: 'ramping-vus', + startVUs: 1, + stages: cfg.stages, + gracefulRampDown: '5s', + gracefulStop: '5s', + }, + }, + thresholds: { + // Soft thresholds — we report but don't fail the run on breach. + // Failing here would make `pool_pressure` always "fail", which is + // expected behavior (we're deliberately over-subscribing the pool). + rsc_page_err: ['count<1000'], + }, + summaryTrendStats: ['min', 'avg', 'med', 'p(95)', 'p(99)', 'max'], +}; + +/** + * setup() runs once before the VU loop. Snapshots `/diag` so the + * teardown phase can report the delta in commands, check-outs, and + * TCP connections attributable to this scenario. + */ +export function setup() { + console.log(`== ${cfg.description}`); + console.log(`== target: ${BASE_URL}${cfg.path}`); + + const diagRes = http.get(`${BASE_URL}/diag`); + const before = diagRes.status === 200 ? diagRes.json() : null; + + return { + startedAt: new Date().toISOString(), + diagBefore: before, + }; +} + +/** + * Default VU function. Each iteration fires one GET against the + * scenario's target route. We intentionally do not sleep between + * iterations: the point of the scenarios is to generate concurrent + * pressure, not to model realistic think time. + */ +export default function vuIteration() { + const res = http.get(`${BASE_URL}${cfg.path}`, { + tags: { scenario: SCENARIO }, + timeout: '30s', + }); + pageLatency.add(res.timings.duration); + + const ok = check(res, { + 'status is 200': (r) => r.status === 200, + 'body is not empty': (r) => typeof r.body === 'string' && r.body.length > 0, + }); + + if (ok) { + pageOkCount.add(1); + } else { + pageErrCount.add(1); + } + + // No sleep(); generate continuous pressure. + sleep(0); +} + +/** + * teardown() runs once after the VU loop. Reads `/diag` again and logs + * the deltas. These numbers are the core evidence for the findings doc. + */ +export function teardown(data) { + const diagRes = http.get(`${BASE_URL}/diag`); + const after = diagRes.status === 200 ? diagRes.json() : null; + + const before = data.diagBefore; + const snapshotForScenario = (payload) => { + if (!payload || !Array.isArray(payload.snapshots)) return null; + return payload.snapshots; + }; + + const beforeSnaps = snapshotForScenario(before) || []; + const afterSnaps = snapshotForScenario(after) || []; + + // Align after-snaps to before-snaps by poolMax so we can compute + // deltas even when `/diag` grows new entries mid-run (e.g. another + // VU hits a different route). In practice each k6 run hits one + // route, so there's typically one entry. + const beforeByPool = Object.fromEntries(beforeSnaps.map((s) => [s.poolMax, s])); + const deltas = afterSnaps.map((a) => { + const b = beforeByPool[a.poolMax] || { + commandsStarted: 0, + commandsSucceeded: 0, + commandsFailed: 0, + connectionsCheckedOut: 0, + connectionsCheckedIn: 0, + connectionsCreated: 0, + connectionsClosed: 0, + }; + return { + poolMax: a.poolMax, + commandsStartedDelta: a.commandsStarted - b.commandsStarted, + commandsSucceededDelta: a.commandsSucceeded - b.commandsSucceeded, + commandsFailedDelta: a.commandsFailed - b.commandsFailed, + checkOutsDelta: a.connectionsCheckedOut - b.connectionsCheckedOut, + checkInsDelta: a.connectionsCheckedIn - b.connectionsCheckedIn, + tcpCreatedDelta: a.connectionsCreated - b.connectionsCreated, + tcpClosedDelta: a.connectionsClosed - b.connectionsClosed, + clientFinal: a.client, + }; + }); + + console.log(''); + console.log(`== ${SCENARIO} /diag deltas (after - before):`); + console.log(JSON.stringify(deltas, null, 2)); + console.log(`== started: ${data.startedAt}`); + console.log(`== finished: ${new Date().toISOString()}`); +} diff --git a/examples/rsc-poc-mongo/src/components/create-event-form.tsx b/examples/rsc-poc-mongo/src/components/create-event-form.tsx new file mode 100644 index 0000000000..fd52745755 --- /dev/null +++ b/examples/rsc-poc-mongo/src/components/create-event-form.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { useActionState } from 'react'; +import { type CreateEventState, createEventAction } from '../../app/actions'; + +/** + * Client-side form for the `createEventAction` Server Action. + * + * Mongo analogue of the Postgres app's ``. It exists + * to demonstrate (by hand, not under k6) that a Server Action mutation + * can run alongside the page's five parallel Server Component reads + * without the shared Mongo runtime blowing up. + * + * Uses `useActionState` to surface the action's result inline. The + * action itself calls `revalidatePath('/')` on success, so the new + * event shows up in `` and `` on + * the next render. + */ + +const INITIAL_STATE: CreateEventState = { status: 'idle' }; + +export function CreateEventForm() { + const [state, formAction, pending] = useActionState(createEventAction, INITIAL_STATE); + + return ( +
+

Record a search (Server Action)

+

+ createEventAction(query) → revalidatePath('/') +

+
+ + +
+ {state.status === 'ok' && ( +

+ ok + {state.message} +

+ )} + {state.status === 'error' && ( +

+ error + {state.message} +

+ )} +
+ ); +} diff --git a/examples/rsc-poc-mongo/src/components/diag-panel.tsx b/examples/rsc-poc-mongo/src/components/diag-panel.tsx new file mode 100644 index 0000000000..36482f5f94 --- /dev/null +++ b/examples/rsc-poc-mongo/src/components/diag-panel.tsx @@ -0,0 +1,81 @@ +import { getClient } from '../lib/db'; +import { snapshot } from '../lib/diag'; + +/** + * Dev-only diagnostics panel, rendered at the bottom of a Server Component + * page to surface the counters that back hypothesis H5 (Mongo's + * runtime/ORM have no analogue of H2/H3; the app still runs and the + * pool behaves predictably under concurrent rendering). + * + * ## Staleness caveat + * + * Same caveat as the Postgres app's panel: this is a Server Component + * that reads the in-process diagnostic snapshot at render time. React + * renders siblings concurrently but does **not** guarantee an ordering + * among siblings wrapped in separate `` boundaries, so the + * numbers printed on the **first** load after process start reflect + * "what had finished before the panel was scheduled" — often zero or + * very few commands. + * + * Workaround: reload the page. Counters are cumulative since process + * start, so the second page render reads the post-work snapshot from + * the first render and the numbers settle into their intended meaning. + * + * For values that are always current (e.g. for k6 post-run inspection), + * prefer the `/diag` JSON route handler — it's read **after** any page + * render completes and has no ordering relationship to sibling Suspense + * boundaries. + * + * The panel does not issue its own query, so reading it doesn't perturb + * the counters it reports. + */ +export interface DiagPanelProps { + readonly poolMax?: number | undefined; +} + +export function DiagPanel({ poolMax }: DiagPanelProps) { + const effectivePoolMax = poolMax ?? 100; + const snap = snapshot(effectivePoolMax); + const client = getClient({ poolMax }); + + const commandsOk = snap.commandsStarted === snap.commandsSucceeded + snap.commandsFailed; + const poolBalanced = snap.connectionsCheckedOut === snap.connectionsCheckedIn; + const tcpBalanced = snap.connectionsCreated === snap.connectionsClosed; + + return ( + + ); +} diff --git a/examples/rsc-poc-mongo/src/components/parallel-reads-page.tsx b/examples/rsc-poc-mongo/src/components/parallel-reads-page.tsx new file mode 100644 index 0000000000..4bfd64ccc4 --- /dev/null +++ b/examples/rsc-poc-mongo/src/components/parallel-reads-page.tsx @@ -0,0 +1,107 @@ +import { Suspense } from 'react'; +import { EventTypeStats } from '../server-components/event-type-stats'; +import { OrdersWithUser } from '../server-components/orders-with-user'; +import { ProductList } from '../server-components/product-list'; +import { ProductsBySearch } from '../server-components/products-by-search'; +import { SearchEvents } from '../server-components/search-events'; +import { CreateEventForm } from './create-event-form'; +import { DiagPanel } from './diag-panel'; + +/** + * Shared page body for `/` and `/stress/pool-pressure`. + * + * Both routes render the same five parallel Server Components plus the + * Server Action form and the diagnostics panel; what differs is the + * `poolMax` they pass to `getDb(...)`. Each unique `poolMax` gets its + * own Mongo runtime singleton in the `lib/db` registry, so the two + * routes never share a runtime and never contaminate each other's + * counters. + * + * Layout rules (same as the Postgres app): + * + * - Each Server Component is wrapped in its own `` so React + * schedules them concurrently and a slow one doesn't block the others. + * - The Server Action form and the diag panel live outside the grid of + * read-only cards. The form is a client component; the diag panel is + * a Server Component whose staleness caveats are documented on the + * component itself. + * + * Props are passed through to every Server Component untouched — no + * branching on route in this file, consistent with the repo's "no + * target branches, use adapters" rule. + * + * Contrast with the Postgres app's `ParallelReadsPage`: + * + * - No `verifyMode` dimension: `MongoRuntimeImpl` has no verification + * state (hypothesis H5), so there's nothing to toggle. + * - No `/stress/always` link: the always-mode route doesn't exist on + * the Mongo side for the same reason. + */ +export interface ParallelReadsPageProps { + /** + * Max size of the Mongo driver's internal connection pool + * (`maxPoolSize` on `MongoClient`). `/stress/pool-pressure` pins a + * small value (e.g. 5) to exercise hypothesis H4; `/` uses the + * default. + */ + readonly poolMax?: number | undefined; + /** + * Short human-readable label describing what this route is for. + * Rendered at the top of the page so the browser tab makes sense + * when multiple are open side-by-side during manual testing. + */ + readonly heading: string; + /** + * One-line explanation of the route's purpose. Rendered under the + * heading. + */ + readonly subtitle: React.ReactNode; +} + +export function ParallelReadsPage({ poolMax, heading, subtitle }: ParallelReadsPageProps) { + return ( + <> +

{heading}

+

{subtitle}

+

+ / · /stress/pool-pressure{' '} + · /diag +

+ +
+ }> + + + + }> + + + + }> + + + + }> + + + + }> + + + + +
+ + + + ); +} + +function LoadingCard({ title }: { readonly title: string }) { + return ( +
+

{title}

+

Loading…

+
+ ); +} diff --git a/examples/rsc-poc-mongo/src/lib/db.ts b/examples/rsc-poc-mongo/src/lib/db.ts new file mode 100644 index 0000000000..5189d810dc --- /dev/null +++ b/examples/rsc-poc-mongo/src/lib/db.ts @@ -0,0 +1,201 @@ +/** + * Process-scoped Prisma Next Mongo runtime singleton for Next.js App + * Router. + * + * Mirrors `examples/rsc-poc-postgres/src/lib/db.ts` in intent: one + * runtime per Node process, pinned to `globalThis` so Next.js dev-mode + * HMR doesn't leak a new MongoClient on every edit. In production the + * `globalThis` pattern collapses to a plain module-level singleton. + * + * ## Key differences from the Postgres side + * + * 1. **No `verifyMode`.** `MongoRuntimeImpl` has no verification state + * and does no marker reads — this is hypothesis H5 in the project + * plan. The registry keys by `poolMax` alone. + * + * 2. **No `InstrumentedPool` subclass.** The Mongo driver owns its + * pool inside `MongoClient` and that class isn't designed to be + * subclassed. Instead, we attach listeners for the documented + * `CMAP` (connection monitoring) and `APM` (command monitoring) + * events before `client.connect()`, and push counts into the + * `lib/diag` registry from those handlers. See the listener setup + * in `createEntry()` below. + * + * 3. **We build the driver via `MongoDriverImpl.fromDb`.** The + * convenience `createMongoDriver(uri, dbName)` factory constructs + * its own MongoClient internally, leaving us no place to attach + * listeners before `connect()`. `fromDb` accepts a pre-built `Db`, + * so we construct the `MongoClient` ourselves, wire up monitoring, + * connect, hand the resolved `Db` to the driver, and keep the + * client reference for a clean shutdown. + */ +import { createMongoAdapter } from '@prisma-next/adapter-mongo'; +import { MongoDriverImpl } from '@prisma-next/driver-mongo'; +import { createTelemetryMiddleware } from '@prisma-next/middleware-telemetry'; +import { validateMongoContract } from '@prisma-next/mongo-contract'; +import { mongoOrm, mongoRaw } from '@prisma-next/mongo-orm'; +import { mongoQuery } from '@prisma-next/mongo-query-builder'; +import { createMongoRuntime, type MongoRuntime } from '@prisma-next/mongo-runtime'; +import { MongoClient } from 'mongodb'; +import type { Contract } from '../prisma/contract.d'; +import contractJson from '../prisma/contract.json' with { type: 'json' }; +import { + recordCommandFailed, + recordCommandStarted, + recordCommandSucceeded, + recordConnectionCheckedIn, + recordConnectionCheckedOut, + recordConnectionClosed, + recordConnectionCreated, +} from './diag'; + +/** + * Shape of the Prisma Next Mongo surface we expose to the rest of the + * app. Mirrors the retail-store example's `Db` type so queries written + * against one style port over easily. + */ +export interface Db { + readonly orm: ReturnType>; + readonly query: ReturnType>; + readonly raw: ReturnType; + readonly runtime: MongoRuntime; + readonly contract: ReturnType>['contract']; +} + +interface DbEntry { + readonly db: Db; + readonly client: MongoClient; + readonly poolMax: number; +} + +type DbRegistry = Map; + +const REGISTRY_KEY = Symbol.for('prisma-next.rsc-poc-mongo.registry'); + +interface GlobalWithRegistry { + [REGISTRY_KEY]?: DbRegistry; +} + +function getRegistry(): DbRegistry { + const g = globalThis as unknown as GlobalWithRegistry; + let registry = g[REGISTRY_KEY]; + if (!registry) { + registry = new Map(); + g[REGISTRY_KEY] = registry; + } + return registry; +} + +export interface DbOptions { + /** + * Max Mongo driver pool size (`maxPoolSize` on MongoClient). The + * `/stress/pool-pressure` route pins a small value to exercise + * hypothesis H4 (pool contention under RSC concurrency). Defaults to + * 100, which is also the Mongo driver's default. + * + * Explicit `undefined` is accepted (with `exactOptionalPropertyTypes`) + * so pass-through call sites don't need conditional-spread + * boilerplate. + */ + readonly poolMax?: number | undefined; +} + +function readConnectionConfig(): { readonly uri: string; readonly dbName: string } { + const uri = process.env['DB_URL']; + if (!uri) { + throw new Error( + 'DB_URL is not set. Copy .env.example to .env and set a MongoDB connection string.', + ); + } + const dbName = process.env['MONGODB_DB'] ?? 'rsc-poc-mongo'; + return { uri, dbName }; +} + +const { contract } = validateMongoContract(contractJson); +const query = mongoQuery({ contractJson }); +const raw = mongoRaw({ contract }); + +async function createEntry(poolMax: number): Promise { + const { uri, dbName } = readConnectionConfig(); + + const client = new MongoClient(uri, { + maxPoolSize: poolMax, + monitorCommands: true, + // Time out cleanly under pool pressure rather than hanging — this + // is the Mongo analogue of pg's `connectionTimeoutMillis` and lets + // the `/stress/pool-pressure` scenario produce legible failures + // rather than wedged requests. + waitQueueTimeoutMS: 5_000, + }); + + // Attach listeners BEFORE connect(). The driver emits + // `connectionPoolCreated` during connect() setup — if we attached + // afterward we'd miss the first batch of events. + client.on('commandStarted', () => recordCommandStarted(poolMax)); + client.on('commandSucceeded', () => recordCommandSucceeded(poolMax)); + client.on('commandFailed', () => recordCommandFailed(poolMax)); + client.on('connectionCheckedOut', () => recordConnectionCheckedOut(poolMax)); + client.on('connectionCheckedIn', () => recordConnectionCheckedIn(poolMax)); + client.on('connectionCreated', () => recordConnectionCreated(poolMax)); + client.on('connectionClosed', () => recordConnectionClosed(poolMax)); + + await client.connect(); + + const driver = MongoDriverImpl.fromDb(client.db(dbName)); + const adapter = createMongoAdapter(); + const runtime = createMongoRuntime({ + adapter, + driver, + contract, + targetId: 'mongo', + middleware: [createTelemetryMiddleware()], + }); + const orm = mongoOrm({ contract, executor: runtime }); + + const db: Db = { orm, runtime, query, raw, contract }; + return { db, client, poolMax }; +} + +/** + * Returns a Prisma Next Mongo client pinned to the given `poolMax`. + * Each unique `poolMax` gets its own singleton, so `/` (default pool) + * and `/stress/pool-pressure` (small pool) never share a runtime and + * their counters remain apples-to-apples. + * + * Async because constructing the MongoClient requires awaiting + * `client.connect()` before the runtime can serve requests. Server + * Components that call this will suspend on the first request and + * resolve synchronously thereafter (the cached entry is returned + * without awaiting). + */ +export async function getDb(options: DbOptions = {}): Promise { + const poolMax = options.poolMax ?? 100; + const registry = getRegistry(); + const existing = registry.get(poolMax); + if (existing) { + return existing.db; + } + + const entry = await createEntry(poolMax); + // Race guard: if two concurrent callers both miss the cache, the + // first to reach this line wins; the second's entry is dropped and + // its client closed to avoid leaking an unused pool. + const raced = registry.get(poolMax); + if (raced) { + await entry.client.close().catch(() => undefined); + return raced.db; + } + registry.set(poolMax, entry); + return entry.db; +} + +/** + * Returns the underlying MongoClient for a given `poolMax`, if one + * has been instantiated. Used by the diagnostics panel and `/diag` + * route to surface live pool stats (approximated via `client.topology` + * / server descriptions). + */ +export function getClient(options: DbOptions = {}): MongoClient | undefined { + const poolMax = options.poolMax ?? 100; + return getRegistry().get(poolMax)?.client; +} diff --git a/examples/rsc-poc-mongo/src/lib/diag.ts b/examples/rsc-poc-mongo/src/lib/diag.ts new file mode 100644 index 0000000000..080281cac1 --- /dev/null +++ b/examples/rsc-poc-mongo/src/lib/diag.ts @@ -0,0 +1,161 @@ +/** + * In-process diagnostic counters for the Mongo RSC concurrency PoC. + * + * Unlike the Postgres app — where counters are populated by an + * instrumented `pg.Pool` subclass — the Mongo driver owns its own + * connection pool inside `MongoClient`. We can't subclass that, but + * `MongoClient` emits command- and pool-level events we can listen to. + * The listeners live in `src/lib/db.ts`; this module is just the + * counter store they write into, plus the snapshot readers the `/diag` + * route and `` pull from. + * + * ## What we count + * + * - **commandStarted / commandSucceeded / commandFailed**: one MongoDB + * command (find, aggregate, insert, …) issued against the driver. + * This is the Mongo analogue of a pg query. + * + * - **connectionCheckedOut / connectionCheckedIn**: a pool client + * borrowed from the driver's internal pool. Roughly the Mongo + * analogue of `pool.connect()` / `client.release()` on the pg side. + * + * - **connectionCreated / connectionClosed**: underlying TCP + * connections opened and closed. These don't have a direct pg + * analogue — pg-pool's `connect()` may reuse a TCP connection that + * was opened earlier, and we don't count TCP-level events there. + * + * ## Why no marker-read counter + * + * The Mongo runtime (`MongoRuntimeImpl`) has **no verification state** + * and issues no marker reads — that's hypothesis H5 from the project + * plan. A counter here would always be zero. Omitting it keeps the + * snapshot shape minimal and makes the contrast with the Postgres + * side obvious in the findings doc: one side verifies, one doesn't. + * + * ## `globalThis`-backed storage + * + * Same pattern as the Postgres app's `lib/diag.ts`: counters live on + * `globalThis` under a stable `Symbol.for(...)` key so they survive + * Next.js dev-mode HMR. In production there's no HMR and this + * collapses to a regular module-level singleton per Node process. + * + * Counters are keyed by `poolMax` (default vs. the small value the + * pool-pressure route pins), mirroring how the Postgres registry + * keys by `(verifyMode, poolMax)`. The Mongo app has no `verifyMode` + * dimension, so `poolMax` alone is enough. + */ + +export interface DiagCounters { + /** MongoDB commands started (find, aggregate, insertOne, …). */ + commandsStarted: number; + /** Commands that reported success (`commandSucceeded`). */ + commandsSucceeded: number; + /** Commands that reported failure (`commandFailed`). */ + commandsFailed: number; + /** Pool connections checked out (`connectionCheckedOut`). */ + connectionsCheckedOut: number; + /** Pool connections checked in (`connectionCheckedIn`). */ + connectionsCheckedIn: number; + /** Underlying TCP connections opened (`connectionCreated`). */ + connectionsCreated: number; + /** Underlying TCP connections closed (`connectionClosed`). */ + connectionsClosed: number; +} + +export interface DiagSnapshot extends DiagCounters { + readonly poolMax: number; + readonly timestampMs: number; +} + +type DiagRegistry = Map; + +const REGISTRY_KEY = Symbol.for('prisma-next.rsc-poc-mongo.diag'); + +interface GlobalWithDiag { + [REGISTRY_KEY]?: DiagRegistry; +} + +function getRegistry(): DiagRegistry { + const g = globalThis as unknown as GlobalWithDiag; + let registry = g[REGISTRY_KEY]; + if (!registry) { + registry = new Map(); + g[REGISTRY_KEY] = registry; + } + return registry; +} + +function getOrCreate(poolMax: number): DiagCounters { + const registry = getRegistry(); + let counters = registry.get(poolMax); + if (!counters) { + counters = { + commandsStarted: 0, + commandsSucceeded: 0, + commandsFailed: 0, + connectionsCheckedOut: 0, + connectionsCheckedIn: 0, + connectionsCreated: 0, + connectionsClosed: 0, + }; + registry.set(poolMax, counters); + } + return counters; +} + +export function recordCommandStarted(poolMax: number): void { + getOrCreate(poolMax).commandsStarted += 1; +} + +export function recordCommandSucceeded(poolMax: number): void { + getOrCreate(poolMax).commandsSucceeded += 1; +} + +export function recordCommandFailed(poolMax: number): void { + getOrCreate(poolMax).commandsFailed += 1; +} + +export function recordConnectionCheckedOut(poolMax: number): void { + getOrCreate(poolMax).connectionsCheckedOut += 1; +} + +export function recordConnectionCheckedIn(poolMax: number): void { + getOrCreate(poolMax).connectionsCheckedIn += 1; +} + +export function recordConnectionCreated(poolMax: number): void { + getOrCreate(poolMax).connectionsCreated += 1; +} + +export function recordConnectionClosed(poolMax: number): void { + getOrCreate(poolMax).connectionsClosed += 1; +} + +export function snapshot(poolMax: number): DiagSnapshot { + const counters = getOrCreate(poolMax); + return { + poolMax, + timestampMs: Date.now(), + commandsStarted: counters.commandsStarted, + commandsSucceeded: counters.commandsSucceeded, + commandsFailed: counters.commandsFailed, + connectionsCheckedOut: counters.connectionsCheckedOut, + connectionsCheckedIn: counters.connectionsCheckedIn, + connectionsCreated: counters.connectionsCreated, + connectionsClosed: counters.connectionsClosed, + }; +} + +export function snapshotAll(): readonly DiagSnapshot[] { + const registry = getRegistry(); + return [...registry.keys()].map((poolMax) => snapshot(poolMax)); +} + +export function reset(poolMax?: number): void { + const registry = getRegistry(); + if (poolMax === undefined) { + registry.clear(); + return; + } + registry.delete(poolMax); +} diff --git a/examples/rsc-poc-mongo/src/prisma/contract.d.ts b/examples/rsc-poc-mongo/src/prisma/contract.d.ts new file mode 100644 index 0000000000..776b904443 --- /dev/null +++ b/examples/rsc-poc-mongo/src/prisma/contract.d.ts @@ -0,0 +1,1080 @@ +// ⚠️ GENERATED FILE - DO NOT EDIT +// This file is automatically generated by 'prisma-next contract emit'. +// To regenerate, run: prisma-next contract emit +import type { CodecTypes as MongoCodecTypes } from '@prisma-next/adapter-mongo/codec-types'; +import type { Vector } from '@prisma-next/adapter-mongo/codec-types'; + +import type { MongoContractWithTypeMaps, MongoTypeMaps } from '@prisma-next/mongo-contract'; +import type { + Contract as ContractType, + ExecutionHashBase, + ProfileHashBase, + StorageHashBase, +} from '@prisma-next/contract/types'; + +export type StorageHash = + StorageHashBase<'sha256:7fc150cbd971b2e0158b09648c0d46a4602641da32e45e76bf75b8ecef3eef37'>; +export type ExecutionHash = ExecutionHashBase; +export type ProfileHash = + ProfileHashBase<'sha256:840de65fba7eb950a31487f74ee420b9c21205f38bce58579026747e0264e840'>; + +export type CodecTypes = MongoCodecTypes; +export type OperationTypes = Record; + +export type PriceOutput = { + readonly amount: CodecTypes['mongo/double@1']['output']; + readonly currency: CodecTypes['mongo/string@1']['output']; +}; +export type PriceInput = { + readonly amount: CodecTypes['mongo/double@1']['input']; + readonly currency: CodecTypes['mongo/string@1']['input']; +}; +export type ImageOutput = { readonly url: CodecTypes['mongo/string@1']['output'] }; +export type ImageInput = { readonly url: CodecTypes['mongo/string@1']['input'] }; +export type AddressOutput = { + readonly streetAndNumber: CodecTypes['mongo/string@1']['output']; + readonly city: CodecTypes['mongo/string@1']['output']; + readonly postalCode: CodecTypes['mongo/string@1']['output']; + readonly country: CodecTypes['mongo/string@1']['output']; +}; +export type AddressInput = { + readonly streetAndNumber: CodecTypes['mongo/string@1']['input']; + readonly city: CodecTypes['mongo/string@1']['input']; + readonly postalCode: CodecTypes['mongo/string@1']['input']; + readonly country: CodecTypes['mongo/string@1']['input']; +}; +export type CartItemOutput = { + readonly productId: CodecTypes['mongo/string@1']['output']; + readonly name: CodecTypes['mongo/string@1']['output']; + readonly brand: CodecTypes['mongo/string@1']['output']; + readonly amount: CodecTypes['mongo/int32@1']['output']; + readonly price: PriceOutput; + readonly image: ImageOutput; +}; +export type CartItemInput = { + readonly productId: CodecTypes['mongo/string@1']['input']; + readonly name: CodecTypes['mongo/string@1']['input']; + readonly brand: CodecTypes['mongo/string@1']['input']; + readonly amount: CodecTypes['mongo/int32@1']['input']; + readonly price: PriceInput; + readonly image: ImageInput; +}; +export type OrderLineItemOutput = { + readonly productId: CodecTypes['mongo/string@1']['output']; + readonly name: CodecTypes['mongo/string@1']['output']; + readonly brand: CodecTypes['mongo/string@1']['output']; + readonly amount: CodecTypes['mongo/int32@1']['output']; + readonly price: PriceOutput; + readonly image: ImageOutput; +}; +export type OrderLineItemInput = { + readonly productId: CodecTypes['mongo/string@1']['input']; + readonly name: CodecTypes['mongo/string@1']['input']; + readonly brand: CodecTypes['mongo/string@1']['input']; + readonly amount: CodecTypes['mongo/int32@1']['input']; + readonly price: PriceInput; + readonly image: ImageInput; +}; +export type StatusEntryOutput = { + readonly status: CodecTypes['mongo/string@1']['output']; + readonly timestamp: CodecTypes['mongo/date@1']['output']; +}; +export type StatusEntryInput = { + readonly status: CodecTypes['mongo/string@1']['input']; + readonly timestamp: CodecTypes['mongo/date@1']['input']; +}; +export type InvoiceLineItemOutput = { + readonly name: CodecTypes['mongo/string@1']['output']; + readonly amount: CodecTypes['mongo/int32@1']['output']; + readonly unitPrice: CodecTypes['mongo/double@1']['output']; + readonly lineTotal: CodecTypes['mongo/double@1']['output']; +}; +export type InvoiceLineItemInput = { + readonly name: CodecTypes['mongo/string@1']['input']; + readonly amount: CodecTypes['mongo/int32@1']['input']; + readonly unitPrice: CodecTypes['mongo/double@1']['input']; + readonly lineTotal: CodecTypes['mongo/double@1']['input']; +}; +export type FieldOutputTypes = { + readonly AddToCartEvent: { + readonly productId: CodecTypes['mongo/string@1']['output']; + readonly brand: CodecTypes['mongo/string@1']['output']; + }; + readonly Cart: { + readonly _id: CodecTypes['mongo/objectId@1']['output']; + readonly userId: CodecTypes['mongo/objectId@1']['output']; + readonly items: ReadonlyArray; + }; + readonly Event: { + readonly _id: CodecTypes['mongo/objectId@1']['output']; + readonly userId: CodecTypes['mongo/string@1']['output']; + readonly sessionId: CodecTypes['mongo/string@1']['output']; + readonly type: CodecTypes['mongo/string@1']['output']; + readonly timestamp: CodecTypes['mongo/date@1']['output']; + }; + readonly Invoice: { + readonly _id: CodecTypes['mongo/objectId@1']['output']; + readonly orderId: CodecTypes['mongo/objectId@1']['output']; + readonly items: ReadonlyArray; + readonly subtotal: CodecTypes['mongo/double@1']['output']; + readonly tax: CodecTypes['mongo/double@1']['output']; + readonly total: CodecTypes['mongo/double@1']['output']; + readonly issuedAt: CodecTypes['mongo/date@1']['output']; + }; + readonly Location: { + readonly _id: CodecTypes['mongo/objectId@1']['output']; + readonly name: CodecTypes['mongo/string@1']['output']; + readonly streetAndNumber: CodecTypes['mongo/string@1']['output']; + readonly city: CodecTypes['mongo/string@1']['output']; + readonly postalCode: CodecTypes['mongo/string@1']['output']; + readonly country: CodecTypes['mongo/string@1']['output']; + }; + readonly Order: { + readonly _id: CodecTypes['mongo/objectId@1']['output']; + readonly userId: CodecTypes['mongo/objectId@1']['output']; + readonly items: ReadonlyArray; + readonly shippingAddress: CodecTypes['mongo/string@1']['output']; + readonly type: CodecTypes['mongo/string@1']['output']; + readonly statusHistory: ReadonlyArray; + }; + readonly Product: { + readonly _id: CodecTypes['mongo/objectId@1']['output']; + readonly name: CodecTypes['mongo/string@1']['output']; + readonly brand: CodecTypes['mongo/string@1']['output']; + readonly code: CodecTypes['mongo/string@1']['output']; + readonly description: CodecTypes['mongo/string@1']['output']; + readonly masterCategory: CodecTypes['mongo/string@1']['output']; + readonly subCategory: CodecTypes['mongo/string@1']['output']; + readonly articleType: CodecTypes['mongo/string@1']['output']; + readonly price: PriceOutput; + readonly image: ImageOutput; + readonly embedding: ReadonlyArray | null; + }; + readonly SearchEvent: { readonly query: CodecTypes['mongo/string@1']['output'] }; + readonly User: { + readonly _id: CodecTypes['mongo/objectId@1']['output']; + readonly name: CodecTypes['mongo/string@1']['output']; + readonly email: CodecTypes['mongo/string@1']['output']; + readonly address: AddressOutput | null; + }; + readonly ViewProductEvent: { + readonly productId: CodecTypes['mongo/string@1']['output']; + readonly subCategory: CodecTypes['mongo/string@1']['output']; + readonly brand: CodecTypes['mongo/string@1']['output']; + readonly exitMethod: CodecTypes['mongo/string@1']['output'] | null; + }; +}; +export type FieldInputTypes = { + readonly AddToCartEvent: { + readonly productId: CodecTypes['mongo/string@1']['input']; + readonly brand: CodecTypes['mongo/string@1']['input']; + }; + readonly Cart: { + readonly _id: CodecTypes['mongo/objectId@1']['input']; + readonly userId: CodecTypes['mongo/objectId@1']['input']; + readonly items: ReadonlyArray; + }; + readonly Event: { + readonly _id: CodecTypes['mongo/objectId@1']['input']; + readonly userId: CodecTypes['mongo/string@1']['input']; + readonly sessionId: CodecTypes['mongo/string@1']['input']; + readonly type: CodecTypes['mongo/string@1']['input']; + readonly timestamp: CodecTypes['mongo/date@1']['input']; + }; + readonly Invoice: { + readonly _id: CodecTypes['mongo/objectId@1']['input']; + readonly orderId: CodecTypes['mongo/objectId@1']['input']; + readonly items: ReadonlyArray; + readonly subtotal: CodecTypes['mongo/double@1']['input']; + readonly tax: CodecTypes['mongo/double@1']['input']; + readonly total: CodecTypes['mongo/double@1']['input']; + readonly issuedAt: CodecTypes['mongo/date@1']['input']; + }; + readonly Location: { + readonly _id: CodecTypes['mongo/objectId@1']['input']; + readonly name: CodecTypes['mongo/string@1']['input']; + readonly streetAndNumber: CodecTypes['mongo/string@1']['input']; + readonly city: CodecTypes['mongo/string@1']['input']; + readonly postalCode: CodecTypes['mongo/string@1']['input']; + readonly country: CodecTypes['mongo/string@1']['input']; + }; + readonly Order: { + readonly _id: CodecTypes['mongo/objectId@1']['input']; + readonly userId: CodecTypes['mongo/objectId@1']['input']; + readonly items: ReadonlyArray; + readonly shippingAddress: CodecTypes['mongo/string@1']['input']; + readonly type: CodecTypes['mongo/string@1']['input']; + readonly statusHistory: ReadonlyArray; + }; + readonly Product: { + readonly _id: CodecTypes['mongo/objectId@1']['input']; + readonly name: CodecTypes['mongo/string@1']['input']; + readonly brand: CodecTypes['mongo/string@1']['input']; + readonly code: CodecTypes['mongo/string@1']['input']; + readonly description: CodecTypes['mongo/string@1']['input']; + readonly masterCategory: CodecTypes['mongo/string@1']['input']; + readonly subCategory: CodecTypes['mongo/string@1']['input']; + readonly articleType: CodecTypes['mongo/string@1']['input']; + readonly price: PriceInput; + readonly image: ImageInput; + readonly embedding: ReadonlyArray | null; + }; + readonly SearchEvent: { readonly query: CodecTypes['mongo/string@1']['input'] }; + readonly User: { + readonly _id: CodecTypes['mongo/objectId@1']['input']; + readonly name: CodecTypes['mongo/string@1']['input']; + readonly email: CodecTypes['mongo/string@1']['input']; + readonly address: AddressInput | null; + }; + readonly ViewProductEvent: { + readonly productId: CodecTypes['mongo/string@1']['input']; + readonly subCategory: CodecTypes['mongo/string@1']['input']; + readonly brand: CodecTypes['mongo/string@1']['input']; + readonly exitMethod: CodecTypes['mongo/string@1']['input'] | null; + }; +}; +export type TypeMaps = MongoTypeMaps; + +type ContractBase = ContractType< + { + readonly collections: { + readonly products: { + readonly indexes: readonly [ + { + readonly keys: readonly [ + { readonly field: 'name'; readonly direction: 'text' }, + { readonly field: 'description'; readonly direction: 'text' }, + ]; + readonly weights: { readonly name: 10; readonly description: 1 }; + }, + { + readonly keys: readonly [ + { readonly field: 'brand'; readonly direction: 1 }, + { readonly field: 'subCategory'; readonly direction: 1 }, + ]; + }, + { readonly keys: readonly [{ readonly field: 'code'; readonly direction: 'hashed' }] }, + ]; + readonly validator: { + readonly jsonSchema: { + readonly bsonType: 'object'; + readonly properties: { + readonly _id: { readonly bsonType: 'objectId' }; + readonly name: { readonly bsonType: 'string' }; + readonly brand: { readonly bsonType: 'string' }; + readonly code: { readonly bsonType: 'string' }; + readonly description: { readonly bsonType: 'string' }; + readonly masterCategory: { readonly bsonType: 'string' }; + readonly subCategory: { readonly bsonType: 'string' }; + readonly articleType: { readonly bsonType: 'string' }; + readonly price: { + readonly bsonType: 'object'; + readonly properties: { + readonly amount: { readonly bsonType: 'double' }; + readonly currency: { readonly bsonType: 'string' }; + }; + readonly required: readonly ['amount', 'currency']; + }; + readonly image: { + readonly bsonType: 'object'; + readonly properties: { readonly url: { readonly bsonType: 'string' } }; + readonly required: readonly ['url']; + }; + readonly embedding: { + readonly bsonType: 'array'; + readonly items: { readonly bsonType: 'double' }; + }; + }; + readonly required: readonly [ + '_id', + 'articleType', + 'brand', + 'code', + 'description', + 'image', + 'masterCategory', + 'name', + 'price', + 'subCategory', + ]; + }; + readonly validationLevel: 'strict'; + readonly validationAction: 'error'; + }; + }; + readonly users: { + readonly indexes: readonly [ + { + readonly keys: readonly [{ readonly field: 'email'; readonly direction: 1 }]; + readonly unique: true; + }, + ]; + readonly validator: { + readonly jsonSchema: { + readonly bsonType: 'object'; + readonly properties: { + readonly _id: { readonly bsonType: 'objectId' }; + readonly name: { readonly bsonType: 'string' }; + readonly email: { readonly bsonType: 'string' }; + readonly address: { + readonly oneOf: readonly [ + { readonly bsonType: 'null' }, + { + readonly bsonType: 'object'; + readonly properties: { + readonly streetAndNumber: { readonly bsonType: 'string' }; + readonly city: { readonly bsonType: 'string' }; + readonly postalCode: { readonly bsonType: 'string' }; + readonly country: { readonly bsonType: 'string' }; + }; + readonly required: readonly [ + 'city', + 'country', + 'postalCode', + 'streetAndNumber', + ]; + }, + ]; + }; + }; + readonly required: readonly ['_id', 'email', 'name']; + }; + readonly validationLevel: 'strict'; + readonly validationAction: 'error'; + }; + }; + readonly carts: { + readonly indexes: readonly [ + { + readonly keys: readonly [{ readonly field: 'userId'; readonly direction: 1 }]; + readonly unique: true; + }, + ]; + readonly validator: { + readonly jsonSchema: { + readonly bsonType: 'object'; + readonly properties: { + readonly _id: { readonly bsonType: 'objectId' }; + readonly userId: { readonly bsonType: 'objectId' }; + readonly items: { + readonly bsonType: 'array'; + readonly items: { + readonly bsonType: 'object'; + readonly properties: { + readonly productId: { readonly bsonType: 'string' }; + readonly name: { readonly bsonType: 'string' }; + readonly brand: { readonly bsonType: 'string' }; + readonly amount: { readonly bsonType: 'int' }; + readonly price: { + readonly bsonType: 'object'; + readonly properties: { + readonly amount: { readonly bsonType: 'double' }; + readonly currency: { readonly bsonType: 'string' }; + }; + readonly required: readonly ['amount', 'currency']; + }; + readonly image: { + readonly bsonType: 'object'; + readonly properties: { readonly url: { readonly bsonType: 'string' } }; + readonly required: readonly ['url']; + }; + }; + readonly required: readonly [ + 'amount', + 'brand', + 'image', + 'name', + 'price', + 'productId', + ]; + }; + }; + }; + readonly required: readonly ['_id', 'items', 'userId']; + }; + readonly validationLevel: 'strict'; + readonly validationAction: 'error'; + }; + }; + readonly orders: { + readonly indexes: readonly [ + { readonly keys: readonly [{ readonly field: 'userId'; readonly direction: 1 }] }, + ]; + readonly validator: { + readonly jsonSchema: { + readonly bsonType: 'object'; + readonly properties: { + readonly _id: { readonly bsonType: 'objectId' }; + readonly userId: { readonly bsonType: 'objectId' }; + readonly items: { + readonly bsonType: 'array'; + readonly items: { + readonly bsonType: 'object'; + readonly properties: { + readonly productId: { readonly bsonType: 'string' }; + readonly name: { readonly bsonType: 'string' }; + readonly brand: { readonly bsonType: 'string' }; + readonly amount: { readonly bsonType: 'int' }; + readonly price: { + readonly bsonType: 'object'; + readonly properties: { + readonly amount: { readonly bsonType: 'double' }; + readonly currency: { readonly bsonType: 'string' }; + }; + readonly required: readonly ['amount', 'currency']; + }; + readonly image: { + readonly bsonType: 'object'; + readonly properties: { readonly url: { readonly bsonType: 'string' } }; + readonly required: readonly ['url']; + }; + }; + readonly required: readonly [ + 'amount', + 'brand', + 'image', + 'name', + 'price', + 'productId', + ]; + }; + }; + readonly shippingAddress: { readonly bsonType: 'string' }; + readonly type: { readonly bsonType: 'string' }; + readonly statusHistory: { + readonly bsonType: 'array'; + readonly items: { + readonly bsonType: 'object'; + readonly properties: { + readonly status: { readonly bsonType: 'string' }; + readonly timestamp: { readonly bsonType: 'date' }; + }; + readonly required: readonly ['status', 'timestamp']; + }; + }; + }; + readonly required: readonly [ + '_id', + 'items', + 'shippingAddress', + 'statusHistory', + 'type', + 'userId', + ]; + }; + readonly validationLevel: 'strict'; + readonly validationAction: 'error'; + }; + }; + readonly locations: { + readonly indexes: readonly [ + { + readonly keys: readonly [ + { readonly field: 'city'; readonly direction: 1 }, + { readonly field: 'country'; readonly direction: 1 }, + ]; + readonly collation: { readonly locale: 'en'; readonly strength: 2 }; + }, + ]; + readonly validator: { + readonly jsonSchema: { + readonly bsonType: 'object'; + readonly properties: { + readonly _id: { readonly bsonType: 'objectId' }; + readonly name: { readonly bsonType: 'string' }; + readonly streetAndNumber: { readonly bsonType: 'string' }; + readonly city: { readonly bsonType: 'string' }; + readonly postalCode: { readonly bsonType: 'string' }; + readonly country: { readonly bsonType: 'string' }; + }; + readonly required: readonly [ + '_id', + 'city', + 'country', + 'name', + 'postalCode', + 'streetAndNumber', + ]; + }; + readonly validationLevel: 'strict'; + readonly validationAction: 'error'; + }; + }; + readonly invoices: { + readonly indexes: readonly [ + { readonly keys: readonly [{ readonly field: 'orderId'; readonly direction: 1 }] }, + { + readonly keys: readonly [{ readonly field: 'issuedAt'; readonly direction: -1 }]; + readonly sparse: true; + }, + ]; + readonly validator: { + readonly jsonSchema: { + readonly bsonType: 'object'; + readonly properties: { + readonly _id: { readonly bsonType: 'objectId' }; + readonly orderId: { readonly bsonType: 'objectId' }; + readonly items: { + readonly bsonType: 'array'; + readonly items: { + readonly bsonType: 'object'; + readonly properties: { + readonly name: { readonly bsonType: 'string' }; + readonly amount: { readonly bsonType: 'int' }; + readonly unitPrice: { readonly bsonType: 'double' }; + readonly lineTotal: { readonly bsonType: 'double' }; + }; + readonly required: readonly ['amount', 'lineTotal', 'name', 'unitPrice']; + }; + }; + readonly subtotal: { readonly bsonType: 'double' }; + readonly tax: { readonly bsonType: 'double' }; + readonly total: { readonly bsonType: 'double' }; + readonly issuedAt: { readonly bsonType: 'date' }; + }; + readonly required: readonly [ + '_id', + 'issuedAt', + 'items', + 'orderId', + 'subtotal', + 'tax', + 'total', + ]; + }; + readonly validationLevel: 'strict'; + readonly validationAction: 'error'; + }; + }; + readonly events: { + readonly indexes: readonly [ + { + readonly keys: readonly [ + { readonly field: 'userId'; readonly direction: 1 }, + { readonly field: 'timestamp'; readonly direction: -1 }, + ]; + }, + { + readonly keys: readonly [{ readonly field: 'timestamp'; readonly direction: 1 }]; + readonly expireAfterSeconds: 7776000; + }, + ]; + readonly validator: { + readonly jsonSchema: { + readonly bsonType: 'object'; + readonly properties: { + readonly _id: { readonly bsonType: 'objectId' }; + readonly userId: { readonly bsonType: 'string' }; + readonly sessionId: { readonly bsonType: 'string' }; + readonly type: { readonly bsonType: 'string' }; + readonly timestamp: { readonly bsonType: 'date' }; + }; + readonly required: readonly ['_id', 'sessionId', 'timestamp', 'type', 'userId']; + readonly oneOf: readonly [ + { + readonly properties: { + readonly type: { readonly enum: readonly ['view-product'] }; + readonly productId: { readonly bsonType: 'string' }; + readonly subCategory: { readonly bsonType: 'string' }; + readonly brand: { readonly bsonType: 'string' }; + readonly exitMethod: { readonly bsonType: readonly ['null', 'string'] }; + }; + readonly required: readonly ['brand', 'productId', 'subCategory', 'type']; + }, + { + readonly properties: { + readonly type: { readonly enum: readonly ['search'] }; + readonly query: { readonly bsonType: 'string' }; + }; + readonly required: readonly ['query', 'type']; + }, + { + readonly properties: { + readonly type: { readonly enum: readonly ['add-to-cart'] }; + readonly productId: { readonly bsonType: 'string' }; + readonly brand: { readonly bsonType: 'string' }; + }; + readonly required: readonly ['brand', 'productId', 'type']; + }, + ]; + }; + readonly validationLevel: 'strict'; + readonly validationAction: 'error'; + }; + }; + }; + readonly storageHash: StorageHash; + }, + { + readonly AddToCartEvent: { + readonly fields: { + readonly productId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly brand: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + }; + readonly relations: Record; + readonly storage: { readonly collection: 'events' }; + readonly base: 'Event'; + }; + readonly Cart: { + readonly fields: { + readonly _id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/objectId@1' }; + }; + readonly userId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/objectId@1' }; + }; + readonly items: { + readonly nullable: false; + readonly type: { readonly kind: 'valueObject'; readonly name: 'CartItem' }; + readonly many: true; + }; + }; + readonly relations: { + readonly user: { + readonly to: 'User'; + readonly cardinality: 'N:1'; + readonly on: { + readonly localFields: readonly ['userId']; + readonly targetFields: readonly ['_id']; + }; + }; + }; + readonly storage: { readonly collection: 'carts' }; + }; + readonly Event: { + readonly fields: { + readonly _id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/objectId@1' }; + }; + readonly userId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly sessionId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly type: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly timestamp: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/date@1' }; + }; + }; + readonly relations: Record; + readonly storage: { readonly collection: 'events' }; + readonly discriminator: { readonly field: 'type' }; + readonly variants: { + readonly ViewProductEvent: { readonly value: 'view-product' }; + readonly SearchEvent: { readonly value: 'search' }; + readonly AddToCartEvent: { readonly value: 'add-to-cart' }; + }; + }; + readonly Invoice: { + readonly fields: { + readonly _id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/objectId@1' }; + }; + readonly orderId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/objectId@1' }; + }; + readonly items: { + readonly nullable: false; + readonly type: { readonly kind: 'valueObject'; readonly name: 'InvoiceLineItem' }; + readonly many: true; + }; + readonly subtotal: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/double@1' }; + }; + readonly tax: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/double@1' }; + }; + readonly total: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/double@1' }; + }; + readonly issuedAt: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/date@1' }; + }; + }; + readonly relations: { + readonly order: { + readonly to: 'Order'; + readonly cardinality: 'N:1'; + readonly on: { + readonly localFields: readonly ['orderId']; + readonly targetFields: readonly ['_id']; + }; + }; + }; + readonly storage: { readonly collection: 'invoices' }; + }; + readonly Location: { + readonly fields: { + readonly _id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/objectId@1' }; + }; + readonly name: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly streetAndNumber: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly city: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly postalCode: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly country: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + }; + readonly relations: Record; + readonly storage: { readonly collection: 'locations' }; + }; + readonly Order: { + readonly fields: { + readonly _id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/objectId@1' }; + }; + readonly userId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/objectId@1' }; + }; + readonly items: { + readonly nullable: false; + readonly type: { readonly kind: 'valueObject'; readonly name: 'OrderLineItem' }; + readonly many: true; + }; + readonly shippingAddress: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly type: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly statusHistory: { + readonly nullable: false; + readonly type: { readonly kind: 'valueObject'; readonly name: 'StatusEntry' }; + readonly many: true; + }; + }; + readonly relations: { + readonly user: { + readonly to: 'User'; + readonly cardinality: 'N:1'; + readonly on: { + readonly localFields: readonly ['userId']; + readonly targetFields: readonly ['_id']; + }; + }; + readonly invoices: { + readonly to: 'Invoice'; + readonly cardinality: '1:N'; + readonly on: { + readonly localFields: readonly ['_id']; + readonly targetFields: readonly ['orderId']; + }; + }; + }; + readonly storage: { readonly collection: 'orders' }; + }; + readonly Product: { + readonly fields: { + readonly _id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/objectId@1' }; + }; + readonly name: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly brand: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly code: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly description: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly masterCategory: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly subCategory: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly articleType: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly price: { + readonly nullable: false; + readonly type: { readonly kind: 'valueObject'; readonly name: 'Price' }; + }; + readonly image: { + readonly nullable: false; + readonly type: { readonly kind: 'valueObject'; readonly name: 'Image' }; + }; + readonly embedding: { + readonly nullable: true; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/double@1' }; + readonly many: true; + }; + }; + readonly relations: Record; + readonly storage: { readonly collection: 'products' }; + }; + readonly SearchEvent: { + readonly fields: { + readonly query: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + }; + readonly relations: Record; + readonly storage: { readonly collection: 'events' }; + readonly base: 'Event'; + }; + readonly User: { + readonly fields: { + readonly _id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/objectId@1' }; + }; + readonly name: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly email: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly address: { + readonly nullable: true; + readonly type: { readonly kind: 'valueObject'; readonly name: 'Address' }; + }; + }; + readonly relations: { + readonly carts: { + readonly to: 'Cart'; + readonly cardinality: '1:N'; + readonly on: { + readonly localFields: readonly ['_id']; + readonly targetFields: readonly ['userId']; + }; + }; + readonly orders: { + readonly to: 'Order'; + readonly cardinality: '1:N'; + readonly on: { + readonly localFields: readonly ['_id']; + readonly targetFields: readonly ['userId']; + }; + }; + }; + readonly storage: { readonly collection: 'users' }; + }; + readonly ViewProductEvent: { + readonly fields: { + readonly productId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly subCategory: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly brand: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly exitMethod: { + readonly nullable: true; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + }; + readonly relations: Record; + readonly storage: { readonly collection: 'events' }; + readonly base: 'Event'; + }; + } +> & { + readonly target: 'mongo'; + readonly targetFamily: 'mongo'; + readonly roots: { + readonly products: 'Product'; + readonly users: 'User'; + readonly carts: 'Cart'; + readonly orders: 'Order'; + readonly locations: 'Location'; + readonly invoices: 'Invoice'; + readonly events: 'Event'; + }; + readonly capabilities: {}; + readonly extensionPacks: {}; + readonly meta: {}; + readonly valueObjects: { + readonly Price: { + readonly fields: { + readonly amount: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/double@1' }; + }; + readonly currency: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + }; + }; + readonly Image: { + readonly fields: { + readonly url: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + }; + }; + readonly Address: { + readonly fields: { + readonly streetAndNumber: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly city: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly postalCode: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly country: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + }; + }; + readonly CartItem: { + readonly fields: { + readonly productId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly name: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly brand: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly amount: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/int32@1' }; + }; + readonly price: { + readonly nullable: false; + readonly type: { readonly kind: 'valueObject'; readonly name: 'Price' }; + }; + readonly image: { + readonly nullable: false; + readonly type: { readonly kind: 'valueObject'; readonly name: 'Image' }; + }; + }; + }; + readonly OrderLineItem: { + readonly fields: { + readonly productId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly name: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly brand: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly amount: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/int32@1' }; + }; + readonly price: { + readonly nullable: false; + readonly type: { readonly kind: 'valueObject'; readonly name: 'Price' }; + }; + readonly image: { + readonly nullable: false; + readonly type: { readonly kind: 'valueObject'; readonly name: 'Image' }; + }; + }; + }; + readonly StatusEntry: { + readonly fields: { + readonly status: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly timestamp: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/date@1' }; + }; + }; + }; + readonly InvoiceLineItem: { + readonly fields: { + readonly name: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/string@1' }; + }; + readonly amount: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/int32@1' }; + }; + readonly unitPrice: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/double@1' }; + }; + readonly lineTotal: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/double@1' }; + }; + }; + }; + }; + readonly profileHash: ProfileHash; +}; + +export type Contract = MongoContractWithTypeMaps; diff --git a/examples/rsc-poc-mongo/src/prisma/contract.json b/examples/rsc-poc-mongo/src/prisma/contract.json new file mode 100644 index 0000000000..cd7f3a4841 --- /dev/null +++ b/examples/rsc-poc-mongo/src/prisma/contract.json @@ -0,0 +1,1401 @@ +{ + "schemaVersion": "1", + "targetFamily": "mongo", + "target": "mongo", + "profileHash": "sha256:840de65fba7eb950a31487f74ee420b9c21205f38bce58579026747e0264e840", + "roots": { + "carts": "Cart", + "events": "Event", + "invoices": "Invoice", + "locations": "Location", + "orders": "Order", + "products": "Product", + "users": "User" + }, + "models": { + "AddToCartEvent": { + "base": "Event", + "fields": { + "brand": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "productId": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "collection": "events" + } + }, + "Cart": { + "fields": { + "_id": { + "nullable": false, + "type": { + "codecId": "mongo/objectId@1", + "kind": "scalar" + } + }, + "items": { + "many": true, + "nullable": false, + "type": { + "kind": "valueObject", + "name": "CartItem" + } + }, + "userId": { + "nullable": false, + "type": { + "codecId": "mongo/objectId@1", + "kind": "scalar" + } + } + }, + "relations": { + "user": { + "cardinality": "N:1", + "on": { + "localFields": [ + "userId" + ], + "targetFields": [ + "_id" + ] + }, + "to": "User" + } + }, + "storage": { + "collection": "carts" + } + }, + "Event": { + "discriminator": { + "field": "type" + }, + "fields": { + "_id": { + "nullable": false, + "type": { + "codecId": "mongo/objectId@1", + "kind": "scalar" + } + }, + "sessionId": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "timestamp": { + "nullable": false, + "type": { + "codecId": "mongo/date@1", + "kind": "scalar" + } + }, + "type": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "userId": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "collection": "events" + }, + "variants": { + "AddToCartEvent": { + "value": "add-to-cart" + }, + "SearchEvent": { + "value": "search" + }, + "ViewProductEvent": { + "value": "view-product" + } + } + }, + "Invoice": { + "fields": { + "_id": { + "nullable": false, + "type": { + "codecId": "mongo/objectId@1", + "kind": "scalar" + } + }, + "issuedAt": { + "nullable": false, + "type": { + "codecId": "mongo/date@1", + "kind": "scalar" + } + }, + "items": { + "many": true, + "nullable": false, + "type": { + "kind": "valueObject", + "name": "InvoiceLineItem" + } + }, + "orderId": { + "nullable": false, + "type": { + "codecId": "mongo/objectId@1", + "kind": "scalar" + } + }, + "subtotal": { + "nullable": false, + "type": { + "codecId": "mongo/double@1", + "kind": "scalar" + } + }, + "tax": { + "nullable": false, + "type": { + "codecId": "mongo/double@1", + "kind": "scalar" + } + }, + "total": { + "nullable": false, + "type": { + "codecId": "mongo/double@1", + "kind": "scalar" + } + } + }, + "relations": { + "order": { + "cardinality": "N:1", + "on": { + "localFields": [ + "orderId" + ], + "targetFields": [ + "_id" + ] + }, + "to": "Order" + } + }, + "storage": { + "collection": "invoices" + } + }, + "Location": { + "fields": { + "_id": { + "nullable": false, + "type": { + "codecId": "mongo/objectId@1", + "kind": "scalar" + } + }, + "city": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "country": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "name": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "postalCode": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "streetAndNumber": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "collection": "locations" + } + }, + "Order": { + "fields": { + "_id": { + "nullable": false, + "type": { + "codecId": "mongo/objectId@1", + "kind": "scalar" + } + }, + "items": { + "many": true, + "nullable": false, + "type": { + "kind": "valueObject", + "name": "OrderLineItem" + } + }, + "shippingAddress": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "statusHistory": { + "many": true, + "nullable": false, + "type": { + "kind": "valueObject", + "name": "StatusEntry" + } + }, + "type": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "userId": { + "nullable": false, + "type": { + "codecId": "mongo/objectId@1", + "kind": "scalar" + } + } + }, + "relations": { + "invoices": { + "cardinality": "1:N", + "on": { + "localFields": [ + "_id" + ], + "targetFields": [ + "orderId" + ] + }, + "to": "Invoice" + }, + "user": { + "cardinality": "N:1", + "on": { + "localFields": [ + "userId" + ], + "targetFields": [ + "_id" + ] + }, + "to": "User" + } + }, + "storage": { + "collection": "orders" + } + }, + "Product": { + "fields": { + "_id": { + "nullable": false, + "type": { + "codecId": "mongo/objectId@1", + "kind": "scalar" + } + }, + "articleType": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "brand": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "code": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "description": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "embedding": { + "many": true, + "nullable": true, + "type": { + "codecId": "mongo/double@1", + "kind": "scalar" + } + }, + "image": { + "nullable": false, + "type": { + "kind": "valueObject", + "name": "Image" + } + }, + "masterCategory": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "name": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "price": { + "nullable": false, + "type": { + "kind": "valueObject", + "name": "Price" + } + }, + "subCategory": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "collection": "products" + } + }, + "SearchEvent": { + "base": "Event", + "fields": { + "query": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "collection": "events" + } + }, + "User": { + "fields": { + "_id": { + "nullable": false, + "type": { + "codecId": "mongo/objectId@1", + "kind": "scalar" + } + }, + "address": { + "nullable": true, + "type": { + "kind": "valueObject", + "name": "Address" + } + }, + "email": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "name": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + } + }, + "relations": { + "carts": { + "cardinality": "1:N", + "on": { + "localFields": [ + "_id" + ], + "targetFields": [ + "userId" + ] + }, + "to": "Cart" + }, + "orders": { + "cardinality": "1:N", + "on": { + "localFields": [ + "_id" + ], + "targetFields": [ + "userId" + ] + }, + "to": "Order" + } + }, + "storage": { + "collection": "users" + } + }, + "ViewProductEvent": { + "base": "Event", + "fields": { + "brand": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "exitMethod": { + "nullable": true, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "productId": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "subCategory": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "collection": "events" + } + } + }, + "valueObjects": { + "Address": { + "fields": { + "city": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "country": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "postalCode": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "streetAndNumber": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + } + } + }, + "CartItem": { + "fields": { + "amount": { + "nullable": false, + "type": { + "codecId": "mongo/int32@1", + "kind": "scalar" + } + }, + "brand": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "image": { + "nullable": false, + "type": { + "kind": "valueObject", + "name": "Image" + } + }, + "name": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "price": { + "nullable": false, + "type": { + "kind": "valueObject", + "name": "Price" + } + }, + "productId": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + } + } + }, + "Image": { + "fields": { + "url": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + } + } + }, + "InvoiceLineItem": { + "fields": { + "amount": { + "nullable": false, + "type": { + "codecId": "mongo/int32@1", + "kind": "scalar" + } + }, + "lineTotal": { + "nullable": false, + "type": { + "codecId": "mongo/double@1", + "kind": "scalar" + } + }, + "name": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "unitPrice": { + "nullable": false, + "type": { + "codecId": "mongo/double@1", + "kind": "scalar" + } + } + } + }, + "OrderLineItem": { + "fields": { + "amount": { + "nullable": false, + "type": { + "codecId": "mongo/int32@1", + "kind": "scalar" + } + }, + "brand": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "image": { + "nullable": false, + "type": { + "kind": "valueObject", + "name": "Image" + } + }, + "name": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "price": { + "nullable": false, + "type": { + "kind": "valueObject", + "name": "Price" + } + }, + "productId": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + } + } + }, + "Price": { + "fields": { + "amount": { + "nullable": false, + "type": { + "codecId": "mongo/double@1", + "kind": "scalar" + } + }, + "currency": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + } + } + }, + "StatusEntry": { + "fields": { + "status": { + "nullable": false, + "type": { + "codecId": "mongo/string@1", + "kind": "scalar" + } + }, + "timestamp": { + "nullable": false, + "type": { + "codecId": "mongo/date@1", + "kind": "scalar" + } + } + } + } + }, + "storage": { + "collections": { + "carts": { + "indexes": [ + { + "keys": [ + { + "direction": 1, + "field": "userId" + } + ], + "unique": true + } + ], + "validator": { + "jsonSchema": { + "bsonType": "object", + "properties": { + "_id": { + "bsonType": "objectId" + }, + "items": { + "bsonType": "array", + "items": { + "bsonType": "object", + "properties": { + "amount": { + "bsonType": "int" + }, + "brand": { + "bsonType": "string" + }, + "image": { + "bsonType": "object", + "properties": { + "url": { + "bsonType": "string" + } + }, + "required": [ + "url" + ] + }, + "name": { + "bsonType": "string" + }, + "price": { + "bsonType": "object", + "properties": { + "amount": { + "bsonType": "double" + }, + "currency": { + "bsonType": "string" + } + }, + "required": [ + "amount", + "currency" + ] + }, + "productId": { + "bsonType": "string" + } + }, + "required": [ + "amount", + "brand", + "image", + "name", + "price", + "productId" + ] + } + }, + "userId": { + "bsonType": "objectId" + } + }, + "required": [ + "_id", + "items", + "userId" + ] + }, + "validationAction": "error", + "validationLevel": "strict" + } + }, + "events": { + "indexes": [ + { + "keys": [ + { + "direction": 1, + "field": "userId" + }, + { + "direction": -1, + "field": "timestamp" + } + ] + }, + { + "expireAfterSeconds": 7776000, + "keys": [ + { + "direction": 1, + "field": "timestamp" + } + ] + } + ], + "validator": { + "jsonSchema": { + "bsonType": "object", + "oneOf": [ + { + "properties": { + "brand": { + "bsonType": "string" + }, + "exitMethod": { + "bsonType": [ + "null", + "string" + ] + }, + "productId": { + "bsonType": "string" + }, + "subCategory": { + "bsonType": "string" + }, + "type": { + "enum": [ + "view-product" + ] + } + }, + "required": [ + "brand", + "productId", + "subCategory", + "type" + ] + }, + { + "properties": { + "query": { + "bsonType": "string" + }, + "type": { + "enum": [ + "search" + ] + } + }, + "required": [ + "query", + "type" + ] + }, + { + "properties": { + "brand": { + "bsonType": "string" + }, + "productId": { + "bsonType": "string" + }, + "type": { + "enum": [ + "add-to-cart" + ] + } + }, + "required": [ + "brand", + "productId", + "type" + ] + } + ], + "properties": { + "_id": { + "bsonType": "objectId" + }, + "sessionId": { + "bsonType": "string" + }, + "timestamp": { + "bsonType": "date" + }, + "type": { + "bsonType": "string" + }, + "userId": { + "bsonType": "string" + } + }, + "required": [ + "_id", + "sessionId", + "timestamp", + "type", + "userId" + ] + }, + "validationAction": "error", + "validationLevel": "strict" + } + }, + "invoices": { + "indexes": [ + { + "keys": [ + { + "direction": 1, + "field": "orderId" + } + ] + }, + { + "keys": [ + { + "direction": -1, + "field": "issuedAt" + } + ], + "sparse": true + } + ], + "validator": { + "jsonSchema": { + "bsonType": "object", + "properties": { + "_id": { + "bsonType": "objectId" + }, + "issuedAt": { + "bsonType": "date" + }, + "items": { + "bsonType": "array", + "items": { + "bsonType": "object", + "properties": { + "amount": { + "bsonType": "int" + }, + "lineTotal": { + "bsonType": "double" + }, + "name": { + "bsonType": "string" + }, + "unitPrice": { + "bsonType": "double" + } + }, + "required": [ + "amount", + "lineTotal", + "name", + "unitPrice" + ] + } + }, + "orderId": { + "bsonType": "objectId" + }, + "subtotal": { + "bsonType": "double" + }, + "tax": { + "bsonType": "double" + }, + "total": { + "bsonType": "double" + } + }, + "required": [ + "_id", + "issuedAt", + "items", + "orderId", + "subtotal", + "tax", + "total" + ] + }, + "validationAction": "error", + "validationLevel": "strict" + } + }, + "locations": { + "indexes": [ + { + "collation": { + "locale": "en", + "strength": 2 + }, + "keys": [ + { + "direction": 1, + "field": "city" + }, + { + "direction": 1, + "field": "country" + } + ] + } + ], + "validator": { + "jsonSchema": { + "bsonType": "object", + "properties": { + "_id": { + "bsonType": "objectId" + }, + "city": { + "bsonType": "string" + }, + "country": { + "bsonType": "string" + }, + "name": { + "bsonType": "string" + }, + "postalCode": { + "bsonType": "string" + }, + "streetAndNumber": { + "bsonType": "string" + } + }, + "required": [ + "_id", + "city", + "country", + "name", + "postalCode", + "streetAndNumber" + ] + }, + "validationAction": "error", + "validationLevel": "strict" + } + }, + "orders": { + "indexes": [ + { + "keys": [ + { + "direction": 1, + "field": "userId" + } + ] + } + ], + "validator": { + "jsonSchema": { + "bsonType": "object", + "properties": { + "_id": { + "bsonType": "objectId" + }, + "items": { + "bsonType": "array", + "items": { + "bsonType": "object", + "properties": { + "amount": { + "bsonType": "int" + }, + "brand": { + "bsonType": "string" + }, + "image": { + "bsonType": "object", + "properties": { + "url": { + "bsonType": "string" + } + }, + "required": [ + "url" + ] + }, + "name": { + "bsonType": "string" + }, + "price": { + "bsonType": "object", + "properties": { + "amount": { + "bsonType": "double" + }, + "currency": { + "bsonType": "string" + } + }, + "required": [ + "amount", + "currency" + ] + }, + "productId": { + "bsonType": "string" + } + }, + "required": [ + "amount", + "brand", + "image", + "name", + "price", + "productId" + ] + } + }, + "shippingAddress": { + "bsonType": "string" + }, + "statusHistory": { + "bsonType": "array", + "items": { + "bsonType": "object", + "properties": { + "status": { + "bsonType": "string" + }, + "timestamp": { + "bsonType": "date" + } + }, + "required": [ + "status", + "timestamp" + ] + } + }, + "type": { + "bsonType": "string" + }, + "userId": { + "bsonType": "objectId" + } + }, + "required": [ + "_id", + "items", + "shippingAddress", + "statusHistory", + "type", + "userId" + ] + }, + "validationAction": "error", + "validationLevel": "strict" + } + }, + "products": { + "indexes": [ + { + "keys": [ + { + "direction": "text", + "field": "name" + }, + { + "direction": "text", + "field": "description" + } + ], + "weights": { + "description": 1, + "name": 10 + } + }, + { + "keys": [ + { + "direction": 1, + "field": "brand" + }, + { + "direction": 1, + "field": "subCategory" + } + ] + }, + { + "keys": [ + { + "direction": "hashed", + "field": "code" + } + ] + } + ], + "validator": { + "jsonSchema": { + "bsonType": "object", + "properties": { + "_id": { + "bsonType": "objectId" + }, + "articleType": { + "bsonType": "string" + }, + "brand": { + "bsonType": "string" + }, + "code": { + "bsonType": "string" + }, + "description": { + "bsonType": "string" + }, + "embedding": { + "bsonType": "array", + "items": { + "bsonType": "double" + } + }, + "image": { + "bsonType": "object", + "properties": { + "url": { + "bsonType": "string" + } + }, + "required": [ + "url" + ] + }, + "masterCategory": { + "bsonType": "string" + }, + "name": { + "bsonType": "string" + }, + "price": { + "bsonType": "object", + "properties": { + "amount": { + "bsonType": "double" + }, + "currency": { + "bsonType": "string" + } + }, + "required": [ + "amount", + "currency" + ] + }, + "subCategory": { + "bsonType": "string" + } + }, + "required": [ + "_id", + "articleType", + "brand", + "code", + "description", + "image", + "masterCategory", + "name", + "price", + "subCategory" + ] + }, + "validationAction": "error", + "validationLevel": "strict" + } + }, + "users": { + "indexes": [ + { + "keys": [ + { + "direction": 1, + "field": "email" + } + ], + "unique": true + } + ], + "validator": { + "jsonSchema": { + "bsonType": "object", + "properties": { + "_id": { + "bsonType": "objectId" + }, + "address": { + "oneOf": [ + { + "bsonType": "null" + }, + { + "bsonType": "object", + "properties": { + "city": { + "bsonType": "string" + }, + "country": { + "bsonType": "string" + }, + "postalCode": { + "bsonType": "string" + }, + "streetAndNumber": { + "bsonType": "string" + } + }, + "required": [ + "city", + "country", + "postalCode", + "streetAndNumber" + ] + } + ] + }, + "email": { + "bsonType": "string" + }, + "name": { + "bsonType": "string" + } + }, + "required": [ + "_id", + "email", + "name" + ] + }, + "validationAction": "error", + "validationLevel": "strict" + } + } + }, + "storageHash": "sha256:7fc150cbd971b2e0158b09648c0d46a4602641da32e45e76bf75b8ecef3eef37" + }, + "capabilities": {}, + "extensionPacks": {}, + "meta": {}, + "_generated": { + "warning": "⚠️ GENERATED FILE - DO NOT EDIT", + "message": "This file is automatically generated by \"prisma-next contract emit\".", + "regenerate": "To regenerate, run: prisma-next contract emit" + } +} \ No newline at end of file diff --git a/examples/rsc-poc-mongo/src/server-components/event-type-stats.tsx b/examples/rsc-poc-mongo/src/server-components/event-type-stats.tsx new file mode 100644 index 0000000000..b138beb6f0 --- /dev/null +++ b/examples/rsc-poc-mongo/src/server-components/event-type-stats.tsx @@ -0,0 +1,77 @@ +import { acc } from '@prisma-next/mongo-query-builder'; +import type { DbOptions } from '../lib/db'; +import { getDb } from '../lib/db'; + +/** + * Server Component #4 / 5 — aggregate pipeline via `$group`. + * + * Exercises the query-builder's `.group()` stage: group `events` by + * their discriminator `type` and count occurrences. Result shape is + * `[{ _id: string, count: number }, ...]`. + * + * This is the Mongo analogue of the Postgres app's + * `` — both probe the aggregate code path. On + * Mongo, `$group` is a pipeline stage the adapter lowers into an + * aggregate wire command, so the driver issues a single + * `aggregate` command (one pool check-out, one command) regardless + * of how many input documents there are. + * + * Included in the mix to verify aggregate plans compose safely with + * the shared runtime under concurrent rendering. There is no + * aggregate-specific mutable state in `MongoRuntimeImpl`, so this + * should behave identically to the other components; the PoC + * verifies rather than assumes. + */ +export interface EventTypeStatsProps { + readonly poolMax?: number | undefined; +} + +interface EventTypeCount { + readonly _id: string; + readonly count: number; +} + +export async function EventTypeStats({ poolMax }: EventTypeStatsProps) { + const opts: DbOptions = { poolMax }; + const db = await getDb(opts); + + const plan = db.query + .from('events') + .group((f) => ({ + _id: f.type, + count: acc.count(), + })) + .sort({ count: -1 }) + .build(); + + const rows: EventTypeCount[] = []; + for await (const row of db.runtime.execute(plan)) { + rows.push(row as EventTypeCount); + } + + return ( +
+

Event type breakdown

+

+ + db.query.from('events').group({'{'} _id: type, count: $count {'}'}).sort(...) + +

+ {rows.length === 0 ? ( +

+ No events yet. Run pnpm seed. +

+ ) : ( +
    + {rows.map((row) => ( +
  • + {row._id} + {row.count} + events +
  • + ))} +
+ )} +
+ ); +} diff --git a/examples/rsc-poc-mongo/src/server-components/orders-with-user.tsx b/examples/rsc-poc-mongo/src/server-components/orders-with-user.tsx new file mode 100644 index 0000000000..16048ef1b6 --- /dev/null +++ b/examples/rsc-poc-mongo/src/server-components/orders-with-user.tsx @@ -0,0 +1,60 @@ +import type { DbOptions } from '../lib/db'; +import { getDb } from '../lib/db'; + +/** + * Server Component #2 / 5 — ORM with `.include()`. + * + * Exercises the ORM's multi-query include dispatch on the Mongo side: + * the parent `find` on `orders` plus a follow-up `find` on `users` + * keyed by the collected `userId`s. Under the hood these run as two + * sequential MongoDB commands sharing the same pool. + * + * This is the Mongo analogue of the Postgres app's + * ``. The shapes differ (no transactions per + * include, no `acquireRuntimeScope` to wrap the pair) but the + * observable footprint is the same: one component, two commands, two + * pool check-outs under concurrent rendering. + * + * Included in the five-component mix specifically to make the + * command-count doubling visible in the `` — it makes + * the contrast between single-query and multi-query components easy + * to read during a manual stress run. + */ +export interface OrdersWithUserProps { + readonly poolMax?: number | undefined; + readonly limit?: number; +} + +export async function OrdersWithUser({ poolMax, limit = 5 }: OrdersWithUserProps) { + const opts: DbOptions = { poolMax }; + const db = await getDb(opts); + const orders = await db.orm.orders.include('user').orderBy({ _id: -1 }).take(limit).all(); + + return ( +
+

Orders with users

+

+ db.orm.orders.include('user').take({limit}).all() +

+ {orders.length === 0 ? ( +

+ No orders yet. Run pnpm seed. +

+ ) : ( +
    + {orders.map((order) => ( +
  • + {order.type} + + {order.user?.name ?? '(unknown user)'} + + {' '} + ({order.items.length} item{order.items.length === 1 ? '' : 's'}) + +
  • + ))} +
+ )} +
+ ); +} diff --git a/examples/rsc-poc-mongo/src/server-components/product-list.tsx b/examples/rsc-poc-mongo/src/server-components/product-list.tsx new file mode 100644 index 0000000000..ae51700afa --- /dev/null +++ b/examples/rsc-poc-mongo/src/server-components/product-list.tsx @@ -0,0 +1,57 @@ +import type { DbOptions } from '../lib/db'; +import { getDb } from '../lib/db'; + +/** + * Server Component #1 / 5 — plain ORM read. + * + * Exercises the simplest Mongo ORM code path: + * `Collection.orderBy().take().all()`. No includes, no aggregates, + * no query-builder pipeline — this is the baseline shape other + * components are measured against. + * + * Under concurrent rendering, each instance of this component issues + * a MongoDB `find` command through the shared runtime. The Mongo + * driver's internal pool checks out a connection for the duration of + * the query and checks it back in on completion. `lib/db.ts` wires + * `connectionCheckedOut` / `connectionCheckedIn` event listeners into + * `lib/diag`, so each invocation bumps those counters. + * + * Unlike the Postgres app's ``, there is no verification + * round-trip to observe — `MongoRuntimeImpl` has no verification state + * (hypothesis H5 in the project plan). That contrast is the entire + * point of running the Mongo app alongside the Postgres one. + */ +export interface ProductListProps { + readonly poolMax?: number | undefined; + readonly limit?: number; +} + +export async function ProductList({ poolMax, limit = 10 }: ProductListProps) { + const opts: DbOptions = { poolMax }; + const db = await getDb(opts); + const products = await db.orm.products.orderBy({ name: 1 }).take(limit).all(); + + return ( +
+

Products

+

+ db.orm.products.orderBy(name).take({limit}).all() +

+ {products.length === 0 ? ( +

+ No products yet. Run pnpm seed. +

+ ) : ( +
    + {products.map((product) => ( +
  • + {product.brand} + {product.name} + — ${product.price.amount.toFixed(2)} +
  • + ))} +
+ )} +
+ ); +} diff --git a/examples/rsc-poc-mongo/src/server-components/products-by-search.tsx b/examples/rsc-poc-mongo/src/server-components/products-by-search.tsx new file mode 100644 index 0000000000..be23f896d5 --- /dev/null +++ b/examples/rsc-poc-mongo/src/server-components/products-by-search.tsx @@ -0,0 +1,91 @@ +import { MongoFieldFilter, MongoOrExpr } from '@prisma-next/mongo-query-ast/execution'; +import { MongoParamRef } from '@prisma-next/mongo-value'; +import type { DbOptions } from '../lib/db'; +import { getDb } from '../lib/db'; +import type { FieldOutputTypes } from '../prisma/contract.d'; + +type Product = FieldOutputTypes['Product']; + +/** + * Server Component #3 / 5 — query-builder pipeline path. + * + * Exercises `db.query.from(...).match(...).sort(...).limit(...).build()` + * followed by direct `runtime.execute(plan)`. This is the Mongo + * analogue of the Postgres app's `` — the component + * that bypasses the ORM entirely and drops to the query-builder + + * runtime pair. + * + * Worth covering in the five-component mix because the + * query-builder path produces a `MongoQueryPlan` that goes through a + * different code path than ORM operations: the ORM's + * `MongoCollectionImpl` translates its state into wire commands via + * internal builders, while `mongoQuery` produces the plan directly + * from the chainable builder API. If the runtime ever grows shared + * mutable state that's touched by one path but not the other, a + * mixed five-component page like this is where the contrast would + * show up. + * + * Uses a case-insensitive regex filter on `name`, `brand`, and + * `articleType` — mirrors the shape `retail-store` uses for its + * search page so anyone cross-referencing sees a familiar pattern. + */ +export interface ProductsBySearchProps { + readonly poolMax?: number | undefined; + readonly query?: string; + readonly limit?: number; +} + +const DEFAULT_QUERY = 'shirt'; + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export async function ProductsBySearch({ + poolMax, + query = DEFAULT_QUERY, + limit = 10, +}: ProductsBySearchProps) { + const opts: DbOptions = { poolMax }; + const db = await getDb(opts); + + const regex = new MongoParamRef(new RegExp(escapeRegex(query), 'i')); + const filter = MongoOrExpr.of([ + MongoFieldFilter.of('name', '$regex', regex), + MongoFieldFilter.of('brand', '$regex', regex), + MongoFieldFilter.of('articleType', '$regex', regex), + ]); + + const plan = db.query.from('products').match(filter).sort({ name: 1 }).limit(limit).build(); + + const results: Product[] = []; + for await (const row of db.runtime.execute(plan)) { + results.push(row as Product); + } + + return ( +
+

Products by search (query builder)

+

+ + db.query.from('products').match(/{query}/i).limit({limit}).build() + +

+ {results.length === 0 ? ( +

+ No matches for {query}. Run pnpm seed or try another term. +

+ ) : ( +
    + {results.map((product) => ( +
  • + {product.articleType} + {product.name} + — {product.brand} +
  • + ))} +
+ )} +
+ ); +} diff --git a/examples/rsc-poc-mongo/src/server-components/search-events.tsx b/examples/rsc-poc-mongo/src/server-components/search-events.tsx new file mode 100644 index 0000000000..2135553d36 --- /dev/null +++ b/examples/rsc-poc-mongo/src/server-components/search-events.tsx @@ -0,0 +1,73 @@ +import type { DbOptions } from '../lib/db'; +import { getDb } from '../lib/db'; + +/** + * Server Component #5 / 5 — polymorphism variant path. + * + * Exercises `db.orm.events.variant('SearchEvent')` — the ORM's + * polymorphism filter, which on Mongo surfaces as a `$match` on the + * discriminator field (`type` here). The resulting rows are typed as + * the variant shape (`SearchEvent`), not the base `Event`, so + * variant-only fields like `query` are accessible without a cast. + * + * This probe is worth running alongside the other four because + * polymorphism dispatch is one of the places the Mongo ORM has + * variant-specific code (discriminator injection on write, variant + * filter on read). If the shared runtime ever gained state that + * discriminator resolution touched, a concurrent render of this + * component would surface it. There's no such state today — this is + * insurance, documented as insurance. + * + * Returns the 10 most recent search events (by `timestamp` + * descending) along with the search query text. Empty state covers + * the no-seed case. + */ +export interface SearchEventsProps { + readonly poolMax?: number | undefined; + readonly limit?: number; +} + +export async function SearchEvents({ poolMax, limit = 10 }: SearchEventsProps) { + const opts: DbOptions = { poolMax }; + const db = await getDb(opts); + + // Type-level note: `.variant('SearchEvent')` filters rows to + // documents with `type === 'search'` at runtime, but the ORM's row + // type narrowing is intentionally conservative — variant-only + // fields like `query` aren't surfaced on the returned type. The + // `retail-store` example works around this in its tests with + // `expect(event).toHaveProperty('query')`. For this PoC we don't + // need to display variant-only fields; `userId` and `sessionId` + // live on the base `Event` and are enough to make the filtering + // observable in the rendered UI. + const rows = await db.orm.events + .variant('SearchEvent') + .orderBy({ timestamp: -1 }) + .take(limit) + .all(); + + return ( +
+

Recent searches (polymorphism)

+

+ db.orm.events.variant('SearchEvent').take({limit}).all() +

+ {rows.length === 0 ? ( +

+ No search events yet. Run pnpm seed. +

+ ) : ( +
    + {rows.map((row) => ( +
  • + {row.type} + {row.userId} + — session + {row.sessionId} +
  • + ))} +
+ )} +
+ ); +} diff --git a/examples/rsc-poc-mongo/test/concurrency-invariants.test.ts b/examples/rsc-poc-mongo/test/concurrency-invariants.test.ts new file mode 100644 index 0000000000..866db034fb --- /dev/null +++ b/examples/rsc-poc-mongo/test/concurrency-invariants.test.ts @@ -0,0 +1,321 @@ +/** + * Integration tests pinning the Mongo-side concurrency invariants. + * + * Mongo counterpart to `examples/rsc-poc-postgres/test/always-mode-invariant.test.ts`. + * The Postgres tests pin H2 (redundant cold-start marker reads under + * `onFirstUse`) and H3 (per-query verification under `always`). Neither + * applies on Mongo: `MongoRuntimeImpl` has no verification state and + * issues no marker reads — that's hypothesis H5 in the project plan. + * + * What we *do* pin here: + * + * - **H5 asymmetry**: no Mongo command carries a marker-read sibling. + * The driver's command counter advances by **exactly K** for K + * concurrent queries, not K × some-multiplier, because there is no + * pre-query verification. + * + * - **Balance invariants under concurrency**: every pool check-out is + * matched by a check-in; every command started resolves as either + * succeeded or failed. K ∈ {1, 5, 50} covers single-query, RSC page + * shape, and well-past-pool-default shapes. + * + * - **No cold-start anomaly**: firing a burst of K concurrent queries + * on a cold runtime produces exactly K commands — not K + 1 (or + * similar) as a stand-in for the Postgres-side H2 race. This is the + * test that makes the asymmetry with the Postgres app explicit. + * + * ## Test level: process, not HTTP + * + * Same rationale as the Postgres invariant test: the observables live + * in the runtime + driver, not in RSC. HTTP-level coverage comes from + * the k6 scripts + `/diag`. + * + * ## Why MongoMemoryReplSet (not MongoMemoryServer) + * + * Matches `retail-store`'s test setup. A replica set is required for + * transactions and some aggregation features; while we don't use them + * in this file today, keeping the shape consistent with retail-store + * means a future test that needs them doesn't have to re-scaffold. + * + * Unlike ppg-dev (which the Postgres tests must skip around because it + * rejects concurrent connections), `mongodb-memory-server` accepts + * concurrent connections, so these tests run in CI without any + * conditional guards. + * + * ## Why each test builds its own runtime + * + * Same reasoning as the Postgres side: tests need cold-start behavior + * for at least one phase of their assertions, and `lib/db.ts`'s + * `globalThis` registry would otherwise bleed state between tests. + * Each test builds a disposable client + runtime against the shared + * `MongoMemoryReplSet` and drops the database before running. + */ + +import { createMongoAdapter } from '@prisma-next/adapter-mongo'; +import { MongoDriverImpl } from '@prisma-next/driver-mongo'; +import { validateMongoContract } from '@prisma-next/mongo-contract'; +import { mongoOrm } from '@prisma-next/mongo-orm'; +import { createMongoRuntime, type MongoRuntime } from '@prisma-next/mongo-runtime'; +import { timeouts } from '@prisma-next/test-utils'; +import { MongoClient } from 'mongodb'; +import { MongoMemoryReplSet } from 'mongodb-memory-server'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { + recordCommandFailed, + recordCommandStarted, + recordCommandSucceeded, + recordConnectionCheckedIn, + recordConnectionCheckedOut, + recordConnectionClosed, + recordConnectionCreated, + reset, + snapshot, +} from '../src/lib/diag'; +import type { Contract } from '../src/prisma/contract.d'; +import contractJson from '../src/prisma/contract.json' with { type: 'json' }; + +interface TestRuntime { + readonly client: MongoClient; + readonly runtime: MongoRuntime; + readonly orm: ReturnType>; + readonly poolMax: number; + close(): Promise; +} + +const { contract } = validateMongoContract(contractJson); + +let sharedReplSet: MongoMemoryReplSet | undefined; + +async function getSharedReplSet(): Promise { + if (!sharedReplSet) { + sharedReplSet = await MongoMemoryReplSet.create({ + replSet: { count: 1, storageEngine: 'wiredTiger' }, + }); + } + return sharedReplSet; +} + +/** + * Wires a fresh MongoClient + Prisma Next Mongo runtime against the + * shared in-memory replica set, attaching the same event listeners the + * app's `lib/db.ts` does so the diagnostic counters behave identically. + * Mirrors `createEntry()` in `src/lib/db.ts` closely enough that the + * instrumentation under test is the real thing, not a stub. + */ +async function createTestRuntime(poolMax: number): Promise { + const replSet = await getSharedReplSet(); + const uri = replSet.getUri(); + // Unique DB per test run so no ordering coupling between tests. + const dbName = `rsc_poc_mongo_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`; + + const client = new MongoClient(uri, { + maxPoolSize: poolMax, + monitorCommands: true, + waitQueueTimeoutMS: 5_000, + }); + + // Attach listeners BEFORE connect() so we don't miss setup-time + // events. Mirrors `lib/db.ts` exactly. + client.on('commandStarted', () => recordCommandStarted(poolMax)); + client.on('commandSucceeded', () => recordCommandSucceeded(poolMax)); + client.on('commandFailed', () => recordCommandFailed(poolMax)); + client.on('connectionCheckedOut', () => recordConnectionCheckedOut(poolMax)); + client.on('connectionCheckedIn', () => recordConnectionCheckedIn(poolMax)); + client.on('connectionCreated', () => recordConnectionCreated(poolMax)); + client.on('connectionClosed', () => recordConnectionClosed(poolMax)); + + await client.connect(); + + const driver = MongoDriverImpl.fromDb(client.db(dbName)); + const adapter = createMongoAdapter(); + const runtime = createMongoRuntime({ adapter, driver, contract, targetId: 'mongo' }); + const orm = mongoOrm({ contract, executor: runtime }); + + // Seed minimal data so reads return something the ORM's stitching + // actually does work on. + await orm.products.create({ + name: 'Test Shirt', + brand: 'Test', + code: 'TST-001', + description: 'Test product', + masterCategory: 'Apparel', + subCategory: 'Topwear', + articleType: 'Shirts', + price: { amount: 19.99, currency: 'USD' }, + image: { url: '/images/products/tst-001.jpg' }, + embedding: null, + }); + + return { + client, + runtime, + orm, + poolMax, + async close() { + await runtime.close(); + await client.close(); + }, + }; +} + +/** + * Exercises the same ORM path the five Server Components use. A + * simpler `runtime.execute(somePlan)` would bypass `acquireRuntimeScope` + * equivalents — we want the production-shape path so the test catches + * regressions in the same place users would see them. + */ +async function runOneQuery(rt: TestRuntime): Promise { + await rt.orm.products.take(1).all(); +} + +async function runKParallelQueries(rt: TestRuntime, k: number): Promise { + const tasks: Array> = []; + for (let i = 0; i < k; i++) { + tasks.push(runOneQuery(rt)); + } + await Promise.all(tasks); +} + +async function withFreshRuntime( + poolMax: number, + fn: (rt: TestRuntime) => Promise, +): Promise { + // Counters persist via `globalThis`; clear before the test so its + // snapshot reflects only its own work. Seed calls in + // `createTestRuntime` also emit events, so we reset AFTER creating + // the runtime (but before the test's own queries). + const rt = await createTestRuntime(poolMax); + reset(); + try { + await fn(rt); + } finally { + await rt.close(); + } +} + +describe( + 'Mongo runtime invariants under concurrency', + { timeout: timeouts.spinUpMongoMemoryServer }, + () => { + beforeAll(async () => { + // Warm up the replica set once up front so the first test doesn't + // absorb the binary-download delay. + await getSharedReplSet(); + }, timeouts.spinUpMongoMemoryServer); + + afterAll(async () => { + if (sharedReplSet) { + await sharedReplSet.stop(); + sharedReplSet = undefined; + } + }, timeouts.spinUpMongoMemoryServer); + + afterEach(() => { + reset(); + }); + + describe('H5 — no marker reads, no cold-start race', () => { + it.each([ + { name: 'K=1 (single query)', k: 1 }, + { name: 'K=5 (matches the RSC page shape)', k: 5 }, + { name: 'K=50 (well past the default pool max)', k: 50 }, + ])('K concurrent queries issue exactly K commands, with no verification multiplier: $name', async ({ + k, + }) => { + await withFreshRuntime(100, async (rt) => { + await runKParallelQueries(rt, k); + const snap = snapshot(100); + + // The core H5 invariant: no marker-read multiplier. If + // Mongo ever grew a verification round-trip per query, + // `commandsStarted` would exceed K and this test would + // surface it immediately. + expect(snap.commandsStarted).toBe(k); + + // Every command resolves as either succeeded or failed. + // With this dataset (trivial find on a 1-row collection) + // all should succeed, but the balance invariant is the + // stable assertion — it holds even if the fixture grows. + expect(snap.commandsStarted).toBe(snap.commandsSucceeded + snap.commandsFailed); + expect(snap.commandsFailed).toBe(0); + }); + }); + + it('cold-start burst issues exactly K commands (no H2 analogue)', async () => { + await withFreshRuntime(100, async (rt) => { + const K = 5; + + // First burst on a cold-ish runtime. This is the mirror of + // the Postgres H2 test, where the same burst on `onFirstUse` + // mode produces 1..K marker reads. On Mongo we expect + // exactly K commands and zero extra — no verification + // path exists to race on. + await runKParallelQueries(rt, K); + const coldSnap = snapshot(100); + + expect(coldSnap.commandsStarted).toBe(K); + expect(coldSnap.commandsStarted).toBe( + coldSnap.commandsSucceeded + coldSnap.commandsFailed, + ); + + // Second burst. On the Postgres side the warm-burst snapshot + // would show no new marker reads; on Mongo it's just +K more + // commands. Same arithmetic, different underlying mechanism. + await runKParallelQueries(rt, K); + const warmSnap = snapshot(100); + + expect(warmSnap.commandsStarted).toBe(coldSnap.commandsStarted + K); + expect(warmSnap.commandsStarted).toBe( + warmSnap.commandsSucceeded + warmSnap.commandsFailed, + ); + }); + }); + }); + + describe('Balance invariants', () => { + it.each([ + { name: 'K=1', k: 1, poolMax: 100 }, + { name: 'K=5', k: 5, poolMax: 100 }, + { name: 'K=50 with large pool', k: 50, poolMax: 100 }, + { name: 'K=50 with small pool (contention)', k: 50, poolMax: 5 }, + ])('pool check-outs and check-ins balance: $name', async ({ k, poolMax }) => { + await withFreshRuntime(poolMax, async (rt) => { + await runKParallelQueries(rt, k); + const snap = snapshot(poolMax); + + // Every pool check-out resolves to a check-in. Desync + // here would indicate either an instrumentation bug or a + // real connection leak in the driver/runtime. Holds under + // contention too (K=50 on poolMax=5) because waiters + // queue rather than leaking. + expect(snap.connectionsCheckedOut).toBe(snap.connectionsCheckedIn); + + // At least K check-outs happened (one per query). More + // is fine — internal driver monitoring may contribute. + expect(snap.connectionsCheckedOut).toBeGreaterThanOrEqual(k); + }); + }); + + it('repeated bursts keep balance and linearly grow command count', async () => { + await withFreshRuntime(100, async (rt) => { + const K = 5; + const BURSTS = 3; + + for (let i = 0; i < BURSTS; i++) { + await runKParallelQueries(rt, K); + } + + const snap = snapshot(100); + + // K commands per burst, cumulative. + expect(snap.commandsStarted).toBe(K * BURSTS); + expect(snap.commandsStarted).toBe(snap.commandsSucceeded + snap.commandsFailed); + expect(snap.commandsFailed).toBe(0); + + // Balance holds across bursts. + expect(snap.connectionsCheckedOut).toBe(snap.connectionsCheckedIn); + }); + }); + }); + }, +); diff --git a/examples/rsc-poc-mongo/tsconfig.json b/examples/rsc-poc-mongo/tsconfig.json new file mode 100644 index 0000000000..401a7df76a --- /dev/null +++ b/examples/rsc-poc-mongo/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": ["@prisma-next/tsconfig/base"], + "compilerOptions": { + "outDir": "dist", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "preserve", + "allowJs": true, + "noEmit": true, + "incremental": true, + "isolatedModules": true, + "resolveJsonModule": true, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "app/**/*.ts", + "app/**/*.tsx", + "src/**/*.ts", + "src/**/*.tsx", + "test/**/*.ts", + "scripts/**/*.ts", + "next-env.d.ts", + ".next/types/**/*.ts" + ], + "exclude": ["dist", ".next", "node_modules"] +} diff --git a/examples/rsc-poc-mongo/turbo.json b/examples/rsc-poc-mongo/turbo.json new file mode 100644 index 0000000000..f2aa1b4767 --- /dev/null +++ b/examples/rsc-poc-mongo/turbo.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "inputs": [ + "src/**", + "app/**", + "public/**", + "next.config.js", + "package.json", + "tsconfig.json" + ], + "outputs": [".next/**", "!.next/cache/**"] + } + } +} diff --git a/examples/rsc-poc-mongo/vitest.config.ts b/examples/rsc-poc-mongo/vitest.config.ts new file mode 100644 index 0000000000..5d88003253 --- /dev/null +++ b/examples/rsc-poc-mongo/vitest.config.ts @@ -0,0 +1,13 @@ +import { timeouts } from '@prisma-next/test-utils'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + pool: 'threads', + maxWorkers: 1, + isolate: false, + testTimeout: timeouts.spinUpMongoMemoryServer, + hookTimeout: timeouts.spinUpMongoMemoryServer, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4694769746..19716523c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -440,6 +440,91 @@ importers: specifier: 'catalog:' version: 4.0.17(@types/node@24.10.4)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.1) + examples/rsc-poc-mongo: + dependencies: + '@prisma-next/adapter-mongo': + specifier: workspace:* + version: link:../../packages/3-mongo-target/2-mongo-adapter + '@prisma-next/contract': + specifier: workspace:* + version: link:../../packages/1-framework/0-foundation/contract + '@prisma-next/driver-mongo': + specifier: workspace:* + version: link:../../packages/3-mongo-target/3-mongo-driver + '@prisma-next/middleware-telemetry': + specifier: workspace:* + version: link:../../packages/3-extensions/middleware-telemetry + '@prisma-next/mongo-contract': + specifier: workspace:* + version: link:../../packages/2-mongo-family/1-foundation/mongo-contract + '@prisma-next/mongo-orm': + specifier: workspace:* + version: link:../../packages/2-mongo-family/5-query-builders/orm + '@prisma-next/mongo-query-ast': + specifier: workspace:* + version: link:../../packages/2-mongo-family/4-query/query-ast + '@prisma-next/mongo-query-builder': + specifier: workspace:* + version: link:../../packages/2-mongo-family/5-query-builders/query-builder + '@prisma-next/mongo-runtime': + specifier: workspace:* + version: link:../../packages/2-mongo-family/7-runtime + '@prisma-next/mongo-value': + specifier: workspace:* + version: link:../../packages/2-mongo-family/1-foundation/mongo-value + mongodb: + specifier: 'catalog:' + version: 6.21.0 + next: + specifier: ^16.1.7 + version: 16.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + devDependencies: + '@prisma-next/cli': + specifier: workspace:* + version: link:../../packages/1-framework/3-tooling/cli + '@prisma-next/family-mongo': + specifier: workspace:* + version: link:../../packages/2-mongo-family/9-family + '@prisma-next/mongo-contract-psl': + specifier: workspace:* + version: link:../../packages/2-mongo-family/2-authoring/contract-psl + '@prisma-next/target-mongo': + specifier: workspace:* + version: link:../../packages/3-mongo-target/1-mongo-target + '@prisma-next/test-utils': + specifier: workspace:* + version: link:../../test/utils + '@prisma-next/tsconfig': + specifier: workspace:* + version: link:../../packages/0-config/tsconfig + '@types/node': + specifier: 'catalog:' + version: 24.10.4 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + mongodb-memory-server: + specifier: 'catalog:' + version: 10.4.3 + tsx: + specifier: ^4.19.2 + version: 4.20.6 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.0.17(@types/node@24.10.4)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.1) + examples/rsc-poc-postgres: dependencies: '@prisma-next/adapter-postgres': From 2ede01a13c9a37c3b0a87a181f08a139ab348f30 Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Wed, 22 Apr 2026 15:43:37 +0200 Subject: [PATCH 7/8] docs(rsc-poc): findings write-up for RSC concurrency safety PoC Consolidates observations from all six implementation commits into a single doc per hypothesis H1-H5, with recommended fix for H2 and operation-accounting tables showing why Postgres and Mongo have different command counts per page. Verdict: Prisma Next runs correctly under RSC concurrency on both families. One performance bug (H2 - redundant cold-start marker reads) with a shared-promise-dedupe fix sketched out. One sizing observation (H4) documented; fix deferred to May per plan. Clarification on per-page operation counts that wasn't obvious from commit messages: Postgres / page = 6 acquires, 7 queries. - PostsWithAuthors's include uses dispatchWithMultiQueryIncludes which acquires one runtime scope and runs parent + include on it (1 acquire, 2 queries). - SimilarPostsSample uses two separate ORM chains (2 acquires, 2 queries) because seed + similarity are separate awaited calls. - The other 3 components are 1:1. - On onFirstUse cold start only 5 of the 7 queries race through verifyPlanIfNeeded because the 2nd queries in include-using and multi-chain components are sequential within their component, so only the first query of each component is in flight at first-touch. Mongo / page = 5 commands, 1:1 with components. - include() uses $lookup in a single aggregate command, not a second find. That's why the Mongo ratio is 5:5 while Postgres is 6:7. This file lives at projects/rsc-concurrency-safety/notes.md during execution and migrates to docs/reference/rsc-concurrency-findings.md at close-out per the project workflow. Refs: TML-2164, projects/rsc-concurrency-safety/plan.md --- projects/rsc-concurrency-safety/notes.md | 523 +++++++++++++++++++++++ 1 file changed, 523 insertions(+) create mode 100644 projects/rsc-concurrency-safety/notes.md diff --git a/projects/rsc-concurrency-safety/notes.md b/projects/rsc-concurrency-safety/notes.md new file mode 100644 index 0000000000..d33bd1cc43 --- /dev/null +++ b/projects/rsc-concurrency-safety/notes.md @@ -0,0 +1,523 @@ +# RSC Concurrency Safety PoC — Findings + +**Linear:** [TML-2164](https://linear.app/prisma-company/issue/TML-2164/rsc-concurrency-safety-poc) +**Milestone:** VP3 of WS3 (Runtime pipeline) +**Source artifacts:** +- Plan: [`projects/rsc-concurrency-safety/plan.md`](./plan.md) +- Postgres app: [`examples/rsc-poc-postgres/`](../../examples/rsc-poc-postgres/) +- Mongo app: [`examples/rsc-poc-mongo/`](../../examples/rsc-poc-mongo/) + +This is the **findings write-up** for the PoC. At close-out it migrates +(with minor edits) to `docs/reference/rsc-concurrency-findings.md`. + +--- + +## TL;DR + +**Prisma Next's runtime and ORM work correctly under React Server +Components' concurrent rendering model, on both the Postgres and Mongo +families, for the configurations this PoC exercised.** No correctness +bugs were found. One **performance bug** (H2) and one **sizing/liveness +observation** (H4) are documented below with a recommended fix for H2. + +The PoC stop condition per the plan — "either works correctly OR we've +identified the specific concurrency issues and know what to fix" — is +met. Pool sizing guidance, edge runtime validation, and production-ready +concurrency guarantees remain out of scope (deferred to May). + +--- + +## What we built + +Two Next.js 16 App Router apps, one per family, both running against a +**shared process-scoped Prisma Next runtime** pinned to `globalThis`: + +- **`examples/rsc-poc-postgres/`** — Postgres (pgvector), 4 routes (`/`, + `/stress/always`, `/stress/pool-pressure`, `/diag`), 5 parallel Server + Components + 1 Server Action + k6 scripts + 6 invariant tests. +- **`examples/rsc-poc-mongo/`** — Mongo, 3 routes (`/`, + `/stress/pool-pressure`, `/diag`), 5 parallel Server Components + 1 + Server Action + k6 scripts + 9 invariant tests. + +Shared design: +- Process-scoped runtime via `globalThis` keyed by + `(verifyMode, poolMax)` (PG) or `poolMax` (Mongo). Survives Next.js + HMR in dev; collapses to a plain module-level singleton in production. +- Server Components render in parallel via `` boundaries. No + Cache Components / PPR / `'use cache'` (caching masks the concurrency + behavior). +- Structured diagnostic counters backed by `globalThis`, exposed via a + dev-only `` and a `/diag` JSON endpoint. + +Diagnostic approach per family: +- **Postgres**: subclassed `pg.Pool` as `InstrumentedPool` to count + connection acquires (on `connect()` resolve), releases (via the + pool's `'release'` event), and marker reads (by matching + `prisma_contract.marker` in client SQL). +- **Mongo**: attached `CMAP` (connection monitoring) and `APM` (command + monitoring) event listeners to `MongoClient` before `connect()`. No + subclassing because `MongoClient` isn't designed for it. + +--- + +## Per-hypothesis findings + +### H1 — ORM Collection cache race: **non-issue** + +**Prediction:** The `Map`-backed Collection cache in `orm()` might race +on concurrent first-access from parallel Server Components. + +**Source-level re-read:** The Proxy's `get` trap in +`packages/3-extensions/sql-orm-client/src/orm.ts` is fully synchronous: +construct `Collection`, store in `Map`, return. No `await` inside. On +Node's event loop, synchronous code cannot interleave, so the trap is +race-free by construction. + +**Observed:** No errors or duplicate-construction bugs across thousands +of concurrent requests in either app's baseline scenario. Nothing +observable to report. + +**Verdict:** **Safe as-is.** No fix needed. + +--- + +### H2 — Redundant cold-start marker reads (`onFirstUse` / `startup`): **bug, non-critical, recommend fixing** + +**Prediction:** Concurrent cold queries race through +`RuntimeCoreImpl.verifyPlanIfNeeded()` before any of them flips +`verified = true`, each issuing its own marker-read round-trip. + +**Source:** In `packages/1-framework/4-runtime/runtime-executor/src/runtime-core.ts`, +`verifyPlanIfNeeded()` reads the marker table then sets `verified = true` +with an `await` in between. K concurrent callers on a cold runtime all +see `verified === false` at their synchronous entry check, then all +proceed to the marker read. Only after the first one's `await` resolves +and sets `verified = true` do subsequent callers early-return. + +**Observed (rsc-poc-postgres, `/` on cold start, 5 parallel Server +Components):** + +``` +markerReads: 5, connectionAcquires: 11, connectionReleases: 11 +``` + +Exactly 5 marker reads for 5 concurrent first-touch queries. +Subsequent page loads show `markerReads: 5` remaining constant — no +further verification. The pinning invariant test +(`always-mode-invariant.test.ts > H2 cold-start`) asserts +`markerReads ∈ [1, K]` and `markerReads` stays at its post-cold value +on warm bursts. + +**Observed (rsc-poc-mongo, `/` on cold start):** + +``` +commandsStarted: 5, commandsSucceeded: 5, commandsFailed: 0 +``` + +Exactly 5 wire commands — one per Server Component query, no +verification sibling. H5's confirmation. + +**Severity:** Low-to-moderate. The bug is wasted work, not incorrect +results. Cold-start cost = `(components_per_page − 1) × marker_read_RTT`, +paid once per runtime lifetime per process. For a dev server with +frequent HMR, or a serverless platform with frequent cold starts, this +is noticeable but not critical. For a long-running server it's a +negligible one-time cost. + +**Verdict:** **Real bug, recommend fix.** See "Recommended fix for H2" +below. + +--- + +### H3 — `verify.mode === 'always'` race: **non-issue (revised)** + +**Original prediction:** Under concurrency, one query flips +`verified = true` between a peer's `verified = false` reset and its +peer's own `if (verified) return` check, causing the peer to skip +verification. + +**Source-level re-read (mid-flight correction):** The claim doesn't +hold. Lines `verified = false` (in `always` mode) and +`if (verified) return` are **synchronous neighbors** with no `await` +between them. Every entry to `verifyPlanIfNeeded()` in `always` mode +unconditionally sets the flag false, then immediately checks it on the +same tick — the early-return is unreachable regardless of peer +behavior. The flag can be flipped by a peer later in the method body, +but `always` mode doesn't read it again on this path. + +Plan §2 updated with the corrected reasoning and the retained value of +the `/stress/always` route and test (invariant confirmations rather +than race reproducers). + +**Observed (`/stress/always` under 50 VUs × 30s, `k6 spike`):** + +``` +commandsΔ (ppg path): 266 markerReads, 447 acquires, 447 releases +iterations ≈ 238, effective ~7 queries per completed render +``` + +The integration test pins the strict form: +`markerReads === queryCount` for K ∈ {1, 5, 50} concurrent queries, +and for K × BURSTS across repeated bursts. All 4 cases pass. + +**Verdict:** **Safe as-is.** No race, no skipped verifications. `always` +mode costs one marker round-trip per query, as advertised. + +--- + +### H4 — Connection pool pressure: **sizing/liveness concern, not a safety bug** + +**Prediction:** With `components_per_page × concurrent_requests > pool_max`, +requests queue for connections and may time out. + +**Observed (rsc-poc-postgres, `/stress/pool-pressure`, ramp 1→100 VUs × +50s, `poolMax: 5`):** + +``` +iterations: 15,571 over 50s (~311 req/s) +acquires Δ: 93,431 +releases Δ: 93,431 # balanced under saturation +pg timeouts: 0 # with fast queries, queue drains in time +``` + +**Observed (rsc-poc-mongo, `/stress/pool-pressure`, ramp 1→100 VUs × +50s, `maxPoolSize: 5`):** + +``` +iterations: 17,503 over 50s (~350 req/s) +commandsStarted Δ: 87,515 +commandsSucceeded Δ: 87,515 +commandsFailed Δ: 0 # no waitQueueTimeoutMS breaches +checkOuts Δ = checkIns Δ # balanced +``` + +With the PoC's fast queries and small payloads, both drivers sustained +100 VUs × 50s on 5-slot pools without failures. Under slow queries or +heavier payloads, the picture would change — that's explicitly **out of +scope** for this PoC (plan §5). What we learned: + +- **Postgres**: each query exclusively borrows a pool connection for + its lifetime. Ratio of commands to acquires is 1:1. +- **Mongo**: the driver multiplexes commands over a smaller set of + wire connections. 87,515 commands on a 5-slot pool means heavy + sharing. + +**Verdict:** **No safety bug.** Pool sizing guidance is deferred to May +as explicitly planned. + +--- + +### H5 — Mongo runtime has no H2/H3 analogue: **confirmed** + +**Prediction:** `MongoRuntimeImpl` has no verification state and +`mongoOrm()` eagerly builds collections, so the Postgres-side H1/H2/H3 +hazards don't apply to the Mongo family. + +**Source-level verification:** +- `packages/2-mongo-family/7-runtime/src/mongo-runtime.ts`: + `MongoRuntimeImpl` has no `verified`/`startupVerified` fields, no + marker reads. Construction → ready. +- `packages/2-mongo-family/5-query-builders/orm/src/mongo-orm.ts`: + `mongoOrm()` eagerly constructs all collections in a `for` loop over + `contract.roots` at init time. No lazy Map, no cache. + +**Observed (rsc-poc-mongo, `/` baseline, 10 VUs × 30s):** + +``` +iterations: 14,003 over 30s (~467 req/s) +commandsΔ: 70,015 = exactly 5 × iterations, no multiplier +failedΔ: 0 +checkOutsΔ: 70,015 = checkInsΔ +tcpCreatedΔ: 0 # connections reused +``` + +Compared to the Postgres baseline (`~295 req/s`, 6 acquires per +request): Mongo is ~60% faster and does 1 fewer operation per page — +the missing operation is the marker read the Postgres runtime issues +per first-touch that the Mongo runtime simply doesn't have. The +integration test (`concurrency-invariants.test.ts`) pins this as the +hard invariant: **K concurrent queries → exactly K commands**, not +K × some-multiplier. + +**Verdict:** **Confirmed by construction and by measurement.** The +asymmetry is useful as a baseline: it localizes the H2 bug to the SQL +runtime rather than the PoC's architecture. + +--- + +## Per-page operation accounting + +Clarified after looking at what the counters actually report. Useful +for anyone cross-referencing the numbers against the source. + +### Postgres app, `/` (onFirstUse, default pool) + +| Component | Pool acquires | Queries | Notes | +|------------------------------|--------------:|--------:|---------------------------------------------| +| `` | 1 | 1 | ORM baseline | +| `` | 1 | 2 | Parent + include share one `acquireRuntimeScope` | +| `` | 1 | 1 | SQL DSL via `runtime.execute(plan)` | +| `` | 1 | 1 | Aggregate | +| `` | 2 | 2 | Seed lookup + similarity (separate chains) | +| **Total per page** | **6** | **7** | | + +Observed steady-state: `+6 acquires per request`. Matches. + +On cold start with `onFirstUse`, 5 of the 7 queries are the "first +query" of their respective Server Component and race concurrently +through `verifyPlanIfNeeded`. The 2nd queries in components that need +them (`PostsWithAuthors`, `SimilarPostsSample`) are `await`-ed **after** +the first in their component completes, so by the time they run, +`verified = true` and no marker read fires. Hence `5` marker reads, +not 7. + +### Mongo app, `/` (default pool) + +| Component | Commands | Notes | +|-----------------------------|---------:|---------------------------------------------------------------------------| +| `` | 1 | ORM baseline | +| `` | 1 | `include()` becomes a `$lookup` stage in a single aggregate command | +| `` | 1 | Query-builder pipeline via `runtime.execute(plan)` | +| `` | 1 | Aggregate pipeline | +| `` | 1 | Polymorphism variant → `$match` on discriminator | +| **Total per page** | **5** | | + +Observed: `+5 commands per request`. Matches. The Mongo ORM's include +is a single aggregation command (not a second find), which is why the +ratio is 5:5 while Postgres is 6:7. + +--- + +## Recommended fix for H2 + +**Shape:** Dedupe in-flight verification via a shared promise. + +Current code, paraphrased: + +```ts +private verified: boolean; + +async verifyPlanIfNeeded(plan) { + if (this.verify.mode === 'always') this.verified = false; + if (this.verified) return; + await this.driver.query(markerSql, markerParams); // ← every concurrent caller runs this + this.verified = true; +} +``` + +Proposed change: + +```ts +private verified: boolean; +private verifyInFlight: Promise | undefined; + +async verifyPlanIfNeeded(plan) { + if (this.verify.mode === 'always') this.verified = false; + if (this.verified) return; + if (this.verifyInFlight) return this.verifyInFlight; // ← new: join existing work + this.verifyInFlight = this.runVerify(); + try { + await this.verifyInFlight; + } finally { + this.verifyInFlight = undefined; + } +} + +private async runVerify() { + // Full existing body of verifyPlanIfNeeded from the marker read + // onward: driver.query, MARKER_MISSING / MARKER_MISMATCH handling + // for storageHash and profileHash, and the final + // this.verified = true / this.startupVerified = true assignments. + // Omitted here for brevity; see + // packages/1-framework/4-runtime/runtime-executor/src/runtime-core.ts + // for the current implementation. +} +``` + +**Effect:** K concurrent cold-start callers produce 1 marker read, not +K. Subsequent warm calls still skip via the `if (this.verified) return` +fast path. In `always` mode the behavior is unchanged (`verified = false` +happens on the sync entry, then the check fails and every caller +joins/starts its own verify — but the invariant `markerReads === K` +still holds because `always` resets between calls). + +Wait — that last claim deserves care. Under `always` mode, if caller A +has set `verifyInFlight` and is mid-flight, caller B arrives, sets +`verified = false` (no-op if already false), skips the early return, +sees `verifyInFlight`, and returns it. Result: B's verify is satisfied +by A's marker read. **That breaks `always` semantics** — B didn't +actually re-verify from its own perspective. + +**Refined proposal:** Only dedupe for `onFirstUse` / `startup` modes; +`always` mode keeps its current per-call behavior. + +```ts +async verifyPlanIfNeeded(plan) { + if (this.verify.mode === 'always') { + this.verified = false; + // Fall through to non-deduped path below. + } else { + if (this.verified) return; + if (this.verifyInFlight) return this.verifyInFlight; + this.verifyInFlight = this.runVerify(); + try { await this.verifyInFlight; } + finally { this.verifyInFlight = undefined; } + return; + } + // always mode path, unchanged: + await this.driver.query(markerSql, markerParams); + this.verified = true; +} +``` + +**Tests to add alongside the fix:** +- H2 test tightens from `markerReads ∈ [1, K]` to `markerReads === 1` + on cold start. +- New warm-burst test confirms `markerReads` stays at 1 after additional + bursts. +- H3 `always`-mode tests continue to require `markerReads === K`. + +**Implementation lives in:** +`packages/1-framework/4-runtime/runtime-executor/src/runtime-core.ts`, +the `verifyPlanIfNeeded()` method and the fields on `RuntimeCoreImpl`. + +**Should this be an ADR?** Borderline. It's a behavior change inside a +single method, backward-compatible (observable only as fewer marker +reads), and doesn't introduce new abstractions. I'd argue it's a PR +with a good commit message, not an ADR. If anyone disagrees, drafting +one is cheap. + +--- + +## Recommended user-facing guidance + +### Process-scoped runtime singleton (the HMR-safe pattern) + +Both PoC apps use the same pattern in `src/lib/db.ts`: + +```ts +const REGISTRY_KEY = Symbol.for('your-app.db.registry'); + +type DbRegistry = Map; + +function getRegistry(): DbRegistry { + const g = globalThis as unknown as { [REGISTRY_KEY]?: DbRegistry }; + let registry = g[REGISTRY_KEY]; + if (!registry) { + registry = new Map(); + g[REGISTRY_KEY] = registry; + } + return registry; +} + +export function getDb(options = {}): Client { + const registry = getRegistry(); + const key = /* derive from options */; + let entry = registry.get(key); + if (!entry) { + entry = createEntry(options); + registry.set(key, entry); + } + return entry.client; +} +``` + +Why: Next.js dev-mode HMR re-evaluates modules on every edit. A plain +module-level `let` leaks a fresh pool on every save and exhausts +Postgres connection slots within seconds. Pinning to `globalThis` via a +stable `Symbol.for(...)` key survives re-evaluation while still giving +one runtime per Node process in production. + +This pattern is ready to promote to `docs/reference/` as the +recommended integration for Next.js users. Mongo and Postgres both +work with the same shape. + +### Async `getDb()` on Mongo + +Mongo's `getDb()` is `async` because `MongoClient` requires +`await client.connect()` before the runtime can serve requests. Server +Components that call it suspend on the first render and resolve from +the cached entry thereafter. The Postgres equivalent can be sync +because the bundled `@prisma-next/postgres` builds the pool lazily. + +### Suspense boundaries per Server Component + +Wrapping each parallel Server Component in its own `` makes +the concurrency observable in the browser waterfall and avoids one slow +component gating the others. Not required for correctness, but it's +the configuration users should copy. + +--- + +## What's out of scope but likely next + +Following the plan's explicit non-goals: + +- **Pool sizing guidance.** Needs load-testing with realistic query + latencies and payloads, not the PoC's 1 KB seed. "Expected parallel + components per page × concurrent requests + headroom" is a starting + heuristic but nothing more. +- **Edge runtime validation.** `InstrumentedPool` uses `pg.Pool` which + needs Node TCP sockets — can't run on edge. HTTP drivers (Neon HTTP, + Hyperdrive) need their own PoC. The process-scoped singleton pattern + still applies. +- **Production-ready concurrency guarantees.** The invariant tests pin + what the PoC observed; more stress patterns (streaming, long-lived + cursors, AbortSignal propagation) are uncovered. +- **Transaction semantics across Server Components.** Covered by VP1, + not this PoC. + +--- + +## Artifacts + +- **Integration tests pinning the invariants:** + - `examples/rsc-poc-postgres/test/always-mode-invariant.test.ts` (6 tests) + - `examples/rsc-poc-mongo/test/concurrency-invariants.test.ts` (9 tests) +- **k6 stress scripts:** + - `examples/rsc-poc-postgres/scripts/stress.k6.js` (baseline / spike / pool-pressure) + - `examples/rsc-poc-mongo/scripts/stress.k6.js` (baseline / pool-pressure) +- **Diagnostic endpoints:** + - `GET /diag` on each app returns a JSON snapshot of live counters. +- **Draft PR:** [#370](https://github.com/prisma/prisma-next/pull/370) + +## Two mid-flight corrections worth remembering + +For anyone reading this later who wonders why the plan doesn't match +the first version a reviewer might have seen: + +1. **H3 was wrong in the original plan.** The predicted "always mode + skips verification under concurrency" race doesn't exist — the + reset and the check are synchronous neighbors. Caught by re-reading + the source before implementing the stress route. Plan §2 documents + both the original claim and the correction. + +2. **The PoC's pool instrumentation had two bugs of its own** + (documented in `examples/rsc-poc-postgres/src/lib/pool.ts`): + - Wrapping `client.release` didn't survive pg-pool's per-checkout + reassignment of the method. Fixed by listening on the pool's + `'release'` event. + - Counting acquires before `super.connect()` resolved inflated the + counter under connect-timeout rejections. Fixed by counting only + on success. + + Both were bugs in the measurement, not in Prisma Next or pg-pool. + Flagging them because they're the kind of thing only live load + exposes, and future contributors to the PoC should know about them + before trying similar instrumentation. + +--- + +## Close-out checklist (step 9 of the plan) + +- [ ] Migrate this file (lightly edited) to + `docs/reference/rsc-concurrency-findings.md`. +- [ ] Decide whether the H2 fix needs an ADR. Current recommendation: + no, just a PR. +- [ ] Strip repo-wide references to + `projects/rsc-concurrency-safety/**`; replace with canonical + `docs/reference/rsc-concurrency-findings.md` links or remove. +- [ ] Delete `projects/rsc-concurrency-safety/` in the close-out PR. +- [ ] Update Linear TML-2164 to Done; leave a summary comment + pointing at the findings doc and the draft PR. +- [ ] Confirm VP3's stop condition met with the project lead. \ No newline at end of file From 3fbebf2ec0712c27e17a3e5d153456be0ad9c69e Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Wed, 22 Apr 2026 15:51:00 +0200 Subject: [PATCH 8/8] docs(rsc-poc): close out the RSC concurrency PoC project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates the findings doc to its permanent home and removes the transient project directory per the drive-project-workflow. Changes: 1. Renamed projects/rsc-concurrency-safety/notes.md to docs/reference/rsc-concurrency-findings.md with light edits: - Header rewritten for permanent-doc framing; dropped the 'this is the findings write-up' meta line. - Dropped 'Plan §2 updated with...' phrasing where the plan is no longer reachable; kept the same reasoning inline. - Dropped 'per the plan' / 'as explicitly planned' references to the deleted plan.md. - Stripped the 'Two mid-flight corrections worth remembering' section (project history, not permanent-doc material). - Stripped the close-out checklist (this commit is the close-out). - Dropped the draft PR link from the Artifacts section. 2. Deleted projects/rsc-concurrency-safety/plan.md (the findings doc captures all the hypotheses and reasoning that mattered). 3. Updated external references: - examples/rsc-poc-postgres/README.md: 3 references to projects/rsc-concurrency-safety/plan.md redirected to the findings doc. Also cleaned up a stale '(planned)' note on the Mongo companion link. - examples/rsc-poc-mongo/README.md: 2 references redirected. - docs/reference/framework-integration-analysis.md: - Added 'Status — PoC complete' pointer in the 'What needs validation' section of Hard problem 2. - Marked the 'RSC concurrency safety is untested' row in the Blocking gaps table as resolved, with a link to the findings. Decision on ADR: not filing one. The H2 fix is a behavior change inside a single method (verifyPlanIfNeeded), backward-compatible (observable only as fewer marker reads), and doesn't introduce new abstractions. A PR with a good commit message is the right shape. Documented this reasoning inline in the findings doc (Recommended fix for H2 section). Stops the project per the drive-project-workflow: the transient projects/rsc-concurrency-safety/ directory is gone, long-lived docs live in docs/, and the two example apps remain as the PoC artifacts. Refs: TML-2164 --- .../framework-integration-analysis.md | 4 +- .../reference/rsc-concurrency-findings.md | 109 ++---- examples/rsc-poc-mongo/README.md | 7 +- examples/rsc-poc-postgres/README.md | 13 +- projects/rsc-concurrency-safety/plan.md | 339 ------------------ 5 files changed, 52 insertions(+), 420 deletions(-) rename projects/rsc-concurrency-safety/notes.md => docs/reference/rsc-concurrency-findings.md (83%) delete mode 100644 projects/rsc-concurrency-safety/plan.md diff --git a/docs/reference/framework-integration-analysis.md b/docs/reference/framework-integration-analysis.md index e542987123..0b4811b19a 100644 --- a/docs/reference/framework-integration-analysis.md +++ b/docs/reference/framework-integration-analysis.md @@ -150,6 +150,8 @@ The right next step is a **proof-of-concept**: build a Next.js App Router page w 3. **Pool sizing guidance**: How should pool size account for RSC's concurrent rendering? The PoC should measure connection acquisition patterns to inform guidance (e.g., expected parallel components per page × concurrent requests). +**Status — PoC complete.** See [RSC Concurrency Safety — Findings](./rsc-concurrency-findings.md) for the write-up. Summary: no correctness bugs on either family; one performance bug (redundant cold-start marker reads under `onFirstUse` / `startup`) with a shared-promise-dedupe fix recommended; pool sizing guidance remains deferred. + --- ## Hard problem 3: Streaming composition @@ -310,7 +312,7 @@ NestJS (~5M/week) is the only framework that structurally requires a DI wrapper | # | Gap | Hard problem | Impact | |---|---|---|---| | 1 | **Adapter driver architecture is undefined** — single adapter with pluggable drivers vs. separate packages per driver | Connection mgmt | Blocks every framework integration; the user-facing API depends on this | -| 2 | **RSC concurrency safety is untested** — runtime mutable state and ORM lazy cache under parallel Server Component rendering | RSC statefulness | Blocks confident Next.js integration (11.2M/week) | +| 2 | ~~**RSC concurrency safety is untested**~~ — **resolved**: PoC complete, see [findings](./rsc-concurrency-findings.md). No correctness bugs on either family; one performance bug (redundant cold-start marker reads under `onFirstUse` / `startup`) pending a follow-up fix | RSC statefulness | Was blocking confident Next.js integration; no longer blocking | ### High-priority gaps diff --git a/projects/rsc-concurrency-safety/notes.md b/docs/reference/rsc-concurrency-findings.md similarity index 83% rename from projects/rsc-concurrency-safety/notes.md rename to docs/reference/rsc-concurrency-findings.md index d33bd1cc43..1b35cbd1f6 100644 --- a/projects/rsc-concurrency-safety/notes.md +++ b/docs/reference/rsc-concurrency-findings.md @@ -1,14 +1,21 @@ -# RSC Concurrency Safety PoC — Findings +# RSC Concurrency Safety — Findings -**Linear:** [TML-2164](https://linear.app/prisma-company/issue/TML-2164/rsc-concurrency-safety-poc) -**Milestone:** VP3 of WS3 (Runtime pipeline) -**Source artifacts:** -- Plan: [`projects/rsc-concurrency-safety/plan.md`](./plan.md) -- Postgres app: [`examples/rsc-poc-postgres/`](../../examples/rsc-poc-postgres/) -- Mongo app: [`examples/rsc-poc-mongo/`](../../examples/rsc-poc-mongo/) +How Prisma Next's runtime and ORM behave under React Server Components' +concurrent rendering model, observed across two Next.js 16 App Router +proof-of-concept apps (one per family) under load. -This is the **findings write-up** for the PoC. At close-out it migrates -(with minor edits) to `docs/reference/rsc-concurrency-findings.md`. +**Scope:** what the PoC covered is in this doc. Pool sizing guidance, +edge runtime validation, streaming composition, and transaction +semantics across Server Components are **out of scope** — see the "Out +of scope but likely next" section at the end. + +**Reference apps:** +- Postgres: [`examples/rsc-poc-postgres/`](../../examples/rsc-poc-postgres/) +- Mongo: [`examples/rsc-poc-mongo/`](../../examples/rsc-poc-mongo/) + +**Context:** this doc is the permanent record of the VP3 proof-of-concept +under WS3 (Runtime pipeline). For the framing of the original question, +see [Framework Integration Analysis §"Hard problem 2"](./framework-integration-analysis.md). --- @@ -20,10 +27,11 @@ families, for the configurations this PoC exercised.** No correctness bugs were found. One **performance bug** (H2) and one **sizing/liveness observation** (H4) are documented below with a recommended fix for H2. -The PoC stop condition per the plan — "either works correctly OR we've -identified the specific concurrency issues and know what to fix" — is -met. Pool sizing guidance, edge runtime validation, and production-ready -concurrency guarantees remain out of scope (deferred to May). +The PoC's stop condition — "either works correctly OR we've identified +the specific concurrency issues and know what to fix" — is met. Pool +sizing guidance, edge runtime validation, and production-ready +concurrency guarantees remain out of scope (see the "Out of scope but +likely next" section at the end). --- @@ -136,18 +144,18 @@ below. peer's own `if (verified) return` check, causing the peer to skip verification. -**Source-level re-read (mid-flight correction):** The claim doesn't -hold. Lines `verified = false` (in `always` mode) and -`if (verified) return` are **synchronous neighbors** with no `await` -between them. Every entry to `verifyPlanIfNeeded()` in `always` mode -unconditionally sets the flag false, then immediately checks it on the -same tick — the early-return is unreachable regardless of peer -behavior. The flag can be flipped by a peer later in the method body, -but `always` mode doesn't read it again on this path. +**Source-level re-read:** The claim doesn't hold. Lines +`verified = false` (in `always` mode) and `if (verified) return` are +**synchronous neighbors** with no `await` between them. Every entry to +`verifyPlanIfNeeded()` in `always` mode unconditionally sets the flag +false, then immediately checks it on the same tick — the early-return +is unreachable regardless of peer behavior. The flag can be flipped by +a peer later in the method body, but `always` mode doesn't read it +again on this path. -Plan §2 updated with the corrected reasoning and the retained value of -the `/stress/always` route and test (invariant confirmations rather -than race reproducers). +The `/stress/always` route in the Postgres PoC and its integration test +stand as invariant confirmations (`markerReads === queryCount`) rather +than race reproducers. **Observed (`/stress/always` under 50 VUs × 30s, `k6 spike`):** @@ -194,7 +202,8 @@ checkOuts Δ = checkIns Δ # balanced With the PoC's fast queries and small payloads, both drivers sustained 100 VUs × 50s on 5-slot pools without failures. Under slow queries or heavier payloads, the picture would change — that's explicitly **out of -scope** for this PoC (plan §5). What we learned: +scope** (see the "Out of scope but likely next" section at the end of +this doc). What we learned: - **Postgres**: each query exclusively borrows a pool connection for its lifetime. Ratio of commands to acquires is 1:1. @@ -202,8 +211,8 @@ scope** for this PoC (plan §5). What we learned: wire connections. 87,515 commands on a 5-slot pool means heavy sharing. -**Verdict:** **No safety bug.** Pool sizing guidance is deferred to May -as explicitly planned. +**Verdict:** **No safety bug.** Pool sizing guidance is deferred (see +the "Out of scope but likely next" section at the end). --- @@ -451,7 +460,7 @@ the configuration users should copy. ## What's out of scope but likely next -Following the plan's explicit non-goals: +Explicit non-goals for this PoC, deferred to future work: - **Pool sizing guidance.** Needs load-testing with realistic query latencies and payloads, not the PoC's 1 KB seed. "Expected parallel @@ -478,46 +487,4 @@ Following the plan's explicit non-goals: - `examples/rsc-poc-postgres/scripts/stress.k6.js` (baseline / spike / pool-pressure) - `examples/rsc-poc-mongo/scripts/stress.k6.js` (baseline / pool-pressure) - **Diagnostic endpoints:** - - `GET /diag` on each app returns a JSON snapshot of live counters. -- **Draft PR:** [#370](https://github.com/prisma/prisma-next/pull/370) - -## Two mid-flight corrections worth remembering - -For anyone reading this later who wonders why the plan doesn't match -the first version a reviewer might have seen: - -1. **H3 was wrong in the original plan.** The predicted "always mode - skips verification under concurrency" race doesn't exist — the - reset and the check are synchronous neighbors. Caught by re-reading - the source before implementing the stress route. Plan §2 documents - both the original claim and the correction. - -2. **The PoC's pool instrumentation had two bugs of its own** - (documented in `examples/rsc-poc-postgres/src/lib/pool.ts`): - - Wrapping `client.release` didn't survive pg-pool's per-checkout - reassignment of the method. Fixed by listening on the pool's - `'release'` event. - - Counting acquires before `super.connect()` resolved inflated the - counter under connect-timeout rejections. Fixed by counting only - on success. - - Both were bugs in the measurement, not in Prisma Next or pg-pool. - Flagging them because they're the kind of thing only live load - exposes, and future contributors to the PoC should know about them - before trying similar instrumentation. - ---- - -## Close-out checklist (step 9 of the plan) - -- [ ] Migrate this file (lightly edited) to - `docs/reference/rsc-concurrency-findings.md`. -- [ ] Decide whether the H2 fix needs an ADR. Current recommendation: - no, just a PR. -- [ ] Strip repo-wide references to - `projects/rsc-concurrency-safety/**`; replace with canonical - `docs/reference/rsc-concurrency-findings.md` links or remove. -- [ ] Delete `projects/rsc-concurrency-safety/` in the close-out PR. -- [ ] Update Linear TML-2164 to Done; leave a summary comment - pointing at the findings doc and the draft PR. -- [ ] Confirm VP3's stop condition met with the project lead. \ No newline at end of file + - `GET /diag` on each app returns a JSON snapshot of live counters. \ No newline at end of file diff --git a/examples/rsc-poc-mongo/README.md b/examples/rsc-poc-mongo/README.md index 7f6df489a8..0e508ad756 100644 --- a/examples/rsc-poc-mongo/README.md +++ b/examples/rsc-poc-mongo/README.md @@ -5,8 +5,9 @@ behavior under RSC concurrent rendering**. Paired with `rsc-poc-postgres`; together they cover VP3 of the WS3 runtime-pipeline milestone (Linear: [TML-2164][t]). -See `projects/rsc-concurrency-safety/plan.md` for the full project plan, -including hypotheses H1–H5 and acceptance criteria. +See [`docs/reference/rsc-concurrency-findings.md`](../../docs/reference/rsc-concurrency-findings.md) +for the full write-up, including the per-hypothesis results and the +recommended fix for the one performance bug this PoC surfaced (H2). [t]: https://linear.app/prisma-company/issue/TML-2164/rsc-concurrency-safety-poc @@ -291,7 +292,7 @@ vitest.config.ts ## Related -- Project plan: `projects/rsc-concurrency-safety/plan.md` +- Findings write-up: [`docs/reference/rsc-concurrency-findings.md`](../../docs/reference/rsc-concurrency-findings.md) - Framework integration analysis §"Hard problem 2": `docs/reference/framework-integration-analysis.md` - Companion Postgres app: `examples/rsc-poc-postgres/` diff --git a/examples/rsc-poc-postgres/README.md b/examples/rsc-poc-postgres/README.md index 54077a3014..32aad5a426 100644 --- a/examples/rsc-poc-postgres/README.md +++ b/examples/rsc-poc-postgres/README.md @@ -4,8 +4,9 @@ Next.js 16 App Router proof-of-concept for **Prisma Next runtime behavior under RSC concurrent rendering**. Paired with `rsc-poc-mongo`; together they cover VP3 of the WS3 runtime-pipeline milestone (Linear: [TML-2164][t]). -See `projects/rsc-concurrency-safety/plan.md` for the full project plan, -including hypotheses H1–H5 and acceptance criteria. +See [`docs/reference/rsc-concurrency-findings.md`](../../docs/reference/rsc-concurrency-findings.md) +for the full write-up, including the per-hypothesis results and the +recommended fix for the one performance bug this PoC surfaced (H2). [t]: https://linear.app/prisma-company/issue/TML-2164/rsc-concurrency-safety-poc @@ -211,8 +212,8 @@ configured with `maxWorkers: 1` to serialize naturally. Without `DATABASE_URL`, the whole `describe` is skipped (CI-friendly — no Postgres service needed for `test:examples`). The source-level -reasoning in `projects/rsc-concurrency-safety/plan.md §2` is the primary -argument for H3; this test is the pin. +reasoning in the [findings doc](../../docs/reference/rsc-concurrency-findings.md) +(H3 section) is the primary argument; this test is the pin. ## Stress scripts @@ -294,7 +295,7 @@ out of the box after `pnpm install && pnpm emit`. ## Related -- Project plan: `projects/rsc-concurrency-safety/plan.md` +- Findings write-up: [`docs/reference/rsc-concurrency-findings.md`](../../docs/reference/rsc-concurrency-findings.md) - Framework integration analysis §"Hard problem 2": `docs/reference/framework-integration-analysis.md` -- Companion Mongo app: `examples/rsc-poc-mongo/` (planned) \ No newline at end of file +- Companion Mongo app: `examples/rsc-poc-mongo/` \ No newline at end of file diff --git a/projects/rsc-concurrency-safety/plan.md b/projects/rsc-concurrency-safety/plan.md deleted file mode 100644 index d3cca75664..0000000000 --- a/projects/rsc-concurrency-safety/plan.md +++ /dev/null @@ -1,339 +0,0 @@ -# RSC Concurrency Safety PoC — Plan - -**Linear:** [TML-2164](https://linear.app/prisma-company/issue/TML-2164/rsc-concurrency-safety-poc) -**Milestone:** VP3 of WS3 (Runtime pipeline) -**Project branch:** `tml-2164-rsc-concurrency-safety-poc` -**Status:** Shaping — draft plan, pending team validation. - -This file is the **project plan**. The project spec (`spec.md`) will be -written once the plan is validated; for now the ticket + this plan define -scope. - ---- - -## 1. Objective - -Determine whether Prisma Next's runtime and ORM client behave correctly when -multiple React Server Components query through a **shared** instance under -Next.js App Router concurrent rendering. - -Stop condition (from the ticket): the PoC either works correctly **or** we've -identified the specific concurrency issues and know what to fix. Pool sizing -guidance, edge runtime validation, and production-ready concurrency -guarantees are explicitly **out of scope** — those are May. - -## 2. Hypotheses (what we expect to find) - -These come from reading the source, not from running anything. The PoC -confirms or refutes them. - -### H1 — Collection cache race is a non-issue -The `orm()` Proxy in `packages/3-extensions/sql-orm-client/src/orm.ts` has a -**synchronous** `get` trap: construct `Collection`, store in `Map`, return. -No `await` inside the trap, so concurrent first-access cannot interleave on -Node's event loop. The worst case is redundant work, which is impossible -because the trap is a single microtask-free path. - -**Expected outcome:** PoC confirms. No bug to fix. - -### H2 — `verified` / `startupVerified` in `onFirstUse` and `startup` modes: bug, but not a correctness bug -In `RuntimeCoreImpl.verifyPlanIfNeeded` (runtime-executor), the flag flips -monotonically `false → true`. Concurrent cold-start queries can each fire -their own marker-read roundtrip before the first one lands (`await` between -the `if (this.verified) return` check and the `this.verified = true` write). -The operations are idempotent and all succeed against the same marker, so -results are correct — but up to N-1 of those roundtrips are wasted work -that a user would (rightly) file a bug against. - -**Expected outcome:** N redundant marker reads on cold start, observable via -telemetry. Results are correct; the wasted roundtrips are a real bug worth -fixing (dedupe in-flight verification via a shared promise). Severity: -low-to-moderate, not a correctness violation. - -### H3 — `verified` / `startupVerified` in `always` mode: **likely a non-issue** (revised) - -Original claim: under concurrent execution, one query flips -`this.verified = true` between another query's `this.verified = false` -reset and its own `if (this.verified) return` check, causing the second -query to skip verification. - -Re-reading `verifyPlanIfNeeded` against the proposed interleaving, the -claim doesn't hold: - -```ts -private async verifyPlanIfNeeded(_plan: ExecutionPlan): Promise { - if (this.verify.mode === 'always') { - this.verified = false; // (1) unconditional reset - } - if (this.verified) { // (2) synchronous check on same tick - return; - } - ... - await driver.query(...) // (3) first await - ... - this.verified = true; -} -``` - -Lines (1) and (2) are synchronous; there is no `await` between them. In -`always` mode every entry *unconditionally* sets `verified = false` and -then immediately checks it, so the early-return at (2) is unreachable in -`always` mode regardless of what any concurrent caller does to the flag. -The flag can be flipped by a peer between (2) and (3) or between (3) -and the final `this.verified = true`, but `always` mode doesn't read it -again on this path — it just proceeds to verify. - -**Revised expected outcome:** `markerReads === queryCount` under -concurrency in `always` mode. No skipped verifications, no correctness -bug. The `/stress/always` route and its integration test become an -**invariant test** (lock in the expected equality) rather than a -regression reproducer. - -If the test surprisingly fails under real load — e.g. the interleaving -produces some other skip I didn't anticipate — update this hypothesis -and the findings doc accordingly. Part of the PoC's value is that -running the code tells us what reading the code can't. - -### H4 — Connection pool pressure is a sizing/liveness concern, not a safety bug -5 parallel Server Components × N concurrent requests contend for the pg -pool. Expected symptoms: tail-latency cliff when `concurrency × components > -pool size`; possible deadlock if a request holds a connection while waiting -for another. We measure, we don't fix (pool sizing guidance is May). - -### H5 — Mongo stack has none of the hazards in H2/H3 -Source-level audit: -- `MongoRuntimeImpl` (`packages/2-mongo-family/7-runtime/src/mongo-runtime.ts`): - no verification state, no markers — nothing to race on. -- `mongoOrm()` (`packages/2-mongo-family/5-query-builders/orm/src/mongo-orm.ts`): - **eagerly** constructs all collections in a `for` loop at init time — no - lazy cache. - -So the Mongo app is **not** re-running the same experiment. Its value in -this PoC: -- Coverage of a second family under RSC concurrency at all. -- Baseline: if Postgres shows redundant marker reads on cold start and Mongo - shows nothing analogous, that **localizes** the issue to the SQL runtime. -- Mongo driver pool behavior under RSC concurrency (genuinely different - question from pg pool behavior). - -The plan calls out in the findings doc that the two apps are probing -different things on purpose. - -## 3. Deliverables - -### 3.1 Two Next.js 16 App Router apps - -Note on revised H3 (see §2): the `/stress/always` route and associated -test below were originally designed to **reproduce** a predicted race. -After re-reading the source, the race doesn't hold. The route and test -are kept as-is because they still have value — they lock in the -equality `markerReads === queryCount` as an invariant, and they provide -the direct apples-to-apples comparison to the onFirstUse path (which -*does* exhibit the benign H2 behavior). Treat them as invariant -confirmations, not race reproducers. - -Both live under `examples/` so they survive project close-out. - -``` -examples/rsc-poc-postgres/ # Postgres + pg pool + SQL runtime -examples/rsc-poc-mongo/ # Mongo + mongo driver + mongo runtime -``` - -Each app: -- Next.js 16, App Router, React 19, `export const dynamic = 'force-dynamic'`. -- No Cache Components, no PPR, no `'use cache'` — caching masks the - behavior we're trying to observe. -- HMR-safe `globalThis` singleton for the Prisma Next runtime/db in - `src/lib/db.ts`. -- 5 parallel Server Components on `/` covering a **mix** of code paths - (ORM + SQL DSL + includes + raw reads; see §4). -- `/stress/always` route (Postgres only): same page but with the runtime - pinned to `verify.mode === 'always'` to confirm the revised H3 — - `markerReads === queryCount` under concurrency. -- One Server Action (`POST`-style): proves mutations alongside concurrent - reads don't explode (ticket says reads; we agreed to add one smoke - action). -- Structured telemetry surfaced to a dev-only `` at page - bottom: marker-read count, verification-fire count, connection-acquire - count, per-component timings. -- README documenting how to run, what to look at, known-clean / - known-broken routes. - -### 3.2 Load script per app - -``` -examples/rsc-poc-postgres/scripts/stress.k6.js -examples/rsc-poc-mongo/scripts/stress.k6.js -``` - -**Tool:** k6. - -Scenarios: -- `baseline`: 10 VUs × 30s hitting `/`. -- `spike` (Postgres only): 50 VUs × 30s hitting `/stress/always`, designed - to reproduce H3 by maximizing async interleaving. -- `pool-pressure`: gradually ramp VUs 1 → 100 against `/` with a small pool - (`max: 5`) to characterize H4. - -Scripts emit JSON summaries we can commit as reference output. - -### 3.3 One integration test (Postgres) - -`examples/rsc-poc-postgres/test/always-mode-invariant.test.ts` - -Asserts the H3 invariant (revised): - -> When `verify.mode === 'always'` and K concurrent queries share a runtime, -> the number of verification marker reads **equals** K. - -**Test level: process, not HTTP.** The plan originally called for -asserting against the running app's `/diag` endpoint. On reflection, -the invariant's mechanism lives entirely in -`RuntimeCoreImpl.verifyPlanIfNeeded()` — the RSC layer is incidental. -What the test actually needs to exercise is "N parallel `await`ed -queries sharing one runtime on the Node event loop", which is -identical on-event-loop to what RSC produces. A process-level test -gets that with less machinery (no `next start`, no port management, no -HTTP layer to debug when something fails) and lets us cheaply -parameterize K. - -HTTP-level coverage of the invariant isn't lost: the k6 scripts -already exercise the `/stress/always` path end-to-end and the -teardown-time `/diag` delta is the HTTP-level observation. The -integration test is the deterministic pin; k6 is the empirical -sanity check. - -Implementation: construct an `InstrumentedPool` + `postgres()` client -directly in the test (reusing the app's `src/lib/pool.ts`), fire K -concurrent `execute()`s against the same runtime, then assert -`markerReads === K` and `acquires === releases`. A couple of K values -(e.g. 1, 5, 50) cover both "single query" and "well-past the default -pool" shapes. - -Observational output remains the primary deliverable per §3.1; this -test exists specifically to pin the invariant. - -### 3.4 Findings doc (final) - -During execution: notes live under -`projects/rsc-concurrency-safety/notes.md`. - -At close-out: migrate to `docs/reference/rsc-concurrency-findings.md` -covering: -- What we observed on each app under each scenario. -- Whether H1–H5 held. -- Concrete fixes for whatever's broken (or argument for "safe as-is"). -- Recommended user-facing pattern (the `globalThis` singleton). -- Explicit list of things deferred to May. - -### 3.5 ADR (conditional) - -The revised H3 expects no correctness bug in `always` mode, so the most -likely driver of an ADR is now **H2**: the redundant cold-start marker -reads are wasted work that users will file as a bug. A simple fix -(dedupe in-flight verification via a shared promise) resolves it -cleanly. If the PoC confirms H2 and the team agrees on the fix, draft -an ADR under -`projects/rsc-concurrency-safety/adr-draft-verification-dedupe.md` and -migrate to `docs/architecture docs/adrs/` at close-out. - -If the PoC turns up something genuinely surprising (e.g. a new race -the source-level re-reading didn't catch), the ADR covers that instead. - -## 4. The 5 Server Components (shape) - -Postgres app, `/`: -1. `` — ORM: `db.User.orderBy(...).take(10).all()` -2. `` — ORM with include: `db.Post.include('user').take(10).all()` - (exercises multi-query include dispatch) -3. `` — SQL DSL: `db.sql.post.where(...).select(...).build()` - then `runtime.execute(plan)` -4. `` — ORM aggregate: `db.User.groupBy(...).aggregate(...)` -5. `` — pgvector similarity search via ORM - -Mongo app, `/`: five analogous queries over the retail-store domain -(products / orders / categories). Exact shapes decided during -implementation; the point is "five concurrent reads, varied shapes". - -Server Action: `submitFeedback` (Postgres) / `addToCart` (Mongo). One -insert. Invoked manually from a form on `/`; not hit by k6. - -## 5. Out of scope - -Explicit non-goals so reviewers don't ask: -- Pool sizing guidance (May). -- Edge runtime validation (May). -- Transaction semantics across Server Components (VP1). -- Production-ready concurrency guarantees (May). -- Fixing H3 itself in this PoC, beyond documenting and (if shaped enough) - an ADR draft. The fix belongs in a follow-on issue under the same VP3. -- Cache Components / PPR / `'use cache'` (would mask the behavior). -- Benchmarks (Side-quest milestone). - -## 6. Risks & mitigations - -| Risk | Mitigation | -|---|---| -| H3 invariant unexpectedly fails (`markerReads !== queryCount`) | Capture the failing counts and revisit whether there's a newly-identified race or an instrumentation bug. Update H3 and the findings doc. | -| pg driver's own internal serialization hides the race | Inspect `@prisma-next/driver-postgres` to confirm whether queries are serialized per connection; design stress to use N connections. | -| Next.js 16 churn: RSC semantics / caching defaults shift under us during the PoC | Pin a specific Next.js 16 minor; document version in each app's README. | -| "Telemetry counting" conflates retries, middleware hooks, and actual marker reads | Count at the driver level (spy), not at middleware level, for the H3 assertion. | -| Two apps double the maintenance burden | Keep them minimal; no shared UI kit; copy-paste over abstraction. | - -## 7. Work breakdown & sequencing - -Each bullet is a candidate PR. Branches off `tml-2164-rsc-concurrency-safety-poc`. - -1. **Shaping PR** — this plan + a short `spec.md` under - `projects/rsc-concurrency-safety/`. Validate with team before starting - implementation. *(Blocks everything else.)* -2. **Postgres app scaffold** — `examples/rsc-poc-postgres/` with one trivial - Server Component, `globalThis` singleton, dev-only diag panel, READMEs. - Reuses `prisma-next-demo`'s contract/schema to avoid re-writing it. -3. **Postgres: 5 Server Components + Server Action** — the actual page. -4. **Postgres: `/stress/always` route + k6 scripts** — confirm the H3 - invariant observationally. -5. **Postgres: integration test for H3 invariant** — assert - `markerReads === queryCount` in `always` mode under concurrency. -6. **Mongo app scaffold** — `examples/rsc-poc-mongo/`, reusing - `retail-store`'s contract/seed. -7. **Mongo: 5 Server Components + Server Action + k6 scripts**. -8. **Findings write-up** — `projects/rsc-concurrency-safety/notes.md` - consolidated; decide whether an ADR is needed based on results. -9. **Close-out PR** — migrate findings to `docs/reference/`, (optionally) - ADR to `docs/architecture docs/adrs/`, delete - `projects/rsc-concurrency-safety/`. Apps stay. - -Rough sizing (calibration, not a commitment): 1 is hours; 2, 6 are -half-day each; 3, 7 are a day each; 4, 5 are a day total; 8, 9 are a day. -~5 working days assuming no surprises. Surprises are the whole point, so -assume more. - -## 8. Open questions (to resolve during spec validation) - -- Pool size for `pool-pressure` scenario — `max: 5` is a guess designed to - force contention quickly. Revisit after first run. - -### Resolved during shaping - -- **Scope of the shared runtime:** process-scoped (one runtime per Node - process, held via the `globalThis` HMR-safe singleton pattern). Not - request-scoped via `cache()`. This matches framework-integration-analysis - §"Hard problem 2" and is exactly the configuration that exposes H1–H3. -- **Postgres entry point:** use the bundled `@prisma-next/postgres` runtime - (what `prisma-next-demo` uses). It's the copy-paste path users will take. - -## 9. Acceptance criteria - -- [ ] Two Next.js 16 apps exist under `examples/rsc-poc-postgres/` and - `examples/rsc-poc-mongo/`, each with 5 parallel RSC reads + 1 Server - Action, runnable locally per README. -- [ ] k6 scripts exist and have been run at least once; summaries committed - under `scripts/` as reference. -- [ ] `/stress/always` either reproduces H3 (confirmed via integration test) - **or** findings doc explains why it doesn't. -- [ ] Findings doc covers H1–H5 with evidence. -- [ ] Stakeholder (project lead) signs off that VP3's stop condition is - met. -- [ ] At close-out: findings migrated to `docs/`, (optional) ADR migrated, - `projects/rsc-concurrency-safety/` deleted. \ No newline at end of file