From fbcafbdcd8a1ac1a6bc5747e1ef1cb9a02e0b1e1 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 13:49:47 +0200 Subject: [PATCH 1/9] =?UTF-8?q?feat(examples):=20add=20store=20=E2=80=94?= =?UTF-8?q?=20a=20readable=20three-module=20demo=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose Coffee: a Next.js storefront, a catalog module and an orders module, each module owning its own Postgres. Unlike storefront-auth (the minimal PoC), this example reads as a real app: - catalog exposes listProducts/getProduct and seeds a menu - orders takes catalog as a boundary input and prices each order via catalog.getProduct at placement time — a module→module edge - the storefront page renders the menu and places orders through a server action, via the two injected typed clients - scripts/dev.ts runs the storefront locally against in-memory fakes of both contracts, no cloud needed - DEMO.md is a 5-minute walkthrough script Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- examples/store/DEMO.md | 62 +++++++++ examples/store/README.md | 61 ++++++++ examples/store/module.ts | 20 +++ examples/store/modules/catalog/package.json | 30 ++++ examples/store/modules/catalog/scripts/dev.ts | 3 + .../store/modules/catalog/src/contract.ts | 27 ++++ examples/store/modules/catalog/src/module.ts | 21 +++ examples/store/modules/catalog/src/server.ts | 81 +++++++++++ examples/store/modules/catalog/src/service.ts | 15 ++ .../store/modules/catalog/testing/fake.ts | 31 +++++ examples/store/modules/catalog/tsconfig.json | 17 +++ .../store/modules/catalog/tsdown.config.ts | 15 ++ examples/store/modules/orders/package.json | 31 +++++ examples/store/modules/orders/scripts/dev.ts | 3 + examples/store/modules/orders/src/contract.ts | 28 ++++ examples/store/modules/orders/src/module.ts | 25 ++++ examples/store/modules/orders/src/server.ts | 65 +++++++++ examples/store/modules/orders/src/service.ts | 18 +++ examples/store/modules/orders/testing/fake.ts | 44 ++++++ examples/store/modules/orders/tsconfig.json | 17 +++ .../store/modules/orders/tsdown.config.ts | 15 ++ examples/store/modules/storefront/.gitignore | 2 + .../store/modules/storefront/app/globals.css | 66 +++++++++ .../store/modules/storefront/app/layout.tsx | 11 ++ .../modules/storefront/app/page.test.tsx | 52 +++++++ .../store/modules/storefront/app/page.tsx | 59 ++++++++ .../store/modules/storefront/next.config.ts | 24 ++++ .../store/modules/storefront/package.json | 33 +++++ .../store/modules/storefront/src/service.ts | 17 +++ .../store/modules/storefront/tsconfig.json | 31 +++++ .../store/modules/storefront/vitest.config.ts | 13 ++ examples/store/package.json | 28 ++++ examples/store/prisma-compose.config.ts | 13 ++ examples/store/scripts/dev.ts | 55 ++++++++ examples/store/tsconfig.json | 7 + examples/store/turbo.json | 9 ++ pnpm-lock.yaml | 130 ++++++++++++++++++ 37 files changed, 1179 insertions(+) create mode 100644 examples/store/DEMO.md create mode 100644 examples/store/README.md create mode 100644 examples/store/module.ts create mode 100644 examples/store/modules/catalog/package.json create mode 100644 examples/store/modules/catalog/scripts/dev.ts create mode 100644 examples/store/modules/catalog/src/contract.ts create mode 100644 examples/store/modules/catalog/src/module.ts create mode 100644 examples/store/modules/catalog/src/server.ts create mode 100644 examples/store/modules/catalog/src/service.ts create mode 100644 examples/store/modules/catalog/testing/fake.ts create mode 100644 examples/store/modules/catalog/tsconfig.json create mode 100644 examples/store/modules/catalog/tsdown.config.ts create mode 100644 examples/store/modules/orders/package.json create mode 100644 examples/store/modules/orders/scripts/dev.ts create mode 100644 examples/store/modules/orders/src/contract.ts create mode 100644 examples/store/modules/orders/src/module.ts create mode 100644 examples/store/modules/orders/src/server.ts create mode 100644 examples/store/modules/orders/src/service.ts create mode 100644 examples/store/modules/orders/testing/fake.ts create mode 100644 examples/store/modules/orders/tsconfig.json create mode 100644 examples/store/modules/orders/tsdown.config.ts create mode 100644 examples/store/modules/storefront/.gitignore create mode 100644 examples/store/modules/storefront/app/globals.css create mode 100644 examples/store/modules/storefront/app/layout.tsx create mode 100644 examples/store/modules/storefront/app/page.test.tsx create mode 100644 examples/store/modules/storefront/app/page.tsx create mode 100644 examples/store/modules/storefront/next.config.ts create mode 100644 examples/store/modules/storefront/package.json create mode 100644 examples/store/modules/storefront/src/service.ts create mode 100644 examples/store/modules/storefront/tsconfig.json create mode 100644 examples/store/modules/storefront/vitest.config.ts create mode 100644 examples/store/package.json create mode 100644 examples/store/prisma-compose.config.ts create mode 100644 examples/store/scripts/dev.ts create mode 100644 examples/store/tsconfig.json create mode 100644 examples/store/turbo.json diff --git a/examples/store/DEMO.md b/examples/store/DEMO.md new file mode 100644 index 00000000..65e05e21 --- /dev/null +++ b/examples/store/DEMO.md @@ -0,0 +1,62 @@ +# Standup walkthrough (~5 min) + +Order of files to open, with the one point each makes. + +## 1. The whole app — [module.ts](module.ts) + +The entire application is ~6 lines of composition. Three components, three +edges, no infrastructure config anywhere. Point at the wiring: +`orders` gets `catalog.rpc`, `storefront` gets both. + +## 2. A contract — [modules/catalog/src/contract.ts](modules/catalog/src/contract.ts) + +The edge type. arktype schemas → a typed client on the consumer side and a +type-checked handler map on the producer side. Validated at the boundary at +runtime. + +## 3. A module — [modules/catalog/src/module.ts](modules/catalog/src/module.ts) + +catalog owns its own Postgres. The consumer never sees it — the module +exposes only the `rpc` port. Then [src/service.ts](modules/catalog/src/service.ts): +the service declares `deps: { db: postgres() }` and what it exposes; that's +the whole declaration. + +## 4. The interesting edge — [modules/orders/src/module.ts](modules/orders/src/module.ts) + +orders owns a Postgres too, but declares `deps: { catalog }` as a **boundary +input** — the parent supplies any producer of `catalogContract`. In +[src/server.ts](modules/orders/src/server.ts), `placeOrder` calls +`catalog.getProduct()` — a typed async call, no URL, no fetch, no client +setup. It doesn't know or care where catalog runs. + +## 5. Plain app code — [modules/storefront/app/page.tsx](modules/storefront/app/page.tsx) + +A normal Next.js server component. `service.load()` hands it two typed +clients, injected by the root's wiring. The Buy button is a server action +calling `orders.placeOrder`. + +## 6. Show it live + +Deployed: run the deployed storefront URL, buy a coffee, watch it appear in +recent orders (two RPC hops + two Postgres writes behind one click). + +Fallback if the cloud misbehaves: `pnpm dev` here runs the same storefront +against in-memory fakes on loopback — same contracts, zero cloud. + +## 7. Testing story (if time) + +[page.test.tsx](modules/storefront/app/page.test.tsx): `mockService` swaps +`load()` for typed fakes — the handler maps are type-checked against the same +contracts the real modules serve. No Postgres, no server, no cloud. + +## Likely questions + +- **"What actually got deployed?"** Each compute service is a Prisma Compute + VM; each module's `postgres()` is a Prisma Postgres database. The deploy + derives the graph from the code above — same code could target another + extension pack. +- **"What's in an edge?"** At runtime: an env var (`CATALOG_URL`) the + framework injects, plus the contract's validation on both ends. +- **"Why modules and not just services?"** Boundaries. catalog's database can + never become someone else's dependency — it isn't reachable. Only exposed + ports are. diff --git a/examples/store/README.md b/examples/store/README.md new file mode 100644 index 00000000..94fb1839 --- /dev/null +++ b/examples/store/README.md @@ -0,0 +1,61 @@ +# store — Compose Coffee + +A readable example Prisma App: a Next.js storefront, a **catalog** module, and +an **orders** module. + +```mermaid +flowchart TB + User((User)) + + subgraph Storefront["storefront"] + SF["Next.js · Compute Service"] + end + subgraph Catalog["Module: catalog"] + CA["catalog · Compute Service"] + CDB[("Postgres — private")] + CA --- CDB + end + subgraph Orders["Module: orders"] + OR["orders · Compute Service"] + ODB[("Postgres — private")] + OR --- ODB + end + + User -->|HTTP| SF + SF -->|rpc · catalogContract| CA + SF -->|rpc · ordersContract| OR + OR -->|rpc · catalogContract| CA +``` + +Each module owns its own Postgres; the only edges between components are the +typed RPC contracts. The whole composition is [module.ts](module.ts). + +## What each piece shows + +- [modules/catalog](modules/catalog) — a self-contained Module: a contract + (`listProducts`/`getProduct`), a compute service, and a Postgres it owns and + seeds. Consumers wire only the exposed `rpc` port. +- [modules/orders](modules/orders) — a Module with a **boundary input**: it + owns its Postgres but declares `deps: { catalog }`, so whoever provisions it + supplies a producer of `catalogContract`. `placeOrder` calls catalog to + price the order at placement time. +- [modules/storefront](modules/storefront) — a real Next.js app. The page + calls both typed clients; the Buy button is a server action that places an + order. + +## Run it locally (no cloud) + +```sh +pnpm dev # from examples/store +``` + +Serves in-memory fakes of catalog and orders on loopback ports and runs +`next dev` against them — the same fakes the unit test injects via +`mockService` ([page.test.tsx](modules/storefront/app/page.test.tsx)). + +## Deploy + +```sh +pnpm deploy # needs .env at the repo root (see examples/storefront-auth) +pnpm destroy +``` diff --git a/examples/store/module.ts b/examples/store/module.ts new file mode 100644 index 00000000..55e02f69 --- /dev/null +++ b/examples/store/module.ts @@ -0,0 +1,20 @@ +import { module } from '@prisma/compose'; +import catalogModule from '@store/catalog'; +import ordersModule from '@store/orders'; +import storefrontService from '@store/storefront'; + +/** + * The store app: three components, three edges. + * + * storefront ──rpc──▶ catalog (browse products) + * storefront ──rpc──▶ orders (place + list orders) + * orders ──rpc──▶ catalog (price an order at placement time) + * + * catalog and orders each own their own Postgres internally — the root never + * sees it. All it wires are the exposed, typed rpc ports. + */ +export default module('store', ({ provision }) => { + const catalog = provision(catalogModule); + const orders = provision(ordersModule, { deps: { catalog: catalog.rpc } }); + provision(storefrontService, { deps: { catalog: catalog.rpc, orders: orders.rpc } }); +}); diff --git a/examples/store/modules/catalog/package.json b/examples/store/modules/catalog/package.json new file mode 100644 index 00000000..580ce7d9 --- /dev/null +++ b/examples/store/modules/catalog/package.json @@ -0,0 +1,30 @@ +{ + "name": "@store/catalog", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/module.ts", + "./contract": "./src/contract.ts", + "./fake": "./testing/fake.ts" + }, + "scripts": { + "dev": "bun run scripts/dev.ts", + "build": "tsdown", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "arktype": "^2.2.3" + }, + "peerDependencies": { + "@prisma/compose": "workspace:0.1.0", + "@prisma/compose-prisma-cloud": "workspace:0.1.0" + }, + "devDependencies": { + "@prisma/compose": "workspace:0.1.0", + "@prisma/compose-prisma-cloud": "workspace:0.1.0", + "@types/bun": "^1.3.13", + "tsdown": "^0.22.3", + "typescript": "^6.0.3" + } +} diff --git a/examples/store/modules/catalog/scripts/dev.ts b/examples/store/modules/catalog/scripts/dev.ts new file mode 100644 index 00000000..2f9a2606 --- /dev/null +++ b/examples/store/modules/catalog/scripts/dev.ts @@ -0,0 +1,3 @@ +// Local dev: run the server directly. It calls service.load(), which reads +// DB_URL and PORT from your environment — set them before running. +import '../src/server.ts'; diff --git a/examples/store/modules/catalog/src/contract.ts b/examples/store/modules/catalog/src/contract.ts new file mode 100644 index 00000000..1a52e234 --- /dev/null +++ b/examples/store/modules/catalog/src/contract.ts @@ -0,0 +1,27 @@ +/** + * catalog's public RPC contract. It lives with the module that owns it; a + * consumer imports it and depends on it via `rpc(catalogContract)`, getting + * back a typed client. + */ +import { contract, rpc } from '@prisma/compose/rpc'; +import { type } from 'arktype'; + +export const product = type({ + id: 'string', + name: 'string', + description: 'string', + priceCents: 'number', +}); + +export type Product = typeof product.infer; + +export const catalogContract = contract({ + listProducts: rpc({ + input: type({}), + output: type({ products: product.array() }), + }), + getProduct: rpc({ + input: type({ id: 'string' }), + output: type({ product: product.or('null') }), + }), +}); diff --git a/examples/store/modules/catalog/src/module.ts b/examples/store/modules/catalog/src/module.ts new file mode 100644 index 00000000..bac787d1 --- /dev/null +++ b/examples/store/modules/catalog/src/module.ts @@ -0,0 +1,21 @@ +/** + * The catalog Module: a reusable unit that owns its own Postgres. It + * provisions the database and the catalog compute service, wires the db into + * the service's `db` input, and exposes the service's `rpc` port as the + * Module's own output. A consumer wires only the exposed contract — it never + * sees the database. + * + * The database provision id is "database" (the Connection API rejects names + * shorter than 3 characters); the service keeps an explicit "service" id so + * it doesn't read as "catalog.catalog". + */ +import { module } from '@prisma/compose'; +import { postgres } from '@prisma/compose-prisma-cloud'; +import { catalogContract } from './contract.ts'; +import catalogService from './service.ts'; + +export default module('catalog', { expose: { rpc: catalogContract } }, ({ provision }) => { + const db = provision(postgres({ name: 'database' })); + const service = provision(catalogService, { id: 'service', deps: { db } }); + return { rpc: service.rpc }; +}); diff --git a/examples/store/modules/catalog/src/server.ts b/examples/store/modules/catalog/src/server.ts new file mode 100644 index 00000000..bcf3d5b2 --- /dev/null +++ b/examples/store/modules/catalog/src/server.ts @@ -0,0 +1,81 @@ +import { serve } from '@prisma/compose/rpc'; +import { SQL } from 'bun'; +import type { Product } from './contract.ts'; +import service from './service.ts'; + +const { db } = service.load(); // db: PostgresConfig — the app owns its client +const { port } = service.config(); + +// One pool per process. idleTimeout closes the pooled connection before +// Compute's scale-to-zero drops it, so the next request reconnects instead of +// erroring (FT-5219). +const sql = new SQL({ url: db.url, max: 1, idleTimeout: 10 }); + +// A Prisma Postgres direct connection is closed when it goes idle. Bun.SQL +// surfaces that as an async error with no awaiter, which would otherwise +// crash the process into a restart loop. +process.on('uncaughtException', (err) => console.error('uncaughtException', err)); +process.on('unhandledRejection', (err) => console.error('unhandledRejection', err)); + +const SEED: Product[] = [ + { + id: 'espresso', + name: 'Espresso', + description: 'A double shot, pulled short.', + priceCents: 350, + }, + { + id: 'flat-white', + name: 'Flat White', + description: 'Silky microfoam over a double shot.', + priceCents: 450, + }, + { + id: 'cold-brew', + name: 'Cold Brew', + description: 'Steeped 18 hours, served over ice.', + priceCents: 500, + }, + { id: 'croissant', name: 'Croissant', description: 'Baked every morning.', priceCents: 400 }, +]; + +await sql` + create table if not exists products ( + id text primary key, + name text not null, + description text not null, + price_cents integer not null + ) +`; +for (const p of SEED) { + await sql` + insert into products (id, name, description, price_cents) + values (${p.id}, ${p.name}, ${p.description}, ${p.priceCents}) + on conflict (id) do nothing + `; +} + +const toProduct = (row: Record): Product => ({ + id: String(row.id), + name: String(row.name), + description: String(row.description), + priceCents: Number(row.price_cents), +}); + +const handler = serve(service, { + rpc: { + listProducts: async () => { + const rows = await sql`select * from products order by name`; + return { products: rows.map(toProduct) }; + }, + getProduct: async ({ id }) => { + const rows = await sql`select * from products where id = ${id}`; + return { product: rows.length > 0 ? toProduct(rows[0]) : null }; + }, + }, +}); +export default handler; + +// Bind all interfaces — Compute routes external HTTP to the VM, so a +// loopback-only listener would be unreachable. +Bun.serve({ port, hostname: '0.0.0.0', fetch: handler }); diff --git a/examples/store/modules/catalog/src/service.ts b/examples/store/modules/catalog/src/service.ts new file mode 100644 index 00000000..0f943eaf --- /dev/null +++ b/examples/store/modules/catalog/src/service.ts @@ -0,0 +1,15 @@ +import node from '@prisma/compose/node'; +import { compute, postgres } from '@prisma/compose-prisma-cloud'; +import { catalogContract } from './contract.ts'; + +// The `db` dependency is pure requirement: its binding is PostgresConfig +// (`{ url }`), and the app builds its own SQL client from it in server.ts +// (ADR-0015). +export default compute({ + name: 'catalog', + deps: { + db: postgres(), + }, + build: node({ module: import.meta.url, entry: '../dist/server.mjs' }), + expose: { rpc: catalogContract }, +}); diff --git a/examples/store/modules/catalog/testing/fake.ts b/examples/store/modules/catalog/testing/fake.ts new file mode 100644 index 00000000..87b70ba8 --- /dev/null +++ b/examples/store/modules/catalog/testing/fake.ts @@ -0,0 +1,31 @@ +/** + * An in-memory catalog for TESTING a module that depends on it — no Postgres, + * no deploy. It serves the real `catalogContract`, so its handler map is + * type-checked against the same contract the real catalog exposes. Test-only, + * deliberately outside `src/`, so it never rides into the deployed artifact. + */ +import node from '@prisma/compose/node'; +import { serve } from '@prisma/compose/rpc'; +import { compute } from '@prisma/compose-prisma-cloud'; +import { catalogContract, type Product } from '../src/contract.ts'; + +export const FAKE_PRODUCTS: Product[] = [ + { id: 'espresso', name: 'Espresso', description: 'A double shot.', priceCents: 350 }, + { id: 'croissant', name: 'Croissant', description: 'Baked every morning.', priceCents: 400 }, +]; + +const fakeCatalog = compute({ + name: 'catalog-fake', + deps: {}, + build: node({ module: import.meta.url, entry: 'fake.ts' }), + expose: { rpc: catalogContract }, +}); + +export default serve(fakeCatalog, { + rpc: { + listProducts: async () => ({ products: FAKE_PRODUCTS }), + getProduct: async ({ id }) => ({ + product: FAKE_PRODUCTS.find((p) => p.id === id) ?? null, + }), + }, +}); diff --git a/examples/store/modules/catalog/tsconfig.json b/examples/store/modules/catalog/tsconfig.json new file mode 100644 index 00000000..5b986d35 --- /dev/null +++ b/examples/store/modules/catalog/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "Preserve", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": false, + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "types": ["bun"] + }, + "include": ["src"] +} diff --git a/examples/store/modules/catalog/tsdown.config.ts b/examples/store/modules/catalog/tsdown.config.ts new file mode 100644 index 00000000..109ae871 --- /dev/null +++ b/examples/store/modules/catalog/tsdown.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsdown'; + +// Build only the runnable (src/server.ts). @prisma/* and arktype are inlined — +// node_modules isn't shipped; `bun` is a Compute runtime built-in. +export default defineConfig({ + entry: { server: 'src/server.ts' }, + outDir: 'dist', + format: 'esm', + platform: 'node', + external: ['bun'], + noExternal: [/^@prisma\//, /^arktype/], + dts: false, + sourcemap: false, + clean: true, +}); diff --git a/examples/store/modules/orders/package.json b/examples/store/modules/orders/package.json new file mode 100644 index 00000000..ca0bec22 --- /dev/null +++ b/examples/store/modules/orders/package.json @@ -0,0 +1,31 @@ +{ + "name": "@store/orders", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/module.ts", + "./contract": "./src/contract.ts", + "./fake": "./testing/fake.ts" + }, + "scripts": { + "dev": "bun run scripts/dev.ts", + "build": "tsdown", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@store/catalog": "workspace:0.1.0", + "arktype": "^2.2.3" + }, + "peerDependencies": { + "@prisma/compose": "workspace:0.1.0", + "@prisma/compose-prisma-cloud": "workspace:0.1.0" + }, + "devDependencies": { + "@prisma/compose": "workspace:0.1.0", + "@prisma/compose-prisma-cloud": "workspace:0.1.0", + "@types/bun": "^1.3.13", + "tsdown": "^0.22.3", + "typescript": "^6.0.3" + } +} diff --git a/examples/store/modules/orders/scripts/dev.ts b/examples/store/modules/orders/scripts/dev.ts new file mode 100644 index 00000000..3bbbb689 --- /dev/null +++ b/examples/store/modules/orders/scripts/dev.ts @@ -0,0 +1,3 @@ +// Local dev: run the server directly. It calls service.load(), which reads +// DB_URL, CATALOG_URL and PORT from your environment — set them before running. +import '../src/server.ts'; diff --git a/examples/store/modules/orders/src/contract.ts b/examples/store/modules/orders/src/contract.ts new file mode 100644 index 00000000..fd749dc9 --- /dev/null +++ b/examples/store/modules/orders/src/contract.ts @@ -0,0 +1,28 @@ +/** + * orders' public RPC contract. `placeOrder` snapshots the product's name and + * price at placement time — later catalog changes don't rewrite history. + */ +import { contract, rpc } from '@prisma/compose/rpc'; +import { type } from 'arktype'; + +export const order = type({ + id: 'string', + productId: 'string', + productName: 'string', + quantity: 'number', + totalCents: 'number', + placedAt: 'string', +}); + +export type Order = typeof order.infer; + +export const ordersContract = contract({ + placeOrder: rpc({ + input: type({ productId: 'string', quantity: 'number' }), + output: type({ order: order.or('null') }), + }), + listOrders: rpc({ + input: type({}), + output: type({ orders: order.array() }), + }), +}); diff --git a/examples/store/modules/orders/src/module.ts b/examples/store/modules/orders/src/module.ts new file mode 100644 index 00000000..14476b50 --- /dev/null +++ b/examples/store/modules/orders/src/module.ts @@ -0,0 +1,25 @@ +/** + * The orders Module: owns its own Postgres, but NOT the catalog — that comes + * in through the module's boundary (`deps.catalog`), wired by whoever + * provisions this module. The consumer supplies any producer of + * `catalogContract`; orders never knows which. + */ +import { module } from '@prisma/compose'; +import { rpc } from '@prisma/compose/rpc'; +import { postgres } from '@prisma/compose-prisma-cloud'; +import { catalogContract } from '@store/catalog/contract'; +import { ordersContract } from './contract.ts'; +import ordersService from './service.ts'; + +export default module( + 'orders', + { deps: { catalog: rpc(catalogContract) }, expose: { rpc: ordersContract } }, + ({ inputs, provision }) => { + const db = provision(postgres({ name: 'database' })); + const service = provision(ordersService, { + id: 'service', + deps: { db, catalog: inputs.catalog }, + }); + return { rpc: service.rpc }; + }, +); diff --git a/examples/store/modules/orders/src/server.ts b/examples/store/modules/orders/src/server.ts new file mode 100644 index 00000000..ff8508b3 --- /dev/null +++ b/examples/store/modules/orders/src/server.ts @@ -0,0 +1,65 @@ +import { serve } from '@prisma/compose/rpc'; +import { SQL } from 'bun'; +import type { Order } from './contract.ts'; +import service from './service.ts'; + +// load() hydrates both deps: db is a PostgresConfig, catalog is a typed +// client of catalogContract — calling it is a plain async function call. +const { db, catalog } = service.load(); +const { port } = service.config(); + +// One pool per process. idleTimeout closes the pooled connection before +// Compute's scale-to-zero drops it, so the next request reconnects instead of +// erroring (FT-5219). +const sql = new SQL({ url: db.url, max: 1, idleTimeout: 10 }); + +// A Prisma Postgres direct connection is closed when it goes idle. Bun.SQL +// surfaces that as an async error with no awaiter, which would otherwise +// crash the process into a restart loop. +process.on('uncaughtException', (err) => console.error('uncaughtException', err)); +process.on('unhandledRejection', (err) => console.error('unhandledRejection', err)); + +await sql` + create table if not exists orders ( + id text primary key, + product_id text not null, + product_name text not null, + quantity integer not null, + total_cents integer not null, + placed_at timestamptz not null default now() + ) +`; + +const toOrder = (row: Record): Order => ({ + id: String(row.id), + productId: String(row.product_id), + productName: String(row.product_name), + quantity: Number(row.quantity), + totalCents: Number(row.total_cents), + placedAt: new Date(String(row.placed_at)).toISOString(), +}); + +const handler = serve(service, { + rpc: { + placeOrder: async ({ productId, quantity }) => { + const { product } = await catalog.getProduct({ id: productId }); + if (product === null || quantity < 1) return { order: null }; + + const rows = await sql` + insert into orders (id, product_id, product_name, quantity, total_cents) + values (${crypto.randomUUID()}, ${product.id}, ${product.name}, ${quantity}, ${product.priceCents * quantity}) + returning * + `; + return { order: toOrder(rows[0]) }; + }, + listOrders: async () => { + const rows = await sql`select * from orders order by placed_at desc limit 20`; + return { orders: rows.map(toOrder) }; + }, + }, +}); +export default handler; + +// Bind all interfaces — Compute routes external HTTP to the VM, so a +// loopback-only listener would be unreachable. +Bun.serve({ port, hostname: '0.0.0.0', fetch: handler }); diff --git a/examples/store/modules/orders/src/service.ts b/examples/store/modules/orders/src/service.ts new file mode 100644 index 00000000..376a510c --- /dev/null +++ b/examples/store/modules/orders/src/service.ts @@ -0,0 +1,18 @@ +import node from '@prisma/compose/node'; +import { rpc } from '@prisma/compose/rpc'; +import { compute, postgres } from '@prisma/compose-prisma-cloud'; +import { catalogContract } from '@store/catalog/contract'; +import { ordersContract } from './contract.ts'; + +// Two dependencies, two kinds: `db` binds to PostgresConfig (the app builds +// its own client, ADR-0015); `catalog` hydrates to a typed client of another +// module's contract. +export default compute({ + name: 'orders', + deps: { + db: postgres(), + catalog: rpc(catalogContract), + }, + build: node({ module: import.meta.url, entry: '../dist/server.mjs' }), + expose: { rpc: ordersContract }, +}); diff --git a/examples/store/modules/orders/testing/fake.ts b/examples/store/modules/orders/testing/fake.ts new file mode 100644 index 00000000..eb111a72 --- /dev/null +++ b/examples/store/modules/orders/testing/fake.ts @@ -0,0 +1,44 @@ +/** + * An in-memory orders service for TESTING a module that depends on it — no + * Postgres, no catalog, no deploy. It serves the real `ordersContract`, so its + * handler map is type-checked against the same contract the real orders + * exposes. Test-only, deliberately outside `src/`. + */ +import node from '@prisma/compose/node'; +import { serve } from '@prisma/compose/rpc'; +import { compute } from '@prisma/compose-prisma-cloud'; +import { type Order, ordersContract } from '../src/contract.ts'; + +export const FAKE_ORDERS: Order[] = [ + { + id: 'order-1', + productId: 'espresso', + productName: 'Espresso', + quantity: 2, + totalCents: 700, + placedAt: '2026-07-13T08:00:00.000Z', + }, +]; + +const fakeOrders = compute({ + name: 'orders-fake', + deps: {}, + build: node({ module: import.meta.url, entry: 'fake.ts' }), + expose: { rpc: ordersContract }, +}); + +export default serve(fakeOrders, { + rpc: { + placeOrder: async ({ productId, quantity }) => ({ + order: { + id: `order-${FAKE_ORDERS.length + 1}`, + productId, + productName: productId, + quantity, + totalCents: 100 * quantity, + placedAt: '2026-07-13T09:00:00.000Z', + }, + }), + listOrders: async () => ({ orders: FAKE_ORDERS }), + }, +}); diff --git a/examples/store/modules/orders/tsconfig.json b/examples/store/modules/orders/tsconfig.json new file mode 100644 index 00000000..5b986d35 --- /dev/null +++ b/examples/store/modules/orders/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "Preserve", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": false, + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "types": ["bun"] + }, + "include": ["src"] +} diff --git a/examples/store/modules/orders/tsdown.config.ts b/examples/store/modules/orders/tsdown.config.ts new file mode 100644 index 00000000..c0325ced --- /dev/null +++ b/examples/store/modules/orders/tsdown.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsdown'; + +// Build only the runnable (src/server.ts). @prisma/*, @store/* and arktype are +// inlined — node_modules isn't shipped; `bun` is a Compute runtime built-in. +export default defineConfig({ + entry: { server: 'src/server.ts' }, + outDir: 'dist', + format: 'esm', + platform: 'node', + external: ['bun'], + noExternal: [/^@prisma\//, /^@store\//, /^arktype/], + dts: false, + sourcemap: false, + clean: true, +}); diff --git a/examples/store/modules/storefront/.gitignore b/examples/store/modules/storefront/.gitignore new file mode 100644 index 00000000..81f74ff7 --- /dev/null +++ b/examples/store/modules/storefront/.gitignore @@ -0,0 +1,2 @@ +.next/ +next-env.d.ts diff --git a/examples/store/modules/storefront/app/globals.css b/examples/store/modules/storefront/app/globals.css new file mode 100644 index 00000000..d8fff583 --- /dev/null +++ b/examples/store/modules/storefront/app/globals.css @@ -0,0 +1,66 @@ +:root { + color-scheme: light dark; + font-family: ui-sans-serif, system-ui, sans-serif; +} + +body { + margin: 0 auto; + max-width: 40rem; + padding: 2rem 1rem; + line-height: 1.5; +} + +h1 { + margin-bottom: 0; +} + +.tagline { + margin-top: 0.25rem; + opacity: 0.7; +} + +.product { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 0; + border-bottom: 1px solid color-mix(in srgb, currentColor 15%, transparent); +} + +.product-info { + flex: 1; +} + +.product-info p { + margin: 0; + opacity: 0.7; + font-size: 0.9rem; +} + +.product-info strong { + font-size: 1.05rem; +} + +.price { + font-variant-numeric: tabular-nums; +} + +button { + font: inherit; + padding: 0.4rem 1rem; + border: 1px solid currentColor; + border-radius: 0.5rem; + background: transparent; + cursor: pointer; +} + +.order { + display: flex; + justify-content: space-between; + padding: 0.4rem 0; + font-size: 0.95rem; +} + +.order time { + opacity: 0.6; +} diff --git a/examples/store/modules/storefront/app/layout.tsx b/examples/store/modules/storefront/app/layout.tsx new file mode 100644 index 00000000..17d97082 --- /dev/null +++ b/examples/store/modules/storefront/app/layout.tsx @@ -0,0 +1,11 @@ +import './globals.css'; + +export const metadata = { title: 'Compose Coffee' }; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/examples/store/modules/storefront/app/page.test.tsx b/examples/store/modules/storefront/app/page.test.tsx new file mode 100644 index 00000000..5963884b --- /dev/null +++ b/examples/store/modules/storefront/app/page.test.tsx @@ -0,0 +1,52 @@ +/** + * Unit proof (testing.md § Unit): mocks storefront's own service module so + * `load()` returns typed fakes for catalog and orders via `mockService`, then + * renders the page directly — no server, no environment, no cloud. + */ +import { mockService } from '@prisma/compose/testing'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import type Service from '../src/service.ts'; + +vi.mock('../src/service.ts', async () => { + const actual = await vi.importActual<{ default: typeof Service }>('../src/service.ts'); + return { + default: mockService(actual.default, { + catalog: { + listProducts: async () => ({ + products: [ + { id: 'espresso', name: 'Espresso', description: 'A double shot.', priceCents: 350 }, + ], + }), + getProduct: async () => ({ product: null }), + }, + orders: { + placeOrder: async () => ({ order: null }), + listOrders: async () => ({ + orders: [ + { + id: 'order-1', + productId: 'espresso', + productName: 'Espresso', + quantity: 2, + totalCents: 700, + placedAt: '2026-07-13T08:00:00.000Z', + }, + ], + }), + }, + }), + }; +}); + +describe('Home (page.tsx)', () => { + it('renders products from catalog and orders from orders, load() stubbed to fakes', async () => { + const { default: Home } = await import('./page.tsx'); + + const html = renderToStaticMarkup(await Home()); + + expect(html).toContain('Espresso'); + expect(html).toContain('$3.50'); + expect(html).toContain('$7.00'); + }); +}); diff --git a/examples/store/modules/storefront/app/page.tsx b/examples/store/modules/storefront/app/page.tsx new file mode 100644 index 00000000..3e12801c --- /dev/null +++ b/examples/store/modules/storefront/app/page.tsx @@ -0,0 +1,59 @@ +import { revalidatePath } from 'next/cache'; +import service from '../src/service.ts'; + +// service.load() needs the runtime environment, which doesn't exist at build +// time — so render per request instead of prerendering. +export const dynamic = 'force-dynamic'; + +const price = (cents: number) => `$${(cents / 100).toFixed(2)}`; + +async function buy(formData: FormData) { + 'use server'; + const { orders } = service.load(); + await orders.placeOrder({ productId: String(formData.get('productId')), quantity: 1 }); + revalidatePath('/'); +} + +export default async function Home() { + // Two typed clients, injected by the root module's wiring. This page + // doesn't know where catalog and orders run — only their contracts. + const { catalog, orders } = service.load(); + const [{ products }, { orders: recent }] = await Promise.all([ + catalog.listProducts({}), + orders.listOrders({}), + ]); + + return ( +
+

Compose Coffee

+

+ A Prisma App: this Next.js storefront + a catalog module + an orders module. +

+ +

Menu

+ {products.map((p) => ( +
+
+ {p.name} {price(p.priceCents)} +

{p.description}

+
+
+ + +
+
+ ))} + +

Recent orders

+ {recent.length === 0 &&

No orders yet — buy something!

} + {recent.map((o) => ( +
+ + {o.quantity} × {o.productName} — {price(o.totalCents)} + + +
+ ))} +
+ ); +} diff --git a/examples/store/modules/storefront/next.config.ts b/examples/store/modules/storefront/next.config.ts new file mode 100644 index 00000000..ac764c84 --- /dev/null +++ b/examples/store/modules/storefront/next.config.ts @@ -0,0 +1,24 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { NextConfig } from 'next'; + +// The monorepo root: standalone file tracing must start here, or Next walks up +// to the outer checkout's lockfile and traces the wrong node_modules. +const workspaceRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); + +// Standalone output is what Prisma Compute deploys — a self-contained server.js +// plus the minimal node_modules, not a `next start` dev server. +const nextConfig: NextConfig = { + output: 'standalone', + outputFileTracingRoot: workspaceRoot, + images: { unoptimized: true }, + // Next traces native binaries (`sharp`, `@next/swc`) built for THIS + // machine's platform into the standalone; on Compute's linux VM bun + // auto-installs their linux variants at boot and fills the tiny disk + // (ENOSPC crash loop). Exclude them from the trace. + outputFileTracingExcludes: { + '*': ['**/node_modules/@next/swc-*/**', '**/node_modules/sharp/**', '**/node_modules/@img/**'], + }, +}; + +export default nextConfig; diff --git a/examples/store/modules/storefront/package.json b/examples/store/modules/storefront/package.json new file mode 100644 index 00000000..5c0d5b22 --- /dev/null +++ b/examples/store/modules/storefront/package.json @@ -0,0 +1,33 @@ +{ + "name": "@store/storefront", + "version": "0.1.0", + "private": true, + "exports": { + ".": "./src/service.ts" + }, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@prisma/compose": "workspace:0.1.0", + "@prisma/compose-prisma-cloud": "workspace:0.1.0", + "@store/catalog": "workspace:0.1.0", + "@store/orders": "workspace:0.1.0", + "arktype": "^2.2.3", + "next": "^16.2.9", + "react": "19.2.7", + "react-dom": "19.2.7" + }, + "devDependencies": { + "@types/bun": "^1.3.13", + "@types/node": "^25.9.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.0", + "typescript": "^6.0.3", + "vitest": "^4.1.9" + } +} diff --git a/examples/store/modules/storefront/src/service.ts b/examples/store/modules/storefront/src/service.ts new file mode 100644 index 00000000..50428cc3 --- /dev/null +++ b/examples/store/modules/storefront/src/service.ts @@ -0,0 +1,17 @@ +import nextjs from '@prisma/compose/nextjs'; +import { rpc } from '@prisma/compose/rpc'; +import { compute } from '@prisma/compose-prisma-cloud'; +import { catalogContract } from '@store/catalog/contract'; +import { ordersContract } from '@store/orders/contract'; + +// The storefront's whole declaration: what it is (a Next.js compute service) +// and what it needs (typed clients of the catalog and orders contracts). Who +// provides them is the root module's decision, not this file's. +export default compute({ + name: 'storefront', + deps: { + catalog: rpc(catalogContract), + orders: rpc(ordersContract), + }, + build: nextjs({ module: import.meta.url, appDir: '..', entry: 'server.js' }), +}); diff --git a/examples/store/modules/storefront/tsconfig.json b/examples/store/modules/storefront/tsconfig.json new file mode 100644 index 00000000..57b85d97 --- /dev/null +++ b/examples/store/modules/storefront/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] +} diff --git a/examples/store/modules/storefront/vitest.config.ts b/examples/store/modules/storefront/vitest.config.ts new file mode 100644 index 00000000..ee8dda7e --- /dev/null +++ b/examples/store/modules/storefront/vitest.config.ts @@ -0,0 +1,13 @@ +import { configDefaults, defineConfig } from 'vitest/config'; + +// page.tsx relies on Next's automatic JSX runtime (no `import React` in +// scope) and its own tsconfig sets `jsx: "preserve"` for Next's own +// compiler — vite's oxc transform needs an explicit override so it doesn't +// inherit that setting. +export default defineConfig({ + oxc: { jsx: { runtime: 'automatic' } }, + test: { + environment: 'node', + exclude: [...configDefaults.exclude, '**/*.integration.test.ts'], + }, +}); diff --git a/examples/store/package.json b/examples/store/package.json new file mode 100644 index 00000000..97b8d911 --- /dev/null +++ b/examples/store/package.json @@ -0,0 +1,28 @@ +{ + "name": "@prisma/example-store", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "echo 'nothing to build for this package itself — the modules build via turbo ^build, from the workspace deps below'", + "dev": "bun run scripts/dev.ts", + "typecheck": "tsc --noEmit", + "deploy": "pnpm turbo run build --filter @prisma/example-store... && ( set -a; . ../../.env; set +a; bun node_modules/.bin/prisma-compose deploy module.ts ${STORE_STACK_NAME:+--name \"$STORE_STACK_NAME\"} )", + "destroy": "( set -a; . ../../.env; set +a; bun node_modules/.bin/prisma-compose destroy module.ts --production ${STORE_STACK_NAME:+--name \"$STORE_STACK_NAME\"} )" + }, + "dependencies": { + "@effect/platform-bun": "4.0.0-beta.92", + "@prisma/compose": "workspace:0.1.0", + "@prisma/compose-prisma-cloud": "workspace:0.1.0", + "@store/catalog": "workspace:0.1.0", + "@store/orders": "workspace:0.1.0", + "@store/storefront": "workspace:0.1.0", + "alchemy": "2.0.0-beta.59", + "arktype": "^2.2.3", + "effect": "4.0.0-beta.92" + }, + "devDependencies": { + "@types/bun": "^1.3.13", + "typescript": "^6.0.3" + } +} diff --git a/examples/store/prisma-compose.config.ts b/examples/store/prisma-compose.config.ts new file mode 100644 index 00000000..1b58bb00 --- /dev/null +++ b/examples/store/prisma-compose.config.ts @@ -0,0 +1,13 @@ +/** + * The app's control-plane config (ADR-0017) — read ONLY by `prisma-compose + * deploy`/`destroy`, never imported by app code. + */ +import { defineConfig } from '@prisma/compose/config'; +import { nextjsBuild } from '@prisma/compose/nextjs/control'; +import { nodeBuild } from '@prisma/compose/node/control'; +import { prismaCloud, prismaState } from '@prisma/compose-prisma-cloud/control'; + +export default defineConfig({ + extensions: [prismaCloud(), nodeBuild(), nextjsBuild()], + state: () => prismaState(), +}); diff --git a/examples/store/scripts/dev.ts b/examples/store/scripts/dev.ts new file mode 100644 index 00000000..81ce11a1 --- /dev/null +++ b/examples/store/scripts/dev.ts @@ -0,0 +1,55 @@ +/** + * Local dev, no cloud: serve an in-memory catalog and orders on loopback + * ports, then run the storefront via `next dev` with CATALOG_URL/ORDERS_URL + * pointing at them. The storefront can't tell the difference — it talks to + * any producer of the contracts, and these fakes are type-checked against + * the same contracts the real modules serve. + */ +import node from '@prisma/compose/node'; +import { serve } from '@prisma/compose/rpc'; +import { compute } from '@prisma/compose-prisma-cloud'; +import fakeCatalogHandler, { FAKE_PRODUCTS } from '@store/catalog/fake'; +import { type Order, ordersContract } from '@store/orders/contract'; + +const catalog = Bun.serve({ port: 0, fetch: fakeCatalogHandler }); + +// Stateful, unlike @store/orders/fake: placed orders show up in the UI. +const placed: Order[] = []; +const ordersNode = compute({ + name: 'orders-dev', + deps: {}, + build: node({ module: import.meta.url, entry: 'dev.ts' }), + expose: { rpc: ordersContract }, +}); +const orders = Bun.serve({ + port: 0, + fetch: serve(ordersNode, { + rpc: { + placeOrder: async ({ productId, quantity }) => { + const product = FAKE_PRODUCTS.find((p) => p.id === productId); + if (!product || quantity < 1) return { order: null }; + const order: Order = { + id: crypto.randomUUID(), + productId: product.id, + productName: product.name, + quantity, + totalCents: product.priceCents * quantity, + placedAt: new Date().toISOString(), + }; + placed.unshift(order); + return { order }; + }, + listOrders: async () => ({ orders: placed }), + }, + }), +}); + +console.log(`fake catalog ${catalog.url}`); +console.log(`fake orders ${orders.url}`); + +const storefront = Bun.spawn(['pnpm', 'next', 'dev'], { + cwd: new URL('../modules/storefront', import.meta.url).pathname, + env: { ...process.env, CATALOG_URL: catalog.url.href, ORDERS_URL: orders.url.href }, + stdio: ['inherit', 'inherit', 'inherit'], +}); +await storefront.exited; diff --git a/examples/store/tsconfig.json b/examples/store/tsconfig.json new file mode 100644 index 00000000..e09a8d21 --- /dev/null +++ b/examples/store/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["bun"] + }, + "include": ["module.ts"] +} diff --git a/examples/store/turbo.json b/examples/store/turbo.json new file mode 100644 index 00000000..b743d5d2 --- /dev/null +++ b/examples/store/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": [] + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc758e01..16a36acb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,136 @@ importers: specifier: ^6.0.3 version: 6.0.3 + examples/store: + dependencies: + '@effect/platform-bun': + specifier: 4.0.0-beta.92 + version: 4.0.0-beta.92(effect@4.0.0-beta.92) + '@prisma/compose': + specifier: workspace:0.1.0 + version: link:../../packages/9-public/compose + '@prisma/compose-prisma-cloud': + specifier: workspace:0.1.0 + version: link:../../packages/9-public/compose-prisma-cloud + '@store/catalog': + specifier: workspace:0.1.0 + version: link:modules/catalog + '@store/orders': + specifier: workspace:0.1.0 + version: link:modules/orders + '@store/storefront': + specifier: workspace:0.1.0 + version: link:modules/storefront + alchemy: + specifier: 2.0.0-beta.59 + version: 2.0.0-beta.59(@effect/platform-bun@4.0.0-beta.92(effect@4.0.0-beta.92))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.92)(ioredis@5.11.1))(@types/node@25.9.5)(@types/react@19.2.17)(effect@4.0.0-beta.92)(vite@8.1.2(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.9(@types/node@25.9.5)(vite@8.1.2(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260617.1)(ws@8.21.0) + arktype: + specifier: ^2.2.3 + version: 2.2.3 + effect: + specifier: 4.0.0-beta.92 + version: 4.0.0-beta.92 + devDependencies: + '@types/bun': + specifier: ^1.3.13 + version: 1.3.14 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + examples/store/modules/catalog: + dependencies: + arktype: + specifier: ^2.2.3 + version: 2.2.3 + devDependencies: + '@prisma/compose': + specifier: workspace:0.1.0 + version: link:../../../../packages/9-public/compose + '@prisma/compose-prisma-cloud': + specifier: workspace:0.1.0 + version: link:../../../../packages/9-public/compose-prisma-cloud + '@types/bun': + specifier: ^1.3.13 + version: 1.3.14 + tsdown: + specifier: ^0.22.3 + version: 0.22.3(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + examples/store/modules/orders: + dependencies: + '@store/catalog': + specifier: workspace:0.1.0 + version: link:../catalog + arktype: + specifier: ^2.2.3 + version: 2.2.3 + devDependencies: + '@prisma/compose': + specifier: workspace:0.1.0 + version: link:../../../../packages/9-public/compose + '@prisma/compose-prisma-cloud': + specifier: workspace:0.1.0 + version: link:../../../../packages/9-public/compose-prisma-cloud + '@types/bun': + specifier: ^1.3.13 + version: 1.3.14 + tsdown: + specifier: ^0.22.3 + version: 0.22.3(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + examples/store/modules/storefront: + dependencies: + '@prisma/compose': + specifier: workspace:0.1.0 + version: link:../../../../packages/9-public/compose + '@prisma/compose-prisma-cloud': + specifier: workspace:0.1.0 + version: link:../../../../packages/9-public/compose-prisma-cloud + '@store/catalog': + specifier: workspace:0.1.0 + version: link:../catalog + '@store/orders': + specifier: workspace:0.1.0 + version: link:../orders + arktype: + specifier: ^2.2.3 + version: 2.2.3 + next: + specifier: ^16.2.9 + version: 16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: + specifier: 19.2.7 + version: 19.2.7 + react-dom: + specifier: 19.2.7 + version: 19.2.7(react@19.2.7) + devDependencies: + '@types/bun': + specifier: ^1.3.13 + version: 1.3.14 + '@types/node': + specifier: ^25.9.3 + version: 25.9.5 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.0 + version: 19.2.3(@types/react@19.2.17) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@25.9.5)(vite@8.1.2(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)) + examples/storefront-auth: dependencies: '@effect/platform-bun': From 50ec529b208e6cc881888e878ee843e32438e91d Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 14:01:12 +0200 Subject: [PATCH 2/9] feat(examples/store): rotate a daily special via the shared cron module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demonstrates composing a framework-shipped module: promotions defines the schedule (rotateSpecial: 30s) and the runner mapping that job id to catalog.rotateSpecial(); the root provisions cron({ schedule, runner }) and wires catalog.rpc into its boundary like any other edge. catalog grows getSpecial/rotateSpecial and a singleton special table; the storefront marks the special with a ★ that moves every 30s. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- examples/store/.prisma-compose/alchemy.run.ts | 27 +++++++++++++++ examples/store/DEMO.md | 23 +++++++++++-- examples/store/README.md | 18 ++++++++-- examples/store/module.ts | 13 ++++++- .../store/modules/catalog/src/contract.ts | 8 +++++ examples/store/modules/catalog/src/server.ts | 34 +++++++++++++++++++ .../store/modules/catalog/testing/fake.ts | 7 ++++ .../store/modules/promotions/package.json | 28 +++++++++++++++ .../store/modules/promotions/src/server.ts | 19 +++++++++++ .../store/modules/promotions/src/service.ts | 20 +++++++++++ .../store/modules/promotions/tsconfig.json | 17 ++++++++++ .../store/modules/promotions/tsdown.config.ts | 15 ++++++++ .../store/modules/storefront/app/globals.css | 6 ++++ .../modules/storefront/app/page.test.tsx | 10 ++++++ .../store/modules/storefront/app/page.tsx | 4 ++- examples/store/package.json | 1 + pnpm-lock.yaml | 28 +++++++++++++++ 17 files changed, 271 insertions(+), 7 deletions(-) create mode 100644 examples/store/.prisma-compose/alchemy.run.ts create mode 100644 examples/store/modules/promotions/package.json create mode 100644 examples/store/modules/promotions/src/server.ts create mode 100644 examples/store/modules/promotions/src/service.ts create mode 100644 examples/store/modules/promotions/tsconfig.json create mode 100644 examples/store/modules/promotions/tsdown.config.ts diff --git a/examples/store/.prisma-compose/alchemy.run.ts b/examples/store/.prisma-compose/alchemy.run.ts new file mode 100644 index 00000000..a606fac9 --- /dev/null +++ b/examples/store/.prisma-compose/alchemy.run.ts @@ -0,0 +1,27 @@ +// Generated by `prisma-compose deploy`/`prisma-compose destroy` — overwritten on every +// run; do not edit by hand. Independently runnable from "/Users/will/Projects/prisma/compose/.claude/worktrees/prisma-compose-demo-app-07178e/examples/store": +// +// alchemy deploy .prisma-compose/alchemy.run.ts +// +// bisects a CLI bug from an Alchemy bug (deploy-cli.md § Implementation decisions). +import { lower } from '@prisma/compose/deploy'; +import app from '../module.ts'; +import config from '../prisma-compose.config.ts'; + +export default lower(app, config, { + name: 'store', + bundles: { + 'catalog.service': { + dir: '/Users/will/Projects/prisma/compose/.claude/worktrees/prisma-compose-demo-app-07178e/examples/store/modules/catalog/dist/bundle', + entry: 'server.mjs', + }, + 'orders.service': { + dir: '/Users/will/Projects/prisma/compose/.claude/worktrees/prisma-compose-demo-app-07178e/examples/store/modules/orders/dist/bundle', + entry: 'server.mjs', + }, + storefront: { + dir: '/Users/will/Projects/prisma/compose/.claude/worktrees/prisma-compose-demo-app-07178e/examples/store/modules/storefront/.next/standalone/examples/store/modules/storefront', + entry: 'server.js', + }, + }, +}); diff --git a/examples/store/DEMO.md b/examples/store/DEMO.md index 65e05e21..99415cbd 100644 --- a/examples/store/DEMO.md +++ b/examples/store/DEMO.md @@ -29,13 +29,30 @@ input** — the parent supplies any producer of `catalogContract`. In `catalog.getProduct()` — a typed async call, no URL, no fetch, no client setup. It doesn't know or care where catalog runs. -## 5. Plain app code — [modules/storefront/app/page.tsx](modules/storefront/app/page.tsx) +## 5. A shared module — cron rotating the ★ special + +[module.ts](module.ts) provisions `cron({ schedule, runner })` — the cron +module ships with the framework +(`@prisma/compose-prisma-cloud/cron`), not this app. Two points: + +- [modules/promotions/src/service.ts](modules/promotions/src/service.ts): + the app supplies only the schedule (`rotateSpecial: '30s'`) and a runner + whose handler maps the job id to `catalog.rotateSpecial()` + ([src/server.ts](modules/promotions/src/server.ts)). `serveSchedule` is + exhaustive over the schedule's ids at compile time. +- The cron module's boundary deps mirror the runner's own deps, so the root + wires `catalog: catalog.rpc` into it exactly like any other edge — a + reusable module composes the same way app modules do. + +Live proof: watch the ★ move to the next product every 30s on refresh. + +## 6. Plain app code — [modules/storefront/app/page.tsx](modules/storefront/app/page.tsx) A normal Next.js server component. `service.load()` hands it two typed clients, injected by the root's wiring. The Buy button is a server action calling `orders.placeOrder`. -## 6. Show it live +## 7. Show it live Deployed: run the deployed storefront URL, buy a coffee, watch it appear in recent orders (two RPC hops + two Postgres writes behind one click). @@ -43,7 +60,7 @@ recent orders (two RPC hops + two Postgres writes behind one click). Fallback if the cloud misbehaves: `pnpm dev` here runs the same storefront against in-memory fakes on loopback — same contracts, zero cloud. -## 7. Testing story (if time) +## 8. Testing story (if time) [page.test.tsx](modules/storefront/app/page.test.tsx): `mockService` swaps `load()` for typed fakes — the handler maps are type-checked against the same diff --git a/examples/store/README.md b/examples/store/README.md index 94fb1839..9877e774 100644 --- a/examples/store/README.md +++ b/examples/store/README.md @@ -1,7 +1,8 @@ # store — Compose Coffee -A readable example Prisma App: a Next.js storefront, a **catalog** module, and -an **orders** module. +A readable example Prisma App: a Next.js storefront, a **catalog** module, an +**orders** module, and the shared **cron** module rotating the special of the +day. ```mermaid flowchart TB @@ -20,11 +21,17 @@ flowchart TB ODB[("Postgres — private")] OR --- ODB end + subgraph Cron["Module: cron (shared)"] + SCH["scheduler · Compute Service"] + PR["promotions runner · Compute Service"] + SCH -->|"trigger(jobId)"| PR + end User -->|HTTP| SF SF -->|rpc · catalogContract| CA SF -->|rpc · ordersContract| OR OR -->|rpc · catalogContract| CA + PR -->|rpc · catalogContract| CA ``` Each module owns its own Postgres; the only edges between components are the @@ -42,6 +49,13 @@ typed RPC contracts. The whole composition is [module.ts](module.ts). - [modules/storefront](modules/storefront) — a real Next.js app. The page calls both typed clients; the Buy button is a server action that places an order. +- [modules/promotions](modules/promotions) + the shared + `@prisma/compose-prisma-cloud/cron` module — **composition of a shared + module**. promotions defines the schedule + (`rotateSpecial: '30s'`) and the runner that maps the job id to + `catalog.rotateSpecial()`; the root provisions `cron({ schedule, runner })` + and wires `catalog.rpc` into its boundary like any other edge. The ★ + special on the menu moves every 30 seconds. ## Run it locally (no cloud) diff --git a/examples/store/module.ts b/examples/store/module.ts index 55e02f69..4883882e 100644 --- a/examples/store/module.ts +++ b/examples/store/module.ts @@ -1,20 +1,31 @@ import { module } from '@prisma/compose'; +import { cron } from '@prisma/compose-prisma-cloud/cron'; import catalogModule from '@store/catalog'; import ordersModule from '@store/orders'; +import promotionsService, { schedule } from '@store/promotions'; import storefrontService from '@store/storefront'; /** - * The store app: three components, three edges. + * The store app: four components, four edges. * * storefront ──rpc──▶ catalog (browse products) * storefront ──rpc──▶ orders (place + list orders) * orders ──rpc──▶ catalog (price an order at placement time) + * cron ──rpc──▶ catalog (rotate the special of the day, every 30s) * * catalog and orders each own their own Postgres internally — the root never * sees it. All it wires are the exposed, typed rpc ports. + * + * `cron` is a SHARED module (@prisma/compose-prisma-cloud/cron), not app + * code: it takes our schedule and our promotions runner, and its boundary + * deps mirror the runner's own — so the root wires `catalog` into it exactly + * as it would for any other consumer of that contract. */ export default module('store', ({ provision }) => { const catalog = provision(catalogModule); const orders = provision(ordersModule, { deps: { catalog: catalog.rpc } }); provision(storefrontService, { deps: { catalog: catalog.rpc, orders: orders.rpc } }); + provision(cron({ schedule, runner: promotionsService }), { + deps: { catalog: catalog.rpc }, + }); }); diff --git a/examples/store/modules/catalog/src/contract.ts b/examples/store/modules/catalog/src/contract.ts index 1a52e234..29da717b 100644 --- a/examples/store/modules/catalog/src/contract.ts +++ b/examples/store/modules/catalog/src/contract.ts @@ -24,4 +24,12 @@ export const catalogContract = contract({ input: type({ id: 'string' }), output: type({ product: product.or('null') }), }), + getSpecial: rpc({ + input: type({}), + output: type({ product: product.or('null') }), + }), + rotateSpecial: rpc({ + input: type({}), + output: type({ product: product.or('null') }), + }), }); diff --git a/examples/store/modules/catalog/src/server.ts b/examples/store/modules/catalog/src/server.ts index bcf3d5b2..2808d363 100644 --- a/examples/store/modules/catalog/src/server.ts +++ b/examples/store/modules/catalog/src/server.ts @@ -47,6 +47,14 @@ await sql` price_cents integer not null ) `; +// Single-row table holding the current special of the day; the promotions +// cron job advances it via rotateSpecial. +await sql` + create table if not exists special ( + singleton boolean primary key default true, + product_id text not null + ) +`; for (const p of SEED) { await sql` insert into products (id, name, description, price_cents) @@ -54,6 +62,11 @@ for (const p of SEED) { on conflict (id) do nothing `; } +await sql` + insert into special (singleton, product_id) + values (true, ${SEED[0].id}) + on conflict (singleton) do nothing +`; const toProduct = (row: Record): Product => ({ id: String(row.id), @@ -72,6 +85,27 @@ const handler = serve(service, { const rows = await sql`select * from products where id = ${id}`; return { product: rows.length > 0 ? toProduct(rows[0]) : null }; }, + getSpecial: async () => { + const rows = await sql` + select p.* from special s join products p on p.id = s.product_id + `; + return { product: rows.length > 0 ? toProduct(rows[0]) : null }; + }, + rotateSpecial: async () => { + const products = await sql`select * from products order by name`; + if (products.length === 0) return { product: null }; + + const current = await sql`select product_id from special`; + const currentIdx = products.findIndex( + (p: Record) => p.id === current[0]?.product_id, + ); + const next = toProduct(products[(currentIdx + 1) % products.length]); + await sql` + insert into special (singleton, product_id) values (true, ${next.id}) + on conflict (singleton) do update set product_id = ${next.id} + `; + return { product: next }; + }, }, }); export default handler; diff --git a/examples/store/modules/catalog/testing/fake.ts b/examples/store/modules/catalog/testing/fake.ts index 87b70ba8..f975bf02 100644 --- a/examples/store/modules/catalog/testing/fake.ts +++ b/examples/store/modules/catalog/testing/fake.ts @@ -21,11 +21,18 @@ const fakeCatalog = compute({ expose: { rpc: catalogContract }, }); +let specialIdx = 0; + export default serve(fakeCatalog, { rpc: { listProducts: async () => ({ products: FAKE_PRODUCTS }), getProduct: async ({ id }) => ({ product: FAKE_PRODUCTS.find((p) => p.id === id) ?? null, }), + getSpecial: async () => ({ product: FAKE_PRODUCTS[specialIdx] }), + rotateSpecial: async () => { + specialIdx = (specialIdx + 1) % FAKE_PRODUCTS.length; + return { product: FAKE_PRODUCTS[specialIdx] }; + }, }, }); diff --git a/examples/store/modules/promotions/package.json b/examples/store/modules/promotions/package.json new file mode 100644 index 00000000..5a3a267c --- /dev/null +++ b/examples/store/modules/promotions/package.json @@ -0,0 +1,28 @@ +{ + "name": "@store/promotions", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/service.ts" + }, + "scripts": { + "build": "tsdown", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@store/catalog": "workspace:0.1.0", + "arktype": "^2.2.3" + }, + "peerDependencies": { + "@prisma/compose": "workspace:0.1.0", + "@prisma/compose-prisma-cloud": "workspace:0.1.0" + }, + "devDependencies": { + "@prisma/compose": "workspace:0.1.0", + "@prisma/compose-prisma-cloud": "workspace:0.1.0", + "@types/bun": "^1.3.13", + "tsdown": "^0.22.3", + "typescript": "^6.0.3" + } +} diff --git a/examples/store/modules/promotions/src/server.ts b/examples/store/modules/promotions/src/server.ts new file mode 100644 index 00000000..bea9497a --- /dev/null +++ b/examples/store/modules/promotions/src/server.ts @@ -0,0 +1,19 @@ +import { serveSchedule } from '@prisma/compose-prisma-cloud/cron'; +import service, { schedule } from './service.ts'; + +// serveSchedule is exhaustive over the schedule's job ids at compile time — +// omitting `rotateSpecial` here would be a type error. The handler body is +// where a job id maps to whatever calls implement it. +const handler = serveSchedule(service, schedule, { + rotateSpecial: async (deps) => { + const { product } = await deps.catalog.rotateSpecial({}); + console.log(`special rotated to ${product?.name ?? '(no products)'}`); + }, +}); +export default handler; + +const { port } = service.config(); + +// Bind all interfaces — Compute routes external HTTP to the VM, so a +// loopback-only listener would be unreachable. +Bun.serve({ port, hostname: '0.0.0.0', fetch: handler }); diff --git a/examples/store/modules/promotions/src/service.ts b/examples/store/modules/promotions/src/service.ts new file mode 100644 index 00000000..1c3dae54 --- /dev/null +++ b/examples/store/modules/promotions/src/service.ts @@ -0,0 +1,20 @@ +/** + * The promotions runner: the work target the shared cron module fires at + * (ADR-0020). The schedule lives here — the one source of truth for the job + * ids serveSchedule forces handlers for and the intervals the scheduler + * fires on. Rotates the catalog's special of the day. + */ +import node from '@prisma/compose/node'; +import { rpc } from '@prisma/compose/rpc'; +import { compute } from '@prisma/compose-prisma-cloud'; +import { defineSchedule, triggerContract } from '@prisma/compose-prisma-cloud/cron'; +import { catalogContract } from '@store/catalog/contract'; + +export const schedule = defineSchedule({ rotateSpecial: '30s' }); + +export default compute({ + name: 'promotions', + deps: { catalog: rpc(catalogContract) }, + build: node({ module: import.meta.url, entry: '../dist/server.mjs' }), + expose: { trigger: triggerContract }, +}); diff --git a/examples/store/modules/promotions/tsconfig.json b/examples/store/modules/promotions/tsconfig.json new file mode 100644 index 00000000..5b986d35 --- /dev/null +++ b/examples/store/modules/promotions/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "Preserve", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": false, + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "types": ["bun"] + }, + "include": ["src"] +} diff --git a/examples/store/modules/promotions/tsdown.config.ts b/examples/store/modules/promotions/tsdown.config.ts new file mode 100644 index 00000000..c0325ced --- /dev/null +++ b/examples/store/modules/promotions/tsdown.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsdown'; + +// Build only the runnable (src/server.ts). @prisma/*, @store/* and arktype are +// inlined — node_modules isn't shipped; `bun` is a Compute runtime built-in. +export default defineConfig({ + entry: { server: 'src/server.ts' }, + outDir: 'dist', + format: 'esm', + platform: 'node', + external: ['bun'], + noExternal: [/^@prisma\//, /^@store\//, /^arktype/], + dts: false, + sourcemap: false, + clean: true, +}); diff --git a/examples/store/modules/storefront/app/globals.css b/examples/store/modules/storefront/app/globals.css index d8fff583..01a8ac3a 100644 --- a/examples/store/modules/storefront/app/globals.css +++ b/examples/store/modules/storefront/app/globals.css @@ -45,6 +45,12 @@ h1 { font-variant-numeric: tabular-nums; } +.special { + margin-left: 0.5rem; + font-size: 0.85rem; + color: #b8860b; +} + button { font: inherit; padding: 0.4rem 1rem; diff --git a/examples/store/modules/storefront/app/page.test.tsx b/examples/store/modules/storefront/app/page.test.tsx index 5963884b..12b843cc 100644 --- a/examples/store/modules/storefront/app/page.test.tsx +++ b/examples/store/modules/storefront/app/page.test.tsx @@ -19,6 +19,15 @@ vi.mock('../src/service.ts', async () => { ], }), getProduct: async () => ({ product: null }), + getSpecial: async () => ({ + product: { + id: 'espresso', + name: 'Espresso', + description: 'A double shot.', + priceCents: 350, + }, + }), + rotateSpecial: async () => ({ product: null }), }, orders: { placeOrder: async () => ({ order: null }), @@ -48,5 +57,6 @@ describe('Home (page.tsx)', () => { expect(html).toContain('Espresso'); expect(html).toContain('$3.50'); expect(html).toContain('$7.00'); + expect(html).toContain('today’s special'); }); }); diff --git a/examples/store/modules/storefront/app/page.tsx b/examples/store/modules/storefront/app/page.tsx index 3e12801c..8862a6f8 100644 --- a/examples/store/modules/storefront/app/page.tsx +++ b/examples/store/modules/storefront/app/page.tsx @@ -18,9 +18,10 @@ export default async function Home() { // Two typed clients, injected by the root module's wiring. This page // doesn't know where catalog and orders run — only their contracts. const { catalog, orders } = service.load(); - const [{ products }, { orders: recent }] = await Promise.all([ + const [{ products }, { orders: recent }, { product: special }] = await Promise.all([ catalog.listProducts({}), orders.listOrders({}), + catalog.getSpecial({}), ]); return ( @@ -35,6 +36,7 @@ export default async function Home() {
{p.name} {price(p.priceCents)} + {special?.id === p.id && ★ today’s special}

{p.description}

diff --git a/examples/store/package.json b/examples/store/package.json index 97b8d911..f29db75e 100644 --- a/examples/store/package.json +++ b/examples/store/package.json @@ -16,6 +16,7 @@ "@prisma/compose-prisma-cloud": "workspace:0.1.0", "@store/catalog": "workspace:0.1.0", "@store/orders": "workspace:0.1.0", + "@store/promotions": "workspace:0.1.0", "@store/storefront": "workspace:0.1.0", "alchemy": "2.0.0-beta.59", "arktype": "^2.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16a36acb..4b3c6530 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,6 +118,9 @@ importers: '@store/orders': specifier: workspace:0.1.0 version: link:modules/orders + '@store/promotions': + specifier: workspace:0.1.0 + version: link:modules/promotions '@store/storefront': specifier: workspace:0.1.0 version: link:modules/storefront @@ -185,6 +188,31 @@ importers: specifier: ^6.0.3 version: 6.0.3 + examples/store/modules/promotions: + dependencies: + '@store/catalog': + specifier: workspace:0.1.0 + version: link:../catalog + arktype: + specifier: ^2.2.3 + version: 2.2.3 + devDependencies: + '@prisma/compose': + specifier: workspace:0.1.0 + version: link:../../../../packages/9-public/compose + '@prisma/compose-prisma-cloud': + specifier: workspace:0.1.0 + version: link:../../../../packages/9-public/compose-prisma-cloud + '@types/bun': + specifier: ^1.3.13 + version: 1.3.14 + tsdown: + specifier: ^0.22.3 + version: 0.22.3(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + examples/store/modules/storefront: dependencies: '@prisma/compose': From f191eaf8ed6e78cd0152ab8345fca8ba0cdeec76 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 14:03:10 +0200 Subject: [PATCH 3/9] fix(node/control): name the wrapper bundle entry so any service filename assembles assemble() passed entry: [serviceModule] to tsdown, which names the output after the entry file basename; the subsequent /^service\.m?js$/ lookup then only matched modules literally named service.*. App services are, but the shared cron scheduler ships scheduler-service.mjs, so deploying any app composing cron() failed with "tsdown produced no service.js". A named entry ({ service: serviceModule }) pins the output filename. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/0-framework/2-authoring/node/src/control.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/0-framework/2-authoring/node/src/control.ts b/packages/0-framework/2-authoring/node/src/control.ts index 5f474a15..2f7ce8eb 100644 --- a/packages/0-framework/2-authoring/node/src/control.ts +++ b/packages/0-framework/2-authoring/node/src/control.ts @@ -69,7 +69,11 @@ export async function assemble(input: AssembleInput): Promise { await fs.promises.mkdir(bundleDir, { recursive: true }); await build({ - entry: [serviceModule], + // Named entry: tsdown derives the output filename from the entry key, so + // the bundle is service.mjs regardless of the module's own basename (a + // shared module's service file may not be named service.ts — the cron + // scheduler's is scheduler-service.mjs). + entry: { service: serviceModule }, outDir: bundleDir, format: 'esm', platform: 'node', From 847b409937074e5b4e89879f12f189f317b32916 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 14:16:04 +0200 Subject: [PATCH 4/9] feat(examples/store): type the databases with Prisma Next MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit catalog and orders each swap postgres() for pnPostgres(contract) (ADR-0022): the data schema is authored in contract.prisma, emitted to a typed contract, and applied by the deploy from migrations/ — the raw create-table-at-boot SQL is gone. load() now hands each server the typed Prisma Next client, replacing Bun.SQL strings and hand-written row mappers with contract-typed queries (db.orm.public.Product.where(...)). RPC contracts, storefront, cron and fakes are unchanged. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- examples/store/.prisma-compose/alchemy.run.ts | 8 + examples/store/DEMO.md | 17 +- examples/store/README.md | 15 +- examples/store/module.ts | 2 + examples/store/modules/catalog/contract.d.ts | 232 ++++++++++++++++++ examples/store/modules/catalog/contract.json | 192 +++++++++++++++ .../store/modules/catalog/contract.prisma | 16 ++ .../app/20260713T1212_init/end-contract.d.ts | 232 ++++++++++++++++++ .../app/20260713T1212_init/end-contract.json | 196 +++++++++++++++ .../app/20260713T1212_init/migration.json | 7 + .../app/20260713T1212_init/migration.ts | 38 +++ .../app/20260713T1212_init/ops.json | 80 ++++++ examples/store/modules/catalog/package.json | 4 +- .../modules/catalog/prisma-next.config.ts | 10 + examples/store/modules/catalog/src/data.ts | 12 + examples/store/modules/catalog/src/module.ts | 19 +- examples/store/modules/catalog/src/server.ts | 93 +++---- examples/store/modules/catalog/src/service.ts | 12 +- examples/store/modules/catalog/tsconfig.json | 2 +- .../store/modules/catalog/tsdown.config.ts | 2 +- examples/store/modules/orders/contract.d.ts | 230 +++++++++++++++++ examples/store/modules/orders/contract.json | 195 +++++++++++++++ examples/store/modules/orders/contract.prisma | 13 + .../app/20260713T1212_init/end-contract.d.ts | 230 +++++++++++++++++ .../app/20260713T1212_init/end-contract.json | 197 +++++++++++++++ .../app/20260713T1212_init/migration.json | 7 + .../app/20260713T1212_init/migration.ts | 38 +++ .../app/20260713T1212_init/ops.json | 41 ++++ examples/store/modules/orders/package.json | 4 +- .../modules/orders/prisma-next.config.ts | 10 + examples/store/modules/orders/src/data.ts | 11 + examples/store/modules/orders/src/module.ts | 16 +- examples/store/modules/orders/src/server.ts | 58 ++--- examples/store/modules/orders/src/service.ts | 12 +- examples/store/modules/orders/tsconfig.json | 2 +- .../store/modules/orders/tsdown.config.ts | 2 +- pnpm-lock.yaml | 12 + 37 files changed, 2124 insertions(+), 143 deletions(-) create mode 100644 examples/store/modules/catalog/contract.d.ts create mode 100644 examples/store/modules/catalog/contract.json create mode 100644 examples/store/modules/catalog/contract.prisma create mode 100644 examples/store/modules/catalog/migrations/app/20260713T1212_init/end-contract.d.ts create mode 100644 examples/store/modules/catalog/migrations/app/20260713T1212_init/end-contract.json create mode 100644 examples/store/modules/catalog/migrations/app/20260713T1212_init/migration.json create mode 100755 examples/store/modules/catalog/migrations/app/20260713T1212_init/migration.ts create mode 100644 examples/store/modules/catalog/migrations/app/20260713T1212_init/ops.json create mode 100644 examples/store/modules/catalog/prisma-next.config.ts create mode 100644 examples/store/modules/catalog/src/data.ts create mode 100644 examples/store/modules/orders/contract.d.ts create mode 100644 examples/store/modules/orders/contract.json create mode 100644 examples/store/modules/orders/contract.prisma create mode 100644 examples/store/modules/orders/migrations/app/20260713T1212_init/end-contract.d.ts create mode 100644 examples/store/modules/orders/migrations/app/20260713T1212_init/end-contract.json create mode 100644 examples/store/modules/orders/migrations/app/20260713T1212_init/migration.json create mode 100755 examples/store/modules/orders/migrations/app/20260713T1212_init/migration.ts create mode 100644 examples/store/modules/orders/migrations/app/20260713T1212_init/ops.json create mode 100644 examples/store/modules/orders/prisma-next.config.ts create mode 100644 examples/store/modules/orders/src/data.ts diff --git a/examples/store/.prisma-compose/alchemy.run.ts b/examples/store/.prisma-compose/alchemy.run.ts index a606fac9..e8e20c76 100644 --- a/examples/store/.prisma-compose/alchemy.run.ts +++ b/examples/store/.prisma-compose/alchemy.run.ts @@ -23,5 +23,13 @@ export default lower(app, config, { dir: '/Users/will/Projects/prisma/compose/.claude/worktrees/prisma-compose-demo-app-07178e/examples/store/modules/storefront/.next/standalone/examples/store/modules/storefront', entry: 'server.js', }, + 'cron.runner': { + dir: '/Users/will/Projects/prisma/compose/.claude/worktrees/prisma-compose-demo-app-07178e/examples/store/modules/promotions/dist/bundle', + entry: 'server.mjs', + }, + 'cron.scheduler': { + dir: '/Users/will/Projects/prisma/compose/.claude/worktrees/prisma-compose-demo-app-07178e/packages/9-public/compose-prisma-cloud/dist/cron/bundle', + entry: 'scheduler-entrypoint.mjs', + }, }, }); diff --git a/examples/store/DEMO.md b/examples/store/DEMO.md index 99415cbd..415323dc 100644 --- a/examples/store/DEMO.md +++ b/examples/store/DEMO.md @@ -16,10 +16,19 @@ runtime. ## 3. A module — [modules/catalog/src/module.ts](modules/catalog/src/module.ts) -catalog owns its own Postgres. The consumer never sees it — the module -exposes only the `rpc` port. Then [src/service.ts](modules/catalog/src/service.ts): -the service declares `deps: { db: postgres() }` and what it exposes; that's -the whole declaration. +catalog owns its own Postgres — a **Prisma Next-typed** one. Three pieces: + +- [contract.prisma](modules/catalog/contract.prisma): the data schema. Emit + turns it into a typed contract; `migration plan` authors + [migrations/](modules/catalog/migrations), which the deploy applies before + the service ever starts — no `create table if not exists` in app code. +- [src/service.ts](modules/catalog/src/service.ts): the service declares + `deps: { db: pnPostgres(catalogData) }` and what it exposes; that's the + whole declaration. +- [src/server.ts](modules/catalog/src/server.ts): `load()` hands it the typed + client — `db.orm.public.Product.where({ id }).first()`. No SQL strings, no + row mappers, and the same contract types the resource end (the deploy + refuses to wire a service against a database with a different contract). ## 4. The interesting edge — [modules/orders/src/module.ts](modules/orders/src/module.ts) diff --git a/examples/store/README.md b/examples/store/README.md index 9877e774..2cb4328b 100644 --- a/examples/store/README.md +++ b/examples/store/README.md @@ -40,12 +40,17 @@ typed RPC contracts. The whole composition is [module.ts](module.ts). ## What each piece shows - [modules/catalog](modules/catalog) — a self-contained Module: a contract - (`listProducts`/`getProduct`), a compute service, and a Postgres it owns and - seeds. Consumers wire only the exposed `rpc` port. + (`listProducts`/`getProduct`), a compute service, and a **Prisma + Next-typed Postgres** it owns. The data schema is + [contract.prisma](modules/catalog/contract.prisma); the deploy applies + [migrations/](modules/catalog/migrations) before the service starts, and + `load()` hands the server a typed client — queries like + `db.orm.public.Product.where({ id }).first()`, no SQL, no row mapping. + Consumers wire only the exposed `rpc` port. - [modules/orders](modules/orders) — a Module with a **boundary input**: it - owns its Postgres but declares `deps: { catalog }`, so whoever provisions it - supplies a producer of `catalogContract`. `placeOrder` calls catalog to - price the order at placement time. + owns its (also Prisma Next-typed) Postgres but declares `deps: { catalog }`, + so whoever provisions it supplies a producer of `catalogContract`. + `placeOrder` calls catalog to price the order at placement time. - [modules/storefront](modules/storefront) — a real Next.js app. The page calls both typed clients; the Buy button is a server action that places an order. diff --git a/examples/store/module.ts b/examples/store/module.ts index 4883882e..dfa04375 100644 --- a/examples/store/module.ts +++ b/examples/store/module.ts @@ -24,7 +24,9 @@ import storefrontService from '@store/storefront'; export default module('store', ({ provision }) => { const catalog = provision(catalogModule); const orders = provision(ordersModule, { deps: { catalog: catalog.rpc } }); + provision(storefrontService, { deps: { catalog: catalog.rpc, orders: orders.rpc } }); + provision(cron({ schedule, runner: promotionsService }), { deps: { catalog: catalog.rpc }, }); diff --git a/examples/store/modules/catalog/contract.d.ts b/examples/store/modules/catalog/contract.d.ts new file mode 100644 index 00000000..e747eddf --- /dev/null +++ b/examples/store/modules/catalog/contract.d.ts @@ -0,0 +1,232 @@ +// ⚠️ GENERATED FILE - DO NOT EDIT +// This file is automatically generated by 'prisma-next contract emit'. +// To regenerate, run: prisma-next contract emit +import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { + Bit, + Char, + CodecTypes as PgTypes, + Interval, + JsonValue, + Numeric, + Time, + Timestamp, + Timestamptz, + Timetz, + VarBit, + Varchar, +} from '@prisma-next/target-postgres/codec-types'; + +import type { + ContractWithTypeMaps, + TypeMaps as TypeMapsType, +} from '@prisma-next/sql-contract/types'; +import type { + Contract as ContractType, + ExecutionHashBase, + NamespaceId, + ProfileHashBase, + StorageHashBase, +} from '@prisma-next/contract/types'; + +export type StorageHash = + StorageHashBase<'sha256:454ca6e7e026e7ea3664b651ff39b82c327191d7cb6e55f5314b157d223305e9'>; +export type ExecutionHash = ExecutionHashBase; +export type ProfileHash = + ProfileHashBase<'sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb'>; + +export type CodecTypes = PgTypes; +export type LaneCodecTypes = CodecTypes; +export type QueryOperationTypes = PgAdapterQueryOps; +type DefaultLiteralValue = CodecId extends keyof CodecTypes + ? CodecTypes[CodecId]['output'] + : _Encoded; + +export type FieldOutputTypes = { + readonly public: { + readonly Product: { + readonly id: CodecTypes['pg/text@1']['output']; + readonly name: CodecTypes['pg/text@1']['output']; + readonly description: CodecTypes['pg/text@1']['output']; + readonly priceCents: CodecTypes['pg/int4@1']['output']; + }; + readonly Special: { + readonly id: CodecTypes['pg/int4@1']['output']; + readonly productId: CodecTypes['pg/text@1']['output']; + }; + }; +}; +export type FieldInputTypes = { + readonly public: { + readonly Product: { + readonly id: CodecTypes['pg/text@1']['input']; + readonly name: CodecTypes['pg/text@1']['input']; + readonly description: CodecTypes['pg/text@1']['input']; + readonly priceCents: CodecTypes['pg/int4@1']['input']; + }; + readonly Special: { + readonly id: CodecTypes['pg/int4@1']['input']; + readonly productId: CodecTypes['pg/text@1']['input']; + }; + }; +}; +export type TypeMaps = TypeMapsType< + CodecTypes, + QueryOperationTypes, + FieldOutputTypes, + FieldInputTypes +>; + +type ContractBase = Omit< + ContractType<{ + readonly namespaces: { + readonly public: { + readonly id: 'public'; + readonly kind: 'postgres-schema'; + readonly entries: { + readonly table: { + readonly product: { + columns: { + readonly id: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly name: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly description: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly priceCents: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + readonly special: { + columns: { + readonly id: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + readonly productId: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + }; + }; + }; + }; + readonly storageHash: StorageHash; + }>, + 'roots' | 'domain' +> & { + readonly target: 'postgres'; + readonly targetFamily: 'sql'; + readonly roots: { + readonly product: { readonly namespace: 'public' & NamespaceId; readonly model: 'Product' }; + readonly special: { readonly namespace: 'public' & NamespaceId; readonly model: 'Special' }; + }; + readonly domain: { + readonly namespaces: { + readonly public: { + readonly models: { + readonly Product: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly name: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly description: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly priceCents: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'product'; + readonly namespaceId: 'public'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly name: { readonly column: 'name' }; + readonly description: { readonly column: 'description' }; + readonly priceCents: { readonly column: 'priceCents' }; + }; + }; + }; + readonly Special: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly productId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'special'; + readonly namespaceId: 'public'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly productId: { readonly column: 'productId' }; + }; + }; + }; + }; + }; + }; + }; + readonly capabilities: { + readonly postgres: { + readonly distinctOn: true; + readonly jsonAgg: true; + readonly lateral: true; + readonly limit: true; + readonly orderBy: true; + readonly returning: true; + }; + readonly sql: { + readonly defaultInInsert: true; + readonly enums: true; + readonly lateral: true; + readonly returning: true; + }; + }; + readonly extensionPacks: {}; + readonly meta: {}; + + readonly profileHash: ProfileHash; +}; + +export type Contract = ContractWithTypeMaps; + +export type Namespaces = Contract['storage']['namespaces']; diff --git a/examples/store/modules/catalog/contract.json b/examples/store/modules/catalog/contract.json new file mode 100644 index 00000000..5bd83727 --- /dev/null +++ b/examples/store/modules/catalog/contract.json @@ -0,0 +1,192 @@ +{ + "schemaVersion": "1", + "targetFamily": "sql", + "target": "postgres", + "profileHash": "sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb", + "roots": { + "product": { + "model": "Product", + "namespace": "public" + }, + "special": { + "model": "Special", + "namespace": "public" + } + }, + "domain": { + "namespaces": { + "public": { + "models": { + "Product": { + "fields": { + "description": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "id": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "name": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "priceCents": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "description": { + "column": "description" + }, + "id": { + "column": "id" + }, + "name": { + "column": "name" + }, + "priceCents": { + "column": "priceCents" + } + }, + "namespaceId": "public", + "table": "product" + } + }, + "Special": { + "fields": { + "id": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + }, + "productId": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "id": { + "column": "id" + }, + "productId": { + "column": "productId" + } + }, + "namespaceId": "public", + "table": "special" + } + } + } + } + } + }, + "storage": { + "namespaces": { + "public": { + "entries": { + "table": { + "product": { + "columns": { + "description": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "id": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "name": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "priceCents": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": ["id"] + }, + "uniques": [] + }, + "special": { + "columns": { + "id": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + }, + "productId": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": ["id"] + }, + "uniques": [] + } + } + }, + "id": "public", + "kind": "postgres-schema" + } + }, + "storageHash": "sha256:454ca6e7e026e7ea3664b651ff39b82c327191d7cb6e55f5314b157d223305e9" + }, + "capabilities": { + "postgres": { + "distinctOn": true, + "jsonAgg": true, + "lateral": true, + "limit": true, + "orderBy": true, + "returning": true + }, + "sql": { + "defaultInInsert": true, + "enums": true, + "lateral": true, + "returning": true + } + }, + "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" + } +} diff --git a/examples/store/modules/catalog/contract.prisma b/examples/store/modules/catalog/contract.prisma new file mode 100644 index 00000000..b136ddd3 --- /dev/null +++ b/examples/store/modules/catalog/contract.prisma @@ -0,0 +1,16 @@ +// use prisma-next +// +// catalog's data contract: the menu, and a single-row Special pointing at the +// current special of the day (the promotions cron job advances it). + +model Product { + id String @id + name String + description String + priceCents Int +} + +model Special { + id Int @id + productId String +} diff --git a/examples/store/modules/catalog/migrations/app/20260713T1212_init/end-contract.d.ts b/examples/store/modules/catalog/migrations/app/20260713T1212_init/end-contract.d.ts new file mode 100644 index 00000000..e747eddf --- /dev/null +++ b/examples/store/modules/catalog/migrations/app/20260713T1212_init/end-contract.d.ts @@ -0,0 +1,232 @@ +// ⚠️ GENERATED FILE - DO NOT EDIT +// This file is automatically generated by 'prisma-next contract emit'. +// To regenerate, run: prisma-next contract emit +import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { + Bit, + Char, + CodecTypes as PgTypes, + Interval, + JsonValue, + Numeric, + Time, + Timestamp, + Timestamptz, + Timetz, + VarBit, + Varchar, +} from '@prisma-next/target-postgres/codec-types'; + +import type { + ContractWithTypeMaps, + TypeMaps as TypeMapsType, +} from '@prisma-next/sql-contract/types'; +import type { + Contract as ContractType, + ExecutionHashBase, + NamespaceId, + ProfileHashBase, + StorageHashBase, +} from '@prisma-next/contract/types'; + +export type StorageHash = + StorageHashBase<'sha256:454ca6e7e026e7ea3664b651ff39b82c327191d7cb6e55f5314b157d223305e9'>; +export type ExecutionHash = ExecutionHashBase; +export type ProfileHash = + ProfileHashBase<'sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb'>; + +export type CodecTypes = PgTypes; +export type LaneCodecTypes = CodecTypes; +export type QueryOperationTypes = PgAdapterQueryOps; +type DefaultLiteralValue = CodecId extends keyof CodecTypes + ? CodecTypes[CodecId]['output'] + : _Encoded; + +export type FieldOutputTypes = { + readonly public: { + readonly Product: { + readonly id: CodecTypes['pg/text@1']['output']; + readonly name: CodecTypes['pg/text@1']['output']; + readonly description: CodecTypes['pg/text@1']['output']; + readonly priceCents: CodecTypes['pg/int4@1']['output']; + }; + readonly Special: { + readonly id: CodecTypes['pg/int4@1']['output']; + readonly productId: CodecTypes['pg/text@1']['output']; + }; + }; +}; +export type FieldInputTypes = { + readonly public: { + readonly Product: { + readonly id: CodecTypes['pg/text@1']['input']; + readonly name: CodecTypes['pg/text@1']['input']; + readonly description: CodecTypes['pg/text@1']['input']; + readonly priceCents: CodecTypes['pg/int4@1']['input']; + }; + readonly Special: { + readonly id: CodecTypes['pg/int4@1']['input']; + readonly productId: CodecTypes['pg/text@1']['input']; + }; + }; +}; +export type TypeMaps = TypeMapsType< + CodecTypes, + QueryOperationTypes, + FieldOutputTypes, + FieldInputTypes +>; + +type ContractBase = Omit< + ContractType<{ + readonly namespaces: { + readonly public: { + readonly id: 'public'; + readonly kind: 'postgres-schema'; + readonly entries: { + readonly table: { + readonly product: { + columns: { + readonly id: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly name: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly description: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly priceCents: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + readonly special: { + columns: { + readonly id: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + readonly productId: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + }; + }; + }; + }; + readonly storageHash: StorageHash; + }>, + 'roots' | 'domain' +> & { + readonly target: 'postgres'; + readonly targetFamily: 'sql'; + readonly roots: { + readonly product: { readonly namespace: 'public' & NamespaceId; readonly model: 'Product' }; + readonly special: { readonly namespace: 'public' & NamespaceId; readonly model: 'Special' }; + }; + readonly domain: { + readonly namespaces: { + readonly public: { + readonly models: { + readonly Product: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly name: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly description: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly priceCents: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'product'; + readonly namespaceId: 'public'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly name: { readonly column: 'name' }; + readonly description: { readonly column: 'description' }; + readonly priceCents: { readonly column: 'priceCents' }; + }; + }; + }; + readonly Special: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly productId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'special'; + readonly namespaceId: 'public'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly productId: { readonly column: 'productId' }; + }; + }; + }; + }; + }; + }; + }; + readonly capabilities: { + readonly postgres: { + readonly distinctOn: true; + readonly jsonAgg: true; + readonly lateral: true; + readonly limit: true; + readonly orderBy: true; + readonly returning: true; + }; + readonly sql: { + readonly defaultInInsert: true; + readonly enums: true; + readonly lateral: true; + readonly returning: true; + }; + }; + readonly extensionPacks: {}; + readonly meta: {}; + + readonly profileHash: ProfileHash; +}; + +export type Contract = ContractWithTypeMaps; + +export type Namespaces = Contract['storage']['namespaces']; diff --git a/examples/store/modules/catalog/migrations/app/20260713T1212_init/end-contract.json b/examples/store/modules/catalog/migrations/app/20260713T1212_init/end-contract.json new file mode 100644 index 00000000..85ebd87b --- /dev/null +++ b/examples/store/modules/catalog/migrations/app/20260713T1212_init/end-contract.json @@ -0,0 +1,196 @@ +{ + "schemaVersion": "1", + "targetFamily": "sql", + "target": "postgres", + "profileHash": "sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb", + "roots": { + "product": { + "model": "Product", + "namespace": "public" + }, + "special": { + "model": "Special", + "namespace": "public" + } + }, + "domain": { + "namespaces": { + "public": { + "models": { + "Product": { + "fields": { + "description": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "id": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "name": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "priceCents": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "description": { + "column": "description" + }, + "id": { + "column": "id" + }, + "name": { + "column": "name" + }, + "priceCents": { + "column": "priceCents" + } + }, + "namespaceId": "public", + "table": "product" + } + }, + "Special": { + "fields": { + "id": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + }, + "productId": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "id": { + "column": "id" + }, + "productId": { + "column": "productId" + } + }, + "namespaceId": "public", + "table": "special" + } + } + } + } + } + }, + "storage": { + "namespaces": { + "public": { + "entries": { + "table": { + "product": { + "columns": { + "description": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "id": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "name": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "priceCents": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": [ + "id" + ] + }, + "uniques": [] + }, + "special": { + "columns": { + "id": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + }, + "productId": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": [ + "id" + ] + }, + "uniques": [] + } + } + }, + "id": "public", + "kind": "postgres-schema" + } + }, + "storageHash": "sha256:454ca6e7e026e7ea3664b651ff39b82c327191d7cb6e55f5314b157d223305e9" + }, + "capabilities": { + "postgres": { + "distinctOn": true, + "jsonAgg": true, + "lateral": true, + "limit": true, + "orderBy": true, + "returning": true + }, + "sql": { + "defaultInInsert": true, + "enums": true, + "lateral": true, + "returning": true + } + }, + "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/store/modules/catalog/migrations/app/20260713T1212_init/migration.json b/examples/store/modules/catalog/migrations/app/20260713T1212_init/migration.json new file mode 100644 index 00000000..8d8184f0 --- /dev/null +++ b/examples/store/modules/catalog/migrations/app/20260713T1212_init/migration.json @@ -0,0 +1,7 @@ +{ + "from": null, + "to": "sha256:454ca6e7e026e7ea3664b651ff39b82c327191d7cb6e55f5314b157d223305e9", + "providedInvariants": [], + "createdAt": "2026-07-13T12:12:35.384Z", + "migrationHash": "sha256:cbf4595c4fd6d9d71295956fb7ed98c58166e1c0fc1bfd1b3de298a5e6bd489c" +} \ No newline at end of file diff --git a/examples/store/modules/catalog/migrations/app/20260713T1212_init/migration.ts b/examples/store/modules/catalog/migrations/app/20260713T1212_init/migration.ts new file mode 100755 index 00000000..7a27663b --- /dev/null +++ b/examples/store/modules/catalog/migrations/app/20260713T1212_init/migration.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env -S bun +import { Migration, MigrationCLI, col, primaryKey } from '@prisma-next/postgres/migration'; + +export default class M extends Migration { + override describe() { + return { + from: null, + to: 'sha256:454ca6e7e026e7ea3664b651ff39b82c327191d7cb6e55f5314b157d223305e9', + }; + } + + override get operations() { + return [ + this.createTable({ + schema: 'public', + table: 'product', + columns: [ + col('description', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }), + col('id', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }), + col('name', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }), + col('priceCents', 'int4', { notNull: true, codecRef: { codecId: 'pg/int4@1' } }), + ], + constraints: [primaryKey(['id'])], + }), + this.createTable({ + schema: 'public', + table: 'special', + columns: [ + col('id', 'int4', { notNull: true, codecRef: { codecId: 'pg/int4@1' } }), + col('productId', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }), + ], + constraints: [primaryKey(['id'])], + }), + ]; + } +} + +MigrationCLI.run(import.meta.url, M); diff --git a/examples/store/modules/catalog/migrations/app/20260713T1212_init/ops.json b/examples/store/modules/catalog/migrations/app/20260713T1212_init/ops.json new file mode 100644 index 00000000..2bd06f95 --- /dev/null +++ b/examples/store/modules/catalog/migrations/app/20260713T1212_init/ops.json @@ -0,0 +1,80 @@ +[ + { + "id": "table.product", + "label": "Create table \"product\"", + "summary": "Creates table \"product\"", + "operationClass": "additive", + "target": { + "id": "postgres", + "details": { + "schema": "public", + "objectType": "table", + "name": "product" + } + }, + "precheck": [ + { + "description": "ensure table \"product\" does not exist", + "sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"", + "params": [ + "\"public\".\"product\"" + ] + } + ], + "execute": [ + { + "description": "create table \"product\"", + "sql": "CREATE TABLE \"public\".\"product\" (\n \"description\" text NOT NULL,\n \"id\" text NOT NULL,\n \"name\" text NOT NULL,\n \"priceCents\" int4 NOT NULL,\n PRIMARY KEY (\"id\")\n)", + "params": [] + } + ], + "postcheck": [ + { + "description": "verify table \"product\" exists", + "sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"", + "params": [ + "\"public\".\"product\"" + ] + } + ] + }, + { + "id": "table.special", + "label": "Create table \"special\"", + "summary": "Creates table \"special\"", + "operationClass": "additive", + "target": { + "id": "postgres", + "details": { + "schema": "public", + "objectType": "table", + "name": "special" + } + }, + "precheck": [ + { + "description": "ensure table \"special\" does not exist", + "sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"", + "params": [ + "\"public\".\"special\"" + ] + } + ], + "execute": [ + { + "description": "create table \"special\"", + "sql": "CREATE TABLE \"public\".\"special\" (\n \"id\" int4 NOT NULL,\n \"productId\" text NOT NULL,\n PRIMARY KEY (\"id\")\n)", + "params": [] + } + ], + "postcheck": [ + { + "description": "verify table \"special\" exists", + "sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"", + "params": [ + "\"public\".\"special\"" + ] + } + ] + } +] \ No newline at end of file diff --git a/examples/store/modules/catalog/package.json b/examples/store/modules/catalog/package.json index 580ce7d9..e3632786 100644 --- a/examples/store/modules/catalog/package.json +++ b/examples/store/modules/catalog/package.json @@ -14,7 +14,9 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "arktype": "^2.2.3" + "@prisma-next/postgres": "0.14.0", + "arktype": "^2.2.3", + "pg": "8.21.0" }, "peerDependencies": { "@prisma/compose": "workspace:0.1.0", diff --git a/examples/store/modules/catalog/prisma-next.config.ts b/examples/store/modules/catalog/prisma-next.config.ts new file mode 100644 index 00000000..13737a48 --- /dev/null +++ b/examples/store/modules/catalog/prisma-next.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from '@prisma-next/postgres/config'; + +// Anchors the contract source and migrations/ on disk. The deploy lowering +// loads it (by path, from the pnPostgres resource's `config`) to find the +// migrations — the app build never imports it. `db.connection` is dead +// weight: the framework injects the URL at hydrate (no-globals). +export default defineConfig({ + contract: './contract.prisma', + db: { connection: 'postgres://localhost:5432/placeholder' }, +}); diff --git a/examples/store/modules/catalog/src/data.ts b/examples/store/modules/catalog/src/data.ts new file mode 100644 index 00000000..27c2274b --- /dev/null +++ b/examples/store/modules/catalog/src/data.ts @@ -0,0 +1,12 @@ +/** + * catalog's Prisma Next data contract wrapped into the framework's + * `prisma-next` kind — the ONE value both ends reference: the resource end + * (`pnPostgres({ name, contract, config })` in module.ts) and the dependency + * end (`pnPostgres(catalogData)` in service.ts). Emitted from contract.prisma + * by `prisma-next contract emit`. + */ +import { pnContract } from '@prisma/compose-prisma-cloud/prisma-next'; +import type { Contract } from '../contract.d.ts'; +import contractJson from '../contract.json' with { type: 'json' }; + +export const catalogData = pnContract(contractJson); diff --git a/examples/store/modules/catalog/src/module.ts b/examples/store/modules/catalog/src/module.ts index bac787d1..cbc33b3f 100644 --- a/examples/store/modules/catalog/src/module.ts +++ b/examples/store/modules/catalog/src/module.ts @@ -1,21 +1,26 @@ /** - * The catalog Module: a reusable unit that owns its own Postgres. It - * provisions the database and the catalog compute service, wires the db into - * the service's `db` input, and exposes the service's `rpc` port as the - * Module's own output. A consumer wires only the exposed contract — it never - * sees the database. + * The catalog Module: a reusable unit that owns its own Postgres — a Prisma + * Next-typed one (ADR-0022). The resource carries two doors: `contract` + * (types + wires the resource, gives the deploy its target storageHash) and + * `config` (the prisma-next.config.ts PATH the deploy's migration step loads + * to find migrations/ — never imported by the app build). A consumer wires + * only the exposed rpc contract — it never sees the database. * * The database provision id is "database" (the Connection API rejects names * shorter than 3 characters); the service keeps an explicit "service" id so * it doesn't read as "catalog.catalog". */ +import { fileURLToPath } from 'node:url'; import { module } from '@prisma/compose'; -import { postgres } from '@prisma/compose-prisma-cloud'; +import { pnPostgres } from '@prisma/compose-prisma-cloud/prisma-next'; import { catalogContract } from './contract.ts'; +import { catalogData } from './data.ts'; import catalogService from './service.ts'; +const config = fileURLToPath(new URL('../prisma-next.config.ts', import.meta.url)); + export default module('catalog', { expose: { rpc: catalogContract } }, ({ provision }) => { - const db = provision(postgres({ name: 'database' })); + const db = provision(pnPostgres({ name: 'database', contract: catalogData, config })); const service = provision(catalogService, { id: 'service', deps: { db } }); return { rpc: service.rpc }; }); diff --git a/examples/store/modules/catalog/src/server.ts b/examples/store/modules/catalog/src/server.ts index 2808d363..6dd2e5cb 100644 --- a/examples/store/modules/catalog/src/server.ts +++ b/examples/store/modules/catalog/src/server.ts @@ -1,19 +1,15 @@ import { serve } from '@prisma/compose/rpc'; -import { SQL } from 'bun'; import type { Product } from './contract.ts'; import service from './service.ts'; -const { db } = service.load(); // db: PostgresConfig — the app owns its client +// load() hydrates `db` into the typed Prisma Next client (ADR-0022) — no SQL, +// no row mapping; queries are typed by contract.prisma's emitted contract. +const { db } = service.load(); const { port } = service.config(); -// One pool per process. idleTimeout closes the pooled connection before -// Compute's scale-to-zero drops it, so the next request reconnects instead of -// erroring (FT-5219). -const sql = new SQL({ url: db.url, max: 1, idleTimeout: 10 }); - -// A Prisma Postgres direct connection is closed when it goes idle. Bun.SQL -// surfaces that as an async error with no awaiter, which would otherwise -// crash the process into a restart loop. +// A Prisma Postgres direct connection is dropped when it idles / the service +// scales to zero; the lazy pool reconnects on the next query. Surface those +// as logs, not an uncaught crash into a 502 restart loop. process.on('uncaughtException', (err) => console.error('uncaughtException', err)); process.on('unhandledRejection', (err) => console.error('unhandledRejection', err)); @@ -39,71 +35,38 @@ const SEED: Product[] = [ { id: 'croissant', name: 'Croissant', description: 'Baked every morning.', priceCents: 400 }, ]; -await sql` - create table if not exists products ( - id text primary key, - name text not null, - description text not null, - price_cents integer not null - ) -`; -// Single-row table holding the current special of the day; the promotions -// cron job advances it via rotateSpecial. -await sql` - create table if not exists special ( - singleton boolean primary key default true, - product_id text not null - ) -`; +// Idempotent boot seed. The schema itself is NOT created here — the deploy's +// migration step applied migrations/ before this service ever started. for (const p of SEED) { - await sql` - insert into products (id, name, description, price_cents) - values (${p.id}, ${p.name}, ${p.description}, ${p.priceCents}) - on conflict (id) do nothing - `; + await db.orm.public.Product.upsert({ create: p, update: {} }); } -await sql` - insert into special (singleton, product_id) - values (true, ${SEED[0].id}) - on conflict (singleton) do nothing -`; - -const toProduct = (row: Record): Product => ({ - id: String(row.id), - name: String(row.name), - description: String(row.description), - priceCents: Number(row.price_cents), -}); +await db.orm.public.Special.upsert({ create: { id: 1, productId: SEED[0].id }, update: {} }); const handler = serve(service, { rpc: { - listProducts: async () => { - const rows = await sql`select * from products order by name`; - return { products: rows.map(toProduct) }; - }, - getProduct: async ({ id }) => { - const rows = await sql`select * from products where id = ${id}`; - return { product: rows.length > 0 ? toProduct(rows[0]) : null }; - }, + listProducts: async () => ({ + products: await db.orm.public.Product.orderBy((p) => p.name.asc()).all(), + }), + getProduct: async ({ id }) => ({ + product: (await db.orm.public.Product.where({ id }).first()) ?? null, + }), getSpecial: async () => { - const rows = await sql` - select p.* from special s join products p on p.id = s.product_id - `; - return { product: rows.length > 0 ? toProduct(rows[0]) : null }; + const special = await db.orm.public.Special.where({ id: 1 }).first(); + if (!special) return { product: null }; + const product = await db.orm.public.Product.where({ id: special.productId }).first(); + return { product: product ?? null }; }, rotateSpecial: async () => { - const products = await sql`select * from products order by name`; + const products = await db.orm.public.Product.orderBy((p) => p.name.asc()).all(); if (products.length === 0) return { product: null }; - const current = await sql`select product_id from special`; - const currentIdx = products.findIndex( - (p: Record) => p.id === current[0]?.product_id, - ); - const next = toProduct(products[(currentIdx + 1) % products.length]); - await sql` - insert into special (singleton, product_id) values (true, ${next.id}) - on conflict (singleton) do update set product_id = ${next.id} - `; + const special = await db.orm.public.Special.where({ id: 1 }).first(); + const currentIdx = products.findIndex((p) => p.id === special?.productId); + const next = products[(currentIdx + 1) % products.length]; + await db.orm.public.Special.upsert({ + create: { id: 1, productId: next.id }, + update: { productId: next.id }, + }); return { product: next }; }, }, diff --git a/examples/store/modules/catalog/src/service.ts b/examples/store/modules/catalog/src/service.ts index 0f943eaf..da99039b 100644 --- a/examples/store/modules/catalog/src/service.ts +++ b/examples/store/modules/catalog/src/service.ts @@ -1,14 +1,16 @@ import node from '@prisma/compose/node'; -import { compute, postgres } from '@prisma/compose-prisma-cloud'; +import { compute } from '@prisma/compose-prisma-cloud'; +import { pnPostgres } from '@prisma/compose-prisma-cloud/prisma-next'; import { catalogContract } from './contract.ts'; +import { catalogData } from './data.ts'; -// The `db` dependency is pure requirement: its binding is PostgresConfig -// (`{ url }`), and the app builds its own SQL client from it in server.ts -// (ADR-0015). +// The `db` dependency is Prisma Next-typed: `load()` returns the typed +// client the framework constructs from the contract + the injected URL +// (ADR-0022) — server.ts queries `db.orm.public.Product` directly. export default compute({ name: 'catalog', deps: { - db: postgres(), + db: pnPostgres(catalogData), }, build: node({ module: import.meta.url, entry: '../dist/server.mjs' }), expose: { rpc: catalogContract }, diff --git a/examples/store/modules/catalog/tsconfig.json b/examples/store/modules/catalog/tsconfig.json index 5b986d35..66ddc2c7 100644 --- a/examples/store/modules/catalog/tsconfig.json +++ b/examples/store/modules/catalog/tsconfig.json @@ -13,5 +13,5 @@ "resolveJsonModule": true, "types": ["bun"] }, - "include": ["src"] + "include": ["src", "contract.d.ts"] } diff --git a/examples/store/modules/catalog/tsdown.config.ts b/examples/store/modules/catalog/tsdown.config.ts index 109ae871..2e429471 100644 --- a/examples/store/modules/catalog/tsdown.config.ts +++ b/examples/store/modules/catalog/tsdown.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ format: 'esm', platform: 'node', external: ['bun'], - noExternal: [/^@prisma\//, /^arktype/], + noExternal: [/^@prisma\//, /^@prisma-next\//, /^arktype/, /^pg$/, /^pg-/, /^pathe$/], dts: false, sourcemap: false, clean: true, diff --git a/examples/store/modules/orders/contract.d.ts b/examples/store/modules/orders/contract.d.ts new file mode 100644 index 00000000..5d77d6df --- /dev/null +++ b/examples/store/modules/orders/contract.d.ts @@ -0,0 +1,230 @@ +// ⚠️ GENERATED FILE - DO NOT EDIT +// This file is automatically generated by 'prisma-next contract emit'. +// To regenerate, run: prisma-next contract emit +import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { + Bit, + Char, + CodecTypes as PgTypes, + Interval, + JsonValue, + Numeric, + Time, + Timestamp, + Timestamptz, + Timetz, + VarBit, + Varchar, +} from '@prisma-next/target-postgres/codec-types'; + +import type { + ContractWithTypeMaps, + TypeMaps as TypeMapsType, +} from '@prisma-next/sql-contract/types'; +import type { + Contract as ContractType, + ExecutionHashBase, + NamespaceId, + ProfileHashBase, + StorageHashBase, +} from '@prisma-next/contract/types'; + +export type StorageHash = + StorageHashBase<'sha256:c02a1b8e5e4db4eb71986673487f007e103ac093f5af4d5f4c6bb5b4ef84b5ce'>; +export type ExecutionHash = + ExecutionHashBase<'sha256:8e2d88824468b3baf7490ecbfc7147357b480cb98c89fab4b471b1300cec0f00'>; +export type ProfileHash = + ProfileHashBase<'sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb'>; + +export type CodecTypes = PgTypes; +export type LaneCodecTypes = CodecTypes; +export type QueryOperationTypes = PgAdapterQueryOps; +type DefaultLiteralValue = CodecId extends keyof CodecTypes + ? CodecTypes[CodecId]['output'] + : _Encoded; + +export type FieldOutputTypes = { + readonly public: { + readonly Order: { + readonly id: Char<36>; + readonly productId: CodecTypes['pg/text@1']['output']; + readonly productName: CodecTypes['pg/text@1']['output']; + readonly quantity: CodecTypes['pg/int4@1']['output']; + readonly totalCents: CodecTypes['pg/int4@1']['output']; + readonly placedAt: CodecTypes['pg/timestamptz@1']['output']; + }; + }; +}; +export type FieldInputTypes = { + readonly public: { + readonly Order: { + readonly id: CodecTypes['sql/char@1']['input']; + readonly productId: CodecTypes['pg/text@1']['input']; + readonly productName: CodecTypes['pg/text@1']['input']; + readonly quantity: CodecTypes['pg/int4@1']['input']; + readonly totalCents: CodecTypes['pg/int4@1']['input']; + readonly placedAt: CodecTypes['pg/timestamptz@1']['input']; + }; + }; +}; +export type TypeMaps = TypeMapsType< + CodecTypes, + QueryOperationTypes, + FieldOutputTypes, + FieldInputTypes +>; + +type ContractBase = Omit< + ContractType<{ + readonly namespaces: { + readonly public: { + readonly id: 'public'; + readonly kind: 'postgres-schema'; + readonly entries: { + readonly table: { + readonly order: { + columns: { + readonly id: { + readonly nativeType: 'character'; + readonly codecId: 'sql/char@1'; + readonly nullable: false; + readonly typeParams: { readonly length: 36 }; + }; + readonly productId: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly productName: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly quantity: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + readonly totalCents: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + readonly placedAt: { + 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 storageHash: StorageHash; + }>, + 'roots' | 'domain' +> & { + readonly target: 'postgres'; + readonly targetFamily: 'sql'; + readonly roots: { + readonly order: { readonly namespace: 'public' & NamespaceId; readonly model: 'Order' }; + }; + readonly domain: { + readonly namespaces: { + readonly public: { + readonly models: { + readonly Order: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'sql/char@1'; + readonly typeParams: { readonly length: 36 }; + }; + }; + readonly productId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly productName: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly quantity: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly totalCents: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly placedAt: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/timestamptz@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'order'; + readonly namespaceId: 'public'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly productId: { readonly column: 'productId' }; + readonly productName: { readonly column: 'productName' }; + readonly quantity: { readonly column: 'quantity' }; + readonly totalCents: { readonly column: 'totalCents' }; + readonly placedAt: { readonly column: 'placedAt' }; + }; + }; + }; + }; + }; + }; + }; + readonly capabilities: { + readonly postgres: { + readonly distinctOn: true; + readonly jsonAgg: true; + readonly lateral: true; + readonly limit: true; + readonly orderBy: true; + readonly returning: true; + }; + readonly sql: { + readonly defaultInInsert: true; + readonly enums: true; + readonly lateral: true; + readonly returning: true; + }; + }; + readonly extensionPacks: {}; + readonly execution: { + readonly executionHash: ExecutionHash; + readonly mutations: { + readonly defaults: readonly [ + { + readonly ref: { + readonly namespace: 'public'; + readonly table: 'order'; + readonly column: 'id'; + }; + readonly onCreate: { readonly kind: 'generator'; readonly id: 'uuidv4' }; + }, + ]; + }; + }; + readonly meta: {}; + + readonly profileHash: ProfileHash; +}; + +export type Contract = ContractWithTypeMaps; + +export type Namespaces = Contract['storage']['namespaces']; diff --git a/examples/store/modules/orders/contract.json b/examples/store/modules/orders/contract.json new file mode 100644 index 00000000..992cdbfe --- /dev/null +++ b/examples/store/modules/orders/contract.json @@ -0,0 +1,195 @@ +{ + "schemaVersion": "1", + "targetFamily": "sql", + "target": "postgres", + "profileHash": "sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb", + "roots": { + "order": { + "model": "Order", + "namespace": "public" + } + }, + "domain": { + "namespaces": { + "public": { + "models": { + "Order": { + "fields": { + "id": { + "nullable": false, + "type": { + "codecId": "sql/char@1", + "kind": "scalar", + "typeParams": { + "length": 36 + } + } + }, + "placedAt": { + "nullable": false, + "type": { + "codecId": "pg/timestamptz@1", + "kind": "scalar" + } + }, + "productId": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "productName": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "quantity": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + }, + "totalCents": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "id": { + "column": "id" + }, + "placedAt": { + "column": "placedAt" + }, + "productId": { + "column": "productId" + }, + "productName": { + "column": "productName" + }, + "quantity": { + "column": "quantity" + }, + "totalCents": { + "column": "totalCents" + } + }, + "namespaceId": "public", + "table": "order" + } + } + } + } + } + }, + "storage": { + "namespaces": { + "public": { + "entries": { + "table": { + "order": { + "columns": { + "id": { + "codecId": "sql/char@1", + "nativeType": "character", + "nullable": false, + "typeParams": { + "length": 36 + } + }, + "placedAt": { + "codecId": "pg/timestamptz@1", + "default": { + "expression": "now()", + "kind": "function" + }, + "nativeType": "timestamptz", + "nullable": false + }, + "productId": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "productName": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "quantity": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + }, + "totalCents": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": ["id"] + }, + "uniques": [] + } + } + }, + "id": "public", + "kind": "postgres-schema" + } + }, + "storageHash": "sha256:c02a1b8e5e4db4eb71986673487f007e103ac093f5af4d5f4c6bb5b4ef84b5ce" + }, + "execution": { + "executionHash": "sha256:8e2d88824468b3baf7490ecbfc7147357b480cb98c89fab4b471b1300cec0f00", + "mutations": { + "defaults": [ + { + "onCreate": { + "id": "uuidv4", + "kind": "generator" + }, + "ref": { + "column": "id", + "namespace": "public", + "table": "order" + } + } + ] + } + }, + "capabilities": { + "postgres": { + "distinctOn": true, + "jsonAgg": true, + "lateral": true, + "limit": true, + "orderBy": true, + "returning": true + }, + "sql": { + "defaultInInsert": true, + "enums": true, + "lateral": true, + "returning": true + } + }, + "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" + } +} diff --git a/examples/store/modules/orders/contract.prisma b/examples/store/modules/orders/contract.prisma new file mode 100644 index 00000000..188f2fe5 --- /dev/null +++ b/examples/store/modules/orders/contract.prisma @@ -0,0 +1,13 @@ +// use prisma-next +// +// orders' data contract: one Order per purchase, snapshotting the product's +// name and price at placement time. + +model Order { + id String @id @default(uuid(4)) + productId String + productName String + quantity Int + totalCents Int + placedAt DateTime @default(now()) +} diff --git a/examples/store/modules/orders/migrations/app/20260713T1212_init/end-contract.d.ts b/examples/store/modules/orders/migrations/app/20260713T1212_init/end-contract.d.ts new file mode 100644 index 00000000..5d77d6df --- /dev/null +++ b/examples/store/modules/orders/migrations/app/20260713T1212_init/end-contract.d.ts @@ -0,0 +1,230 @@ +// ⚠️ GENERATED FILE - DO NOT EDIT +// This file is automatically generated by 'prisma-next contract emit'. +// To regenerate, run: prisma-next contract emit +import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { + Bit, + Char, + CodecTypes as PgTypes, + Interval, + JsonValue, + Numeric, + Time, + Timestamp, + Timestamptz, + Timetz, + VarBit, + Varchar, +} from '@prisma-next/target-postgres/codec-types'; + +import type { + ContractWithTypeMaps, + TypeMaps as TypeMapsType, +} from '@prisma-next/sql-contract/types'; +import type { + Contract as ContractType, + ExecutionHashBase, + NamespaceId, + ProfileHashBase, + StorageHashBase, +} from '@prisma-next/contract/types'; + +export type StorageHash = + StorageHashBase<'sha256:c02a1b8e5e4db4eb71986673487f007e103ac093f5af4d5f4c6bb5b4ef84b5ce'>; +export type ExecutionHash = + ExecutionHashBase<'sha256:8e2d88824468b3baf7490ecbfc7147357b480cb98c89fab4b471b1300cec0f00'>; +export type ProfileHash = + ProfileHashBase<'sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb'>; + +export type CodecTypes = PgTypes; +export type LaneCodecTypes = CodecTypes; +export type QueryOperationTypes = PgAdapterQueryOps; +type DefaultLiteralValue = CodecId extends keyof CodecTypes + ? CodecTypes[CodecId]['output'] + : _Encoded; + +export type FieldOutputTypes = { + readonly public: { + readonly Order: { + readonly id: Char<36>; + readonly productId: CodecTypes['pg/text@1']['output']; + readonly productName: CodecTypes['pg/text@1']['output']; + readonly quantity: CodecTypes['pg/int4@1']['output']; + readonly totalCents: CodecTypes['pg/int4@1']['output']; + readonly placedAt: CodecTypes['pg/timestamptz@1']['output']; + }; + }; +}; +export type FieldInputTypes = { + readonly public: { + readonly Order: { + readonly id: CodecTypes['sql/char@1']['input']; + readonly productId: CodecTypes['pg/text@1']['input']; + readonly productName: CodecTypes['pg/text@1']['input']; + readonly quantity: CodecTypes['pg/int4@1']['input']; + readonly totalCents: CodecTypes['pg/int4@1']['input']; + readonly placedAt: CodecTypes['pg/timestamptz@1']['input']; + }; + }; +}; +export type TypeMaps = TypeMapsType< + CodecTypes, + QueryOperationTypes, + FieldOutputTypes, + FieldInputTypes +>; + +type ContractBase = Omit< + ContractType<{ + readonly namespaces: { + readonly public: { + readonly id: 'public'; + readonly kind: 'postgres-schema'; + readonly entries: { + readonly table: { + readonly order: { + columns: { + readonly id: { + readonly nativeType: 'character'; + readonly codecId: 'sql/char@1'; + readonly nullable: false; + readonly typeParams: { readonly length: 36 }; + }; + readonly productId: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly productName: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly quantity: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + readonly totalCents: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + readonly placedAt: { + 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 storageHash: StorageHash; + }>, + 'roots' | 'domain' +> & { + readonly target: 'postgres'; + readonly targetFamily: 'sql'; + readonly roots: { + readonly order: { readonly namespace: 'public' & NamespaceId; readonly model: 'Order' }; + }; + readonly domain: { + readonly namespaces: { + readonly public: { + readonly models: { + readonly Order: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'sql/char@1'; + readonly typeParams: { readonly length: 36 }; + }; + }; + readonly productId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly productName: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly quantity: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly totalCents: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly placedAt: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/timestamptz@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'order'; + readonly namespaceId: 'public'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly productId: { readonly column: 'productId' }; + readonly productName: { readonly column: 'productName' }; + readonly quantity: { readonly column: 'quantity' }; + readonly totalCents: { readonly column: 'totalCents' }; + readonly placedAt: { readonly column: 'placedAt' }; + }; + }; + }; + }; + }; + }; + }; + readonly capabilities: { + readonly postgres: { + readonly distinctOn: true; + readonly jsonAgg: true; + readonly lateral: true; + readonly limit: true; + readonly orderBy: true; + readonly returning: true; + }; + readonly sql: { + readonly defaultInInsert: true; + readonly enums: true; + readonly lateral: true; + readonly returning: true; + }; + }; + readonly extensionPacks: {}; + readonly execution: { + readonly executionHash: ExecutionHash; + readonly mutations: { + readonly defaults: readonly [ + { + readonly ref: { + readonly namespace: 'public'; + readonly table: 'order'; + readonly column: 'id'; + }; + readonly onCreate: { readonly kind: 'generator'; readonly id: 'uuidv4' }; + }, + ]; + }; + }; + readonly meta: {}; + + readonly profileHash: ProfileHash; +}; + +export type Contract = ContractWithTypeMaps; + +export type Namespaces = Contract['storage']['namespaces']; diff --git a/examples/store/modules/orders/migrations/app/20260713T1212_init/end-contract.json b/examples/store/modules/orders/migrations/app/20260713T1212_init/end-contract.json new file mode 100644 index 00000000..43e92eb6 --- /dev/null +++ b/examples/store/modules/orders/migrations/app/20260713T1212_init/end-contract.json @@ -0,0 +1,197 @@ +{ + "schemaVersion": "1", + "targetFamily": "sql", + "target": "postgres", + "profileHash": "sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb", + "roots": { + "order": { + "model": "Order", + "namespace": "public" + } + }, + "domain": { + "namespaces": { + "public": { + "models": { + "Order": { + "fields": { + "id": { + "nullable": false, + "type": { + "codecId": "sql/char@1", + "kind": "scalar", + "typeParams": { + "length": 36 + } + } + }, + "placedAt": { + "nullable": false, + "type": { + "codecId": "pg/timestamptz@1", + "kind": "scalar" + } + }, + "productId": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "productName": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "quantity": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + }, + "totalCents": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "id": { + "column": "id" + }, + "placedAt": { + "column": "placedAt" + }, + "productId": { + "column": "productId" + }, + "productName": { + "column": "productName" + }, + "quantity": { + "column": "quantity" + }, + "totalCents": { + "column": "totalCents" + } + }, + "namespaceId": "public", + "table": "order" + } + } + } + } + } + }, + "storage": { + "namespaces": { + "public": { + "entries": { + "table": { + "order": { + "columns": { + "id": { + "codecId": "sql/char@1", + "nativeType": "character", + "nullable": false, + "typeParams": { + "length": 36 + } + }, + "placedAt": { + "codecId": "pg/timestamptz@1", + "default": { + "expression": "now()", + "kind": "function" + }, + "nativeType": "timestamptz", + "nullable": false + }, + "productId": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "productName": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "quantity": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + }, + "totalCents": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": [ + "id" + ] + }, + "uniques": [] + } + } + }, + "id": "public", + "kind": "postgres-schema" + } + }, + "storageHash": "sha256:c02a1b8e5e4db4eb71986673487f007e103ac093f5af4d5f4c6bb5b4ef84b5ce" + }, + "execution": { + "executionHash": "sha256:8e2d88824468b3baf7490ecbfc7147357b480cb98c89fab4b471b1300cec0f00", + "mutations": { + "defaults": [ + { + "onCreate": { + "id": "uuidv4", + "kind": "generator" + }, + "ref": { + "column": "id", + "namespace": "public", + "table": "order" + } + } + ] + } + }, + "capabilities": { + "postgres": { + "distinctOn": true, + "jsonAgg": true, + "lateral": true, + "limit": true, + "orderBy": true, + "returning": true + }, + "sql": { + "defaultInInsert": true, + "enums": true, + "lateral": true, + "returning": true + } + }, + "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/store/modules/orders/migrations/app/20260713T1212_init/migration.json b/examples/store/modules/orders/migrations/app/20260713T1212_init/migration.json new file mode 100644 index 00000000..e8bb1cbd --- /dev/null +++ b/examples/store/modules/orders/migrations/app/20260713T1212_init/migration.json @@ -0,0 +1,7 @@ +{ + "from": null, + "to": "sha256:c02a1b8e5e4db4eb71986673487f007e103ac093f5af4d5f4c6bb5b4ef84b5ce", + "providedInvariants": [], + "createdAt": "2026-07-13T12:12:52.644Z", + "migrationHash": "sha256:d8ce0a69e6df0a866f3b0a0569a0661cda7a316a7a5c41297922d3074f93ffbd" +} \ No newline at end of file diff --git a/examples/store/modules/orders/migrations/app/20260713T1212_init/migration.ts b/examples/store/modules/orders/migrations/app/20260713T1212_init/migration.ts new file mode 100755 index 00000000..16696253 --- /dev/null +++ b/examples/store/modules/orders/migrations/app/20260713T1212_init/migration.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env -S bun +import { Migration, MigrationCLI, col, fn, primaryKey } from '@prisma-next/postgres/migration'; + +export default class M extends Migration { + override describe() { + return { + from: null, + to: 'sha256:c02a1b8e5e4db4eb71986673487f007e103ac093f5af4d5f4c6bb5b4ef84b5ce', + }; + } + + override get operations() { + return [ + this.createTable({ + schema: 'public', + table: 'order', + columns: [ + col('id', 'character(36)', { + notNull: true, + codecRef: { codecId: 'sql/char@1', typeParams: { length: 36 } }, + }), + col('placedAt', 'timestamptz', { + notNull: true, + default: fn('now()'), + codecRef: { codecId: 'pg/timestamptz@1' }, + }), + col('productId', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }), + col('productName', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }), + col('quantity', 'int4', { notNull: true, codecRef: { codecId: 'pg/int4@1' } }), + col('totalCents', 'int4', { notNull: true, codecRef: { codecId: 'pg/int4@1' } }), + ], + constraints: [primaryKey(['id'])], + }), + ]; + } +} + +MigrationCLI.run(import.meta.url, M); diff --git a/examples/store/modules/orders/migrations/app/20260713T1212_init/ops.json b/examples/store/modules/orders/migrations/app/20260713T1212_init/ops.json new file mode 100644 index 00000000..6ab78ede --- /dev/null +++ b/examples/store/modules/orders/migrations/app/20260713T1212_init/ops.json @@ -0,0 +1,41 @@ +[ + { + "id": "table.order", + "label": "Create table \"order\"", + "summary": "Creates table \"order\"", + "operationClass": "additive", + "target": { + "id": "postgres", + "details": { + "schema": "public", + "objectType": "table", + "name": "order" + } + }, + "precheck": [ + { + "description": "ensure table \"order\" does not exist", + "sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"", + "params": [ + "\"public\".\"order\"" + ] + } + ], + "execute": [ + { + "description": "create table \"order\"", + "sql": "CREATE TABLE \"public\".\"order\" (\n \"id\" character(36) NOT NULL,\n \"placedAt\" timestamptz DEFAULT (now()) NOT NULL,\n \"productId\" text NOT NULL,\n \"productName\" text NOT NULL,\n \"quantity\" int4 NOT NULL,\n \"totalCents\" int4 NOT NULL,\n PRIMARY KEY (\"id\")\n)", + "params": [] + } + ], + "postcheck": [ + { + "description": "verify table \"order\" exists", + "sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"", + "params": [ + "\"public\".\"order\"" + ] + } + ] + } +] \ No newline at end of file diff --git a/examples/store/modules/orders/package.json b/examples/store/modules/orders/package.json index ca0bec22..9ffecd0b 100644 --- a/examples/store/modules/orders/package.json +++ b/examples/store/modules/orders/package.json @@ -14,8 +14,10 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@prisma-next/postgres": "0.14.0", "@store/catalog": "workspace:0.1.0", - "arktype": "^2.2.3" + "arktype": "^2.2.3", + "pg": "8.21.0" }, "peerDependencies": { "@prisma/compose": "workspace:0.1.0", diff --git a/examples/store/modules/orders/prisma-next.config.ts b/examples/store/modules/orders/prisma-next.config.ts new file mode 100644 index 00000000..13737a48 --- /dev/null +++ b/examples/store/modules/orders/prisma-next.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from '@prisma-next/postgres/config'; + +// Anchors the contract source and migrations/ on disk. The deploy lowering +// loads it (by path, from the pnPostgres resource's `config`) to find the +// migrations — the app build never imports it. `db.connection` is dead +// weight: the framework injects the URL at hydrate (no-globals). +export default defineConfig({ + contract: './contract.prisma', + db: { connection: 'postgres://localhost:5432/placeholder' }, +}); diff --git a/examples/store/modules/orders/src/data.ts b/examples/store/modules/orders/src/data.ts new file mode 100644 index 00000000..32cfd82c --- /dev/null +++ b/examples/store/modules/orders/src/data.ts @@ -0,0 +1,11 @@ +/** + * orders' Prisma Next data contract wrapped into the framework's + * `prisma-next` kind — referenced by both the resource end (module.ts) and + * the dependency end (service.ts). Emitted from contract.prisma by + * `prisma-next contract emit`. + */ +import { pnContract } from '@prisma/compose-prisma-cloud/prisma-next'; +import type { Contract } from '../contract.d.ts'; +import contractJson from '../contract.json' with { type: 'json' }; + +export const ordersData = pnContract(contractJson); diff --git a/examples/store/modules/orders/src/module.ts b/examples/store/modules/orders/src/module.ts index 14476b50..14cb76a8 100644 --- a/examples/store/modules/orders/src/module.ts +++ b/examples/store/modules/orders/src/module.ts @@ -1,21 +1,25 @@ /** - * The orders Module: owns its own Postgres, but NOT the catalog — that comes - * in through the module's boundary (`deps.catalog`), wired by whoever - * provisions this module. The consumer supplies any producer of - * `catalogContract`; orders never knows which. + * The orders Module: owns its own Prisma Next-typed Postgres (ADR-0022), but + * NOT the catalog — that comes in through the module's boundary + * (`deps.catalog`), wired by whoever provisions this module. The consumer + * supplies any producer of `catalogContract`; orders never knows which. */ +import { fileURLToPath } from 'node:url'; import { module } from '@prisma/compose'; import { rpc } from '@prisma/compose/rpc'; -import { postgres } from '@prisma/compose-prisma-cloud'; +import { pnPostgres } from '@prisma/compose-prisma-cloud/prisma-next'; import { catalogContract } from '@store/catalog/contract'; import { ordersContract } from './contract.ts'; +import { ordersData } from './data.ts'; import ordersService from './service.ts'; +const config = fileURLToPath(new URL('../prisma-next.config.ts', import.meta.url)); + export default module( 'orders', { deps: { catalog: rpc(catalogContract) }, expose: { rpc: ordersContract } }, ({ inputs, provision }) => { - const db = provision(postgres({ name: 'database' })); + const db = provision(pnPostgres({ name: 'database', contract: ordersData, config })); const service = provision(ordersService, { id: 'service', deps: { db, catalog: inputs.catalog }, diff --git a/examples/store/modules/orders/src/server.ts b/examples/store/modules/orders/src/server.ts index ff8508b3..d90942e4 100644 --- a/examples/store/modules/orders/src/server.ts +++ b/examples/store/modules/orders/src/server.ts @@ -1,60 +1,38 @@ import { serve } from '@prisma/compose/rpc'; -import { SQL } from 'bun'; -import type { Order } from './contract.ts'; import service from './service.ts'; -// load() hydrates both deps: db is a PostgresConfig, catalog is a typed -// client of catalogContract — calling it is a plain async function call. +// load() hydrates both deps: `db` is the Prisma Next typed client (ADR-0022), +// `catalog` a typed client of catalogContract — both plain async calls. const { db, catalog } = service.load(); const { port } = service.config(); -// One pool per process. idleTimeout closes the pooled connection before -// Compute's scale-to-zero drops it, so the next request reconnects instead of -// erroring (FT-5219). -const sql = new SQL({ url: db.url, max: 1, idleTimeout: 10 }); - -// A Prisma Postgres direct connection is closed when it goes idle. Bun.SQL -// surfaces that as an async error with no awaiter, which would otherwise -// crash the process into a restart loop. +// A Prisma Postgres direct connection is dropped when it idles / the service +// scales to zero; the lazy pool reconnects on the next query. Surface those +// as logs, not an uncaught crash into a 502 restart loop. process.on('uncaughtException', (err) => console.error('uncaughtException', err)); process.on('unhandledRejection', (err) => console.error('unhandledRejection', err)); -await sql` - create table if not exists orders ( - id text primary key, - product_id text not null, - product_name text not null, - quantity integer not null, - total_cents integer not null, - placed_at timestamptz not null default now() - ) -`; - -const toOrder = (row: Record): Order => ({ - id: String(row.id), - productId: String(row.product_id), - productName: String(row.product_name), - quantity: Number(row.quantity), - totalCents: Number(row.total_cents), - placedAt: new Date(String(row.placed_at)).toISOString(), -}); - const handler = serve(service, { rpc: { placeOrder: async ({ productId, quantity }) => { const { product } = await catalog.getProduct({ id: productId }); if (product === null || quantity < 1) return { order: null }; - const rows = await sql` - insert into orders (id, product_id, product_name, quantity, total_cents) - values (${crypto.randomUUID()}, ${product.id}, ${product.name}, ${quantity}, ${product.priceCents * quantity}) - returning * - `; - return { order: toOrder(rows[0]) }; + const row = await db.orm.public.Order.create({ + productId: product.id, + productName: product.name, + quantity, + totalCents: product.priceCents * quantity, + }); + return { order: { ...row, placedAt: new Date(row.placedAt).toISOString() } }; }, listOrders: async () => { - const rows = await sql`select * from orders order by placed_at desc limit 20`; - return { orders: rows.map(toOrder) }; + const rows = await db.orm.public.Order.orderBy((o) => o.placedAt.desc()) + .take(20) + .all(); + return { + orders: rows.map((o) => ({ ...o, placedAt: new Date(o.placedAt).toISOString() })), + }; }, }, }); diff --git a/examples/store/modules/orders/src/service.ts b/examples/store/modules/orders/src/service.ts index 376a510c..cc8093d2 100644 --- a/examples/store/modules/orders/src/service.ts +++ b/examples/store/modules/orders/src/service.ts @@ -1,16 +1,18 @@ import node from '@prisma/compose/node'; import { rpc } from '@prisma/compose/rpc'; -import { compute, postgres } from '@prisma/compose-prisma-cloud'; +import { compute } from '@prisma/compose-prisma-cloud'; +import { pnPostgres } from '@prisma/compose-prisma-cloud/prisma-next'; import { catalogContract } from '@store/catalog/contract'; import { ordersContract } from './contract.ts'; +import { ordersData } from './data.ts'; -// Two dependencies, two kinds: `db` binds to PostgresConfig (the app builds -// its own client, ADR-0015); `catalog` hydrates to a typed client of another -// module's contract. +// Two dependencies, two kinds: `db` hydrates to the Prisma Next typed client +// (ADR-0022); `catalog` hydrates to a typed client of another module's rpc +// contract. export default compute({ name: 'orders', deps: { - db: postgres(), + db: pnPostgres(ordersData), catalog: rpc(catalogContract), }, build: node({ module: import.meta.url, entry: '../dist/server.mjs' }), diff --git a/examples/store/modules/orders/tsconfig.json b/examples/store/modules/orders/tsconfig.json index 5b986d35..66ddc2c7 100644 --- a/examples/store/modules/orders/tsconfig.json +++ b/examples/store/modules/orders/tsconfig.json @@ -13,5 +13,5 @@ "resolveJsonModule": true, "types": ["bun"] }, - "include": ["src"] + "include": ["src", "contract.d.ts"] } diff --git a/examples/store/modules/orders/tsdown.config.ts b/examples/store/modules/orders/tsdown.config.ts index c0325ced..aa55554f 100644 --- a/examples/store/modules/orders/tsdown.config.ts +++ b/examples/store/modules/orders/tsdown.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ format: 'esm', platform: 'node', external: ['bun'], - noExternal: [/^@prisma\//, /^@store\//, /^arktype/], + noExternal: [/^@prisma\//, /^@prisma-next\//, /^@store\//, /^arktype/, /^pg$/, /^pg-/, /^pathe$/], dts: false, sourcemap: false, clean: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b3c6530..13894b6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,9 +143,15 @@ importers: examples/store/modules/catalog: dependencies: + '@prisma-next/postgres': + specifier: 0.14.0 + version: 0.14.0(typanion@3.14.0)(typescript@6.0.3) arktype: specifier: ^2.2.3 version: 2.2.3 + pg: + specifier: 8.21.0 + version: 8.21.0 devDependencies: '@prisma/compose': specifier: workspace:0.1.0 @@ -165,12 +171,18 @@ importers: examples/store/modules/orders: dependencies: + '@prisma-next/postgres': + specifier: 0.14.0 + version: 0.14.0(typanion@3.14.0)(typescript@6.0.3) '@store/catalog': specifier: workspace:0.1.0 version: link:../catalog arktype: specifier: ^2.2.3 version: 2.2.3 + pg: + specifier: 8.21.0 + version: 8.21.0 devDependencies: '@prisma/compose': specifier: workspace:0.1.0 From 8edf65ba3d530ef0e3dba5e55a1b62ef2e4bf4a8 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 15:11:11 +0200 Subject: [PATCH 5/9] fix(prisma-next): pass the pool as an explicit binding; declare the pg dep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildClient handed the runtime `pg: resilientPool(url)`, whose binding sniff is `instanceof Pool` — broken whenever a bundle carries two copies of pg. It did, since the package consolidation: the public dist builds with skipNodeModulesBundle: false, and pg was a phantom dep (imported by @internal/prisma-cloud, declared by nobody), so it was baked into the public dist chunk while @prisma-next/postgres resolved a second copy at app-bundle time. Every pnPostgres service then crash-looped at boot with "Unable to determine pg binding type". Two fixes: the explicit `binding: { kind: "pgPool", pool }` form skips class-identity sniffing entirely, and declaring pg where it is imported externalizes it from the public dist, deduplicating the bundle. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- packages/1-prisma-cloud/1-extensions/target/package.json | 3 ++- .../1-prisma-cloud/1-extensions/target/src/prisma-next.ts | 5 ++++- packages/9-public/compose-prisma-cloud/package.json | 1 + pnpm-lock.yaml | 6 ++++++ 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/1-prisma-cloud/1-extensions/target/package.json b/packages/1-prisma-cloud/1-extensions/target/package.json index 6ecfa038..9c435c04 100644 --- a/packages/1-prisma-cloud/1-extensions/target/package.json +++ b/packages/1-prisma-cloud/1-extensions/target/package.json @@ -44,7 +44,8 @@ "alchemy": "2.0.0-beta.59", "arktype": "^2.2.3", "effect": "4.0.0-beta.92", - "pathe": "^2.0.3" + "pathe": "^2.0.3", + "pg": "8.21.0" }, "devDependencies": { "@prisma-next/target-postgres": "0.14.0", diff --git a/packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts b/packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts index 7cd55498..869d2d19 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts @@ -148,7 +148,10 @@ function isPnPostgresContract(value: unknown): value is PnPostgresContract { function buildClient(contract: C, url: string): Client { return pnPostgresRuntime>({ contractJson: contract.__cmp.contractJson, - pg: resilientPool(url), + // Explicit binding, NOT `pg: pool`: the bare form sniffs the pool with + // `instanceof`, which breaks whenever a bundle carries two copies of pg + // (the pool from one, the runtime's Pool class from the other). + binding: { kind: 'pgPool', pool: resilientPool(url) }, }); } diff --git a/packages/9-public/compose-prisma-cloud/package.json b/packages/9-public/compose-prisma-cloud/package.json index 3ccd99d5..f5887ccc 100644 --- a/packages/9-public/compose-prisma-cloud/package.json +++ b/packages/9-public/compose-prisma-cloud/package.json @@ -33,6 +33,7 @@ "arktype": "^2.2.3", "effect": "4.0.0-beta.92", "pathe": "^2.0.3", + "pg": "8.21.0", "postgres": "^3.4.9", "tsdown": "^0.22.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13894b6d..667fbdef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -640,6 +640,9 @@ importers: pathe: specifier: ^2.0.3 version: 2.0.3 + pg: + specifier: 8.21.0 + version: 8.21.0 devDependencies: '@internal/tsdown-config': specifier: workspace:0.1.0 @@ -802,6 +805,9 @@ importers: pathe: specifier: ^2.0.3 version: 2.0.3 + pg: + specifier: 8.21.0 + version: 8.21.0 postgres: specifier: ^3.4.9 version: 3.4.9 From 00885205020b4d061215e74d8558032cb23f3de7 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 16:12:56 +0200 Subject: [PATCH 6/9] fix(prisma-next): drop idle pool clients before Prisma Postgres does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prisma Postgres closes idle direct connections well under 30s (FT-5219), so resilientPool with idleTimeoutMillis: 30_000 kept dead sockets: the first query after an idle spell failed with "Connection terminated unexpectedly" — surfacing at query() time, past retryTransientConnect, as an RPC 500. Match the proven 5s idle window and log (rather than die on) the pool's async idle-client errors. Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .../1-extensions/target/src/prisma-next.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts b/packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts index 869d2d19..29679fbe 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts @@ -164,8 +164,17 @@ function resilientPool(url: string): pg.Pool { const pool = new pg.Pool({ connectionString: normalizeSslMode(url), connectionTimeoutMillis: 20_000, - idleTimeoutMillis: 30_000, + // Prisma Postgres closes idle direct connections well under 30s + // (FT-5219). Discard idle clients first, or the first query after an + // idle spell grabs a dead socket and fails with "Connection terminated + // unexpectedly" — a 500, since it surfaces at query() time where + // retryTransientConnect (which wraps only connect()) can't help. + idleTimeoutMillis: 5_000, }); + // The server closing an idle pooled client emits an async 'error' on the + // pool; unhandled, that crashes the process. Log it — the pool already + // discards the dead client and reconnects on the next acquire. + pool.on('error', (err) => console.error('pg pool idle client error', err)); const acquire = pool.connect.bind(pool); pool.connect = blindCast< typeof pool.connect, From c5c84aedb4472eab12e0441066b54baaef5ddfc1 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 18:10:23 +0200 Subject: [PATCH 7/9] chore: gitignore the generated .prisma-compose/ stack files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lint-staged auto-staged examples/store/.prisma-compose/alchemy.run.ts — a deploy-generated file full of machine-local absolute paths, regenerated on every run (same class as the already-ignored .makerkit/). Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- .gitignore | 4 +++ examples/store/.prisma-compose/alchemy.run.ts | 35 ------------------- 2 files changed, 4 insertions(+), 35 deletions(-) delete mode 100644 examples/store/.prisma-compose/alchemy.run.ts diff --git a/.gitignore b/.gitignore index 8caf5508..5a232e1e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,10 @@ packages/e2e-tests/.tmp-output # `makerkit deploy`/`destroy`'s generated stack file (deploy-cli.md) — regenerated every run. .makerkit/ +# `prisma-compose deploy`/`destroy`'s generated stack file — regenerated every +# run, full of machine-local absolute paths. +.prisma-compose/ + # Local clone of prisma-next used as a scaffolding reference; not part of this repo. /prisma-next/ diff --git a/examples/store/.prisma-compose/alchemy.run.ts b/examples/store/.prisma-compose/alchemy.run.ts deleted file mode 100644 index e8e20c76..00000000 --- a/examples/store/.prisma-compose/alchemy.run.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Generated by `prisma-compose deploy`/`prisma-compose destroy` — overwritten on every -// run; do not edit by hand. Independently runnable from "/Users/will/Projects/prisma/compose/.claude/worktrees/prisma-compose-demo-app-07178e/examples/store": -// -// alchemy deploy .prisma-compose/alchemy.run.ts -// -// bisects a CLI bug from an Alchemy bug (deploy-cli.md § Implementation decisions). -import { lower } from '@prisma/compose/deploy'; -import app from '../module.ts'; -import config from '../prisma-compose.config.ts'; - -export default lower(app, config, { - name: 'store', - bundles: { - 'catalog.service': { - dir: '/Users/will/Projects/prisma/compose/.claude/worktrees/prisma-compose-demo-app-07178e/examples/store/modules/catalog/dist/bundle', - entry: 'server.mjs', - }, - 'orders.service': { - dir: '/Users/will/Projects/prisma/compose/.claude/worktrees/prisma-compose-demo-app-07178e/examples/store/modules/orders/dist/bundle', - entry: 'server.mjs', - }, - storefront: { - dir: '/Users/will/Projects/prisma/compose/.claude/worktrees/prisma-compose-demo-app-07178e/examples/store/modules/storefront/.next/standalone/examples/store/modules/storefront', - entry: 'server.js', - }, - 'cron.runner': { - dir: '/Users/will/Projects/prisma/compose/.claude/worktrees/prisma-compose-demo-app-07178e/examples/store/modules/promotions/dist/bundle', - entry: 'server.mjs', - }, - 'cron.scheduler': { - dir: '/Users/will/Projects/prisma/compose/.claude/worktrees/prisma-compose-demo-app-07178e/packages/9-public/compose-prisma-cloud/dist/cron/bundle', - entry: 'scheduler-entrypoint.mjs', - }, - }, -}); From 9898ef94e593f82f97fa51c4c3f63be86f48d791 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 18:13:33 +0200 Subject: [PATCH 8/9] docs(gotchas): idle-close on pooled pg clients; ECONNRESET during cold starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two entries from deploying examples/store: Prisma Postgres closing idle direct connections under a pool's idle timeout (the FT-5219 family, pooled-client variant — one 500 after every idle spell), and intermittent ECONNRESET on service-to-service HTTP while the target cold-starts from scale-to-zero. Linear tickets still to be filed (marked in the entries). Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- gotchas.md | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/gotchas.md b/gotchas.md index efa3abbf..3a715d72 100644 --- a/gotchas.md +++ b/gotchas.md @@ -27,6 +27,8 @@ The capture workflow is the Ignite `product-record-gotcha` skill. - [Branch create has no idempotency — a duplicate gitName 409s, with no create-or-return](#branch-create-has-no-idempotency--a-duplicate-gitname-409s-with-no-create-or-return) - [A project-scoped compute-service create lands on the default branch — and collides with production](#a-project-scoped-compute-service-create-lands-on-the-default-branch--and-collides-with-production) - [First connection to a freshly-provisioned Postgres is rejected while the upstream is cold — breaks deploy-time migrations](#first-connection-to-a-freshly-provisioned-postgres-is-rejected-while-the-upstream-is-cold--breaks-deploy-time-migrations) +- [Idle direct-connection close kills pooled node-postgres clients — first query after idle 500s with "Connection terminated unexpectedly"](#idle-direct-connection-close-kills-pooled-node-postgres-clients--first-query-after-idle-500s-with-connection-terminated-unexpectedly) +- [Service-to-service HTTP gets ECONNRESET while the target cold-starts from scale-to-zero](#service-to-service-http-gets-econnreset-while-the-target-cold-starts-from-scale-to-zero) --- @@ -329,3 +331,68 @@ await withConnectionRetry(() => client.dbInit(...), { attempts: 12, delayMs: 500 - Workaround source: [`packages/app-cloud/src/prisma-next-migrate.ts`](packages/app-cloud/src/prisma-next-migrate.ts) (`withConnectionRetry`) - Removal guard: the CI canary (`examples/scripts/cold-connect-canary.ts`, "Cold-connect canary" E2E job) passes only while the rejection exists — when the platform fixes FT-5226 it goes red, forcing removal of `withConnectionRetry` and itself - Related: [FT-5219](https://linear.app/prisma-company/issue/FT-5219) (idle-close, runtime), [PRO-212](https://linear.app/prisma-company/issue/PRO-212) (nested endpoint DSNs) + +--- + +## Idle direct-connection close kills pooled node-postgres clients — first query after idle 500s with "Connection terminated unexpectedly" + +**Filed upstream:** _not yet filed_ (target: [`compute-gotchas`](https://linear.app/prisma-company/project/compute-gotchas-dd3ac34b5ad4/overview)) +**Product:** Prisma Postgres idle-close × Prisma Compute scale-to-zero (the FT-5219 family, pooled-client variant) +**Version:** `pg` 8.21.0 `Pool` (via `@prisma-next/postgres` 0.14.0); PPg direct connection; Prisma Compute (scale-to-zero) +**First hit:** `examples/store` — the orders service after an idle spell; presented as the storefront rendering Next's error page +**Cost:** ~1 hour — two separate "the demo URL is down" reports before the service logs were pulled + +**Symptom.** A service using a node-postgres `Pool` works right after deploy, then after sitting idle its first DB-backed request fails once with `Connection terminated unexpectedly` (surfaced here as an RPC 500; the consumer's SSR render then 500s). The next request works. No crash loop — just a reliable one-request failure after every idle spell. + +**Cause.** Prisma Postgres closes idle direct connections well under 30 s. A pool with a longer idle timeout (`idleTimeoutMillis: 30_000` here) keeps the dead socket checked in and hands it to the next query. Unlike FT-5219's persistent Bun.SQL client the process survives, and unlike FT-5226 the failure surfaces at `query()` time on an already-established connection — so a connect-time retry (`retryTransientConnect` wrapping `pool.connect()`) never engages. The pool's async idle-client `'error'` event is also unhandled by default, which turns the close into a process crash if no `uncaughtException` guard exists. + +**Workaround.** Keep the pool's idle timeout under the platform's idle-close window, and attach a pool error handler so the close is logged rather than fatal: + +```ts +const pool = new pg.Pool({ connectionString, idleTimeoutMillis: 5_000 }); +pool.on("error", (err) => console.error("pg pool idle client error", err)); +``` + +**Reproduction.** + +1. Deploy a Compute service holding a module-scope `pg.Pool` (default or 30 s `idleTimeoutMillis`) on a PPg direct connection, with a route that queries. +2. Hit the route → 200. Let it idle ≥ 30 s. +3. Hit again → one failure with `Connection terminated unexpectedly`; the following request → 200. + +**References.** + +- Fix in this repo: `resilientPool` in [`packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts`](packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts) (commit `0088520`) +- Related: [FT-5219](https://linear.app/prisma-company/issue/FT-5219) (same idle-close, persistent Bun.SQL client → 502 loop), [FT-5226](https://linear.app/prisma-company/issue/FT-5226) (same cold/idle family, deploy-time first connect) + +--- + +## Service-to-service HTTP gets ECONNRESET while the target cold-starts from scale-to-zero + +**Filed upstream:** _not yet filed_ (target: [`compute-gotchas`](https://linear.app/prisma-company/project/compute-gotchas-dd3ac34b5ad4/overview)) +**Product:** Prisma Compute (ingress / scale-to-zero cold start) +**Version:** Prisma Compute, Bun `fetch` from a Next.js standalone SSR render; observed 2026-07-13 +**First hit:** `examples/store` — the storefront's SSR calls to the catalog and orders services' `*.ewr.prisma.build` endpoints +**Cost:** folded into the idle-500 diagnosis above; intermittent enough to first read as "the in-app browser is flaky" + +**Symptom.** An HTTP request from one Compute service to another intermittently fails with `ECONNRESET` — Bun reports `The socket connection was closed unexpectedly` with the target service's URL as `path`. It happens on the first request(s) after the target has been idle; a retry moments later succeeds. When the caller is an SSR page fanning out to several services, one reset is enough to 500 the whole page render. + +**Cause (observed, mechanism presumed).** The target service had scaled to zero. Instead of the edge holding the connection until the VM finishes booting (which it does do on most cold hits — those requests just take seconds), the connection is sometimes closed mid-establishment during the cold-start window, surfacing as a socket reset to the caller. Warm targets never reset. + +**Workaround.** No principled client-side fix for non-idempotent calls (blind retry could double-execute a write). Mitigations: + +- retry only requests that never reached the server / are idempotent reads; +- keep chatty targets warm — a scheduled ping (the `cron` shared module's 30 s trigger) masks the window for whatever it touches; +- warm the whole app with one request before a demo. + +An always-on / min-instances option on Compute services would remove the window; none exists today. + +**Reproduction.** + +1. Deploy two Compute services, A calling B over HTTP on each request to A. +2. Let B idle to scale-to-zero. +3. Hit A repeatedly right as B cold-starts → occasional `ECONNRESET` from A's fetch to B; warm B never resets. + +**References.** + +- Observed in `storefront` runtime logs (`app logs --project store --app storefront`): `code: 'ECONNRESET', path: 'https://….ewr.prisma.build/rpc/listProducts'` +- Related: [FT-5219](https://linear.app/prisma-company/issue/FT-5219) / FT-5226 (the DB faces of the same scale-to-zero/cold family) From 496f60f30343c1bcb5502d2c487f14252b061755 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 13 Jul 2026 18:41:55 +0200 Subject: [PATCH 9/9] docs(gotchas): link PRO-216 and PRO-217 for the new compute entries Co-Authored-By: Claude Fable 5 Signed-off-by: willbot Signed-off-by: Will Madden --- gotchas.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gotchas.md b/gotchas.md index 3a715d72..24b2bf8a 100644 --- a/gotchas.md +++ b/gotchas.md @@ -336,7 +336,7 @@ await withConnectionRetry(() => client.dbInit(...), { attempts: 12, delayMs: 500 ## Idle direct-connection close kills pooled node-postgres clients — first query after idle 500s with "Connection terminated unexpectedly" -**Filed upstream:** _not yet filed_ (target: [`compute-gotchas`](https://linear.app/prisma-company/project/compute-gotchas-dd3ac34b5ad4/overview)) +**Filed upstream:** [PRO-216](https://linear.app/prisma-company/issue/PRO-216/idle-direct-connection-close-kills-pooled-node-postgres-clients-first) — _"Idle direct-connection close kills pooled node-postgres clients — first query after idle fails with 'Connection terminated unexpectedly'"_ **Product:** Prisma Postgres idle-close × Prisma Compute scale-to-zero (the FT-5219 family, pooled-client variant) **Version:** `pg` 8.21.0 `Pool` (via `@prisma-next/postgres` 0.14.0); PPg direct connection; Prisma Compute (scale-to-zero) **First hit:** `examples/store` — the orders service after an idle spell; presented as the storefront rendering Next's error page @@ -368,7 +368,7 @@ pool.on("error", (err) => console.error("pg pool idle client error", err)); ## Service-to-service HTTP gets ECONNRESET while the target cold-starts from scale-to-zero -**Filed upstream:** _not yet filed_ (target: [`compute-gotchas`](https://linear.app/prisma-company/project/compute-gotchas-dd3ac34b5ad4/overview)) +**Filed upstream:** [PRO-217](https://linear.app/prisma-company/issue/PRO-217/service-to-service-http-gets-econnreset-while-the-target-cold-starts) — _"Service-to-service HTTP gets ECONNRESET while the target cold-starts from scale-to-zero"_ **Product:** Prisma Compute (ingress / scale-to-zero cold start) **Version:** Prisma Compute, Bun `fetch` from a Next.js standalone SSR render; observed 2026-07-13 **First hit:** `examples/store` — the storefront's SSR calls to the catalog and orders services' `*.ewr.prisma.build` endpoints